repl: package for TF REPL

This commit is contained in:
Mitchell Hashimoto 2016-11-13 22:04:21 -08:00
parent 1a8fbdc428
commit d9c522173d
No known key found for this signature in database
GPG Key ID: 744E147AA52F5B0A
4 changed files with 384 additions and 0 deletions

92
repl/format.go Normal file
View File

@ -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
repl/repl.go Normal file
View File

@ -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
repl/session.go Normal file
View File

@ -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
repl/session_test.go Normal file
View File

@ -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
}