Skip to content

Commit

Permalink
repl: package for TF REPL
Browse files Browse the repository at this point in the history
  • Loading branch information
mitchellh committed Nov 14, 2016
1 parent 1a8fbdc commit d9c5221
Show file tree
Hide file tree
Showing 4 changed files with 384 additions and 0 deletions.
92 changes: 92 additions & 0 deletions repl/format.go
Original file line number Diff line number Diff line change
@@ -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()
}
4 changes: 4 additions & 0 deletions repl/repl.go
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions repl/session.go
Original file line number Diff line number Diff line change
@@ -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
}
193 changes: 193 additions & 0 deletions repl/session_test.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit d9c5221

Please sign in to comment.