diff --git a/repl/format.go b/repl/format.go new file mode 100644 index 000000000..c94b35281 --- /dev/null +++ b/repl/format.go @@ -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() +} diff --git a/repl/repl.go b/repl/repl.go new file mode 100644 index 000000000..92df9c3d1 --- /dev/null +++ b/repl/repl.go @@ -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 diff --git a/repl/session.go b/repl/session.go new file mode 100644 index 000000000..ef0cea15f --- /dev/null +++ b/repl/session.go @@ -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 to see the result. + +To exit the console, type "exit" and hit , or use Control-C or +Control-D. +` + + return strings.TrimSpace(text), nil +} diff --git a/repl/session_test.go b/repl/session_test.go new file mode 100644 index 000000000..5936c61aa --- /dev/null +++ b/repl/session_test.go @@ -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 +}