repl: package for TF REPL
This commit is contained in:
parent
1a8fbdc428
commit
d9c522173d
|
@ -0,0 +1,92 @@
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatResult formats the given result value for human-readable output.
|
||||||
|
//
|
||||||
|
// The value must currently be a string, list, map, and any nested values
|
||||||
|
// with those same types.
|
||||||
|
func FormatResult(value interface{}) (string, error) {
|
||||||
|
return formatResult(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatResult(value interface{}) (string, error) {
|
||||||
|
switch output := value.(type) {
|
||||||
|
case string:
|
||||||
|
return output, nil
|
||||||
|
case []interface{}:
|
||||||
|
return formatListResult(output)
|
||||||
|
case map[string]interface{}:
|
||||||
|
return formatMapResult(output)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown value type: %T", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatListResult(value []interface{}) (string, error) {
|
||||||
|
var outputBuf bytes.Buffer
|
||||||
|
outputBuf.WriteString("[")
|
||||||
|
if len(value) > 0 {
|
||||||
|
outputBuf.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIdx := len(value) - 1
|
||||||
|
for i, v := range value {
|
||||||
|
raw, err := formatResult(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
outputBuf.WriteString(indent(raw))
|
||||||
|
if lastIdx != i {
|
||||||
|
outputBuf.WriteString(",")
|
||||||
|
}
|
||||||
|
outputBuf.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
outputBuf.WriteString("]")
|
||||||
|
return outputBuf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMapResult(value map[string]interface{}) (string, error) {
|
||||||
|
ks := make([]string, 0, len(value))
|
||||||
|
for k, _ := range value {
|
||||||
|
ks = append(ks, k)
|
||||||
|
}
|
||||||
|
sort.Strings(ks)
|
||||||
|
|
||||||
|
var outputBuf bytes.Buffer
|
||||||
|
outputBuf.WriteString("{")
|
||||||
|
if len(value) > 0 {
|
||||||
|
outputBuf.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range ks {
|
||||||
|
v := value[k]
|
||||||
|
raw, err := formatResult(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
outputBuf.WriteString(indent(fmt.Sprintf("%s = %v\n", k, raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
outputBuf.WriteString("}")
|
||||||
|
return outputBuf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(value string) string {
|
||||||
|
var outputBuf bytes.Buffer
|
||||||
|
s := bufio.NewScanner(strings.NewReader(value))
|
||||||
|
for s.Scan() {
|
||||||
|
outputBuf.WriteString(" " + s.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputBuf.String()
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Package repl provides the structs and functions necessary to run
|
||||||
|
// REPL for Terraform. The REPL allows experimentation of Terraform
|
||||||
|
// interpolations without having to run a Terraform configuration.
|
||||||
|
package repl
|
|
@ -0,0 +1,95 @@
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/config"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrSessionExit is a special error result that should be checked for
|
||||||
|
// from Handle to signal a graceful exit.
|
||||||
|
var ErrSessionExit = errors.New("session exit")
|
||||||
|
|
||||||
|
// Session represents the state for a single REPL session.
|
||||||
|
type Session struct {
|
||||||
|
// Interpolater is used for calculating interpolations
|
||||||
|
Interpolater *terraform.Interpolater
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle handles a single line of input from the REPL.
|
||||||
|
//
|
||||||
|
// This is a stateful operation if a command is given (such as setting
|
||||||
|
// a variable). This function should not be called in parallel.
|
||||||
|
//
|
||||||
|
// The return value is the output and the error to show.
|
||||||
|
func (s *Session) Handle(line string) (string, error) {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(line) == "exit":
|
||||||
|
return "", ErrSessionExit
|
||||||
|
case strings.TrimSpace(line) == "help":
|
||||||
|
return s.handleHelp()
|
||||||
|
default:
|
||||||
|
return s.handleEval(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) handleEval(line string) (string, error) {
|
||||||
|
// Wrap the line to make it an interpolation.
|
||||||
|
line = fmt.Sprintf("${%s}", line)
|
||||||
|
|
||||||
|
// Parse the line
|
||||||
|
raw, err := config.NewRawConfig(map[string]interface{}{
|
||||||
|
"value": line,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the value
|
||||||
|
raw.Key = "value"
|
||||||
|
|
||||||
|
// Get the values
|
||||||
|
vars, err := s.Interpolater.Values(&terraform.InterpolationScope{
|
||||||
|
Path: []string{"root"},
|
||||||
|
}, raw.Variables)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpolate
|
||||||
|
if err := raw.Interpolate(vars); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have any unknown keys, let the user know.
|
||||||
|
if ks := raw.UnknownKeys(); len(ks) > 0 {
|
||||||
|
return "", fmt.Errorf("unknown values referenced, can't compute value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the value
|
||||||
|
result, err := FormatResult(raw.Value())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) handleHelp() (string, error) {
|
||||||
|
text := `
|
||||||
|
The Terraform console allows you to experiment with Terraform interpolations.
|
||||||
|
You may access resources in the state (if you have one) just as you would
|
||||||
|
from a configuration. For example: "aws_instance.foo.id" would evaluate
|
||||||
|
to the ID of "aws_instance.foo" if it exists in your state.
|
||||||
|
|
||||||
|
Type in the interpolation to test and hit <enter> to see the result.
|
||||||
|
|
||||||
|
To exit the console, type "exit" and hit <enter>, or use Control-C or
|
||||||
|
Control-D.
|
||||||
|
`
|
||||||
|
|
||||||
|
return strings.TrimSpace(text), nil
|
||||||
|
}
|
|
@ -0,0 +1,193 @@
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/config/module"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSession_basicState(t *testing.T) {
|
||||||
|
state := &terraform.State{
|
||||||
|
Modules: []*terraform.ModuleState{
|
||||||
|
&terraform.ModuleState{
|
||||||
|
Path: []string{"root"},
|
||||||
|
Resources: map[string]*terraform.ResourceState{
|
||||||
|
"test_instance.foo": &terraform.ResourceState{
|
||||||
|
Type: "test_instance",
|
||||||
|
Primary: &terraform.InstanceState{
|
||||||
|
ID: "bar",
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"id": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("basic", func(t *testing.T) {
|
||||||
|
testSession(t, testSessionTest{
|
||||||
|
State: state,
|
||||||
|
Inputs: []testSessionInput{
|
||||||
|
{
|
||||||
|
Input: "test_instance.foo.id",
|
||||||
|
Output: "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("resource count", func(t *testing.T) {
|
||||||
|
testSession(t, testSessionTest{
|
||||||
|
State: state,
|
||||||
|
Inputs: []testSessionInput{
|
||||||
|
{
|
||||||
|
Input: "test_instance.foo.count",
|
||||||
|
Output: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing resource", func(t *testing.T) {
|
||||||
|
testSession(t, testSessionTest{
|
||||||
|
State: state,
|
||||||
|
Inputs: []testSessionInput{
|
||||||
|
{
|
||||||
|
Input: "test_instance.bar.id",
|
||||||
|
Error: true,
|
||||||
|
ErrorContains: "'test_instance.bar' not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSession_stateless(t *testing.T) {
|
||||||
|
t.Run("exit", func(t *testing.T) {
|
||||||
|
testSession(t, testSessionTest{
|
||||||
|
Inputs: []testSessionInput{
|
||||||
|
{
|
||||||
|
Input: "exit",
|
||||||
|
Error: true,
|
||||||
|
ErrorContains: ErrSessionExit.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("help", func(t *testing.T) {
|
||||||
|
testSession(t, testSessionTest{
|
||||||
|
Inputs: []testSessionInput{
|
||||||
|
{
|
||||||
|
Input: "help",
|
||||||
|
OutputContains: "allows you to",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("help with spaces", func(t *testing.T) {
|
||||||
|
testSession(t, testSessionTest{
|
||||||
|
Inputs: []testSessionInput{
|
||||||
|
{
|
||||||
|
Input: "help ",
|
||||||
|
OutputContains: "allows you to",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("basic math", func(t *testing.T) {
|
||||||
|
testSession(t, testSessionTest{
|
||||||
|
Inputs: []testSessionInput{
|
||||||
|
{
|
||||||
|
Input: "1 + 5",
|
||||||
|
Output: "6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing resource", func(t *testing.T) {
|
||||||
|
testSession(t, testSessionTest{
|
||||||
|
Inputs: []testSessionInput{
|
||||||
|
{
|
||||||
|
Input: "test_instance.bar.id",
|
||||||
|
Error: true,
|
||||||
|
ErrorContains: "'test_instance.bar' not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSession(t *testing.T, test testSessionTest) {
|
||||||
|
// Build the TF context
|
||||||
|
ctx, err := terraform.NewContext(&terraform.ContextOpts{
|
||||||
|
State: test.State,
|
||||||
|
Module: module.NewEmptyTree(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the session
|
||||||
|
s := &Session{
|
||||||
|
Interpolater: ctx.Interpolater(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the inputs. We purposely don't use subtests here because
|
||||||
|
// the inputs don't recognize subtests, but a sequence of stateful
|
||||||
|
// operations.
|
||||||
|
for _, input := range test.Inputs {
|
||||||
|
result, err := s.Handle(input.Input)
|
||||||
|
if (err != nil) != input.Error {
|
||||||
|
t.Fatalf("%q: err: %s", input.Input, err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if input.ErrorContains != "" {
|
||||||
|
if !strings.Contains(err.Error(), input.ErrorContains) {
|
||||||
|
t.Fatalf(
|
||||||
|
"%q: err should contain: %q\n\n%s",
|
||||||
|
input.Input, input.ErrorContains, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Output != "" && result != input.Output {
|
||||||
|
t.Fatalf(
|
||||||
|
"%q: expected:\n\n%s\n\ngot:\n\n%s",
|
||||||
|
input.Input, input.Output, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.OutputContains != "" && !strings.Contains(result, input.OutputContains) {
|
||||||
|
t.Fatalf(
|
||||||
|
"%q: expected contains:\n\n%s\n\ngot:\n\n%s",
|
||||||
|
input.Input, input.OutputContains, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testSessionTest struct {
|
||||||
|
State *terraform.State // State to use
|
||||||
|
Module string // Module name in test-fixtures to load
|
||||||
|
|
||||||
|
// Inputs are the list of test inputs that are run in order.
|
||||||
|
// Each input can test the output of each step.
|
||||||
|
Inputs []testSessionInput
|
||||||
|
}
|
||||||
|
|
||||||
|
// testSessionInput is a single input to test for a session.
|
||||||
|
type testSessionInput struct {
|
||||||
|
Input string // Input string
|
||||||
|
Output string // Exact output string to check
|
||||||
|
OutputContains string
|
||||||
|
Error bool // Error is true if error is expected
|
||||||
|
ErrorContains string
|
||||||
|
}
|
Loading…
Reference in New Issue