215 lines
5.7 KiB
Go
215 lines
5.7 KiB
Go
package testing
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"os/exec"
|
||
|
|
"sync"
|
||
|
|
|
||
|
|
tfjson "github.com/hashicorp/terraform-json"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestTerraformer is an object representing the lifecycle of a
|
||
|
|
// particular Terraform project.
|
||
|
|
//
|
||
|
|
// Note that Terraform must be installed in the system PATH (not to
|
||
|
|
// be confused with the working directory below). This object does
|
||
|
|
// not manage anything with regards to installation or version
|
||
|
|
// management.
|
||
|
|
type TestTerraformer struct {
|
||
|
|
// The full path to the Terraform binary, set during initialization
|
||
|
|
// to the binary found in the system path.
|
||
|
|
tfPath string
|
||
|
|
|
||
|
|
// The working directory for Terraform commands.
|
||
|
|
workDir string
|
||
|
|
|
||
|
|
// The Terraform state. Use the Output method to look up particular
|
||
|
|
// outputs from the Terraform state.
|
||
|
|
state *tfjson.State
|
||
|
|
|
||
|
|
// Keeps track of whether or not we have run the init command
|
||
|
|
// during this run of Terraform. This only needs to be done once.
|
||
|
|
// Do not use initErr directly; it stores the init error if one was
|
||
|
|
// encountered on init.
|
||
|
|
initOnce sync.Once
|
||
|
|
initErr error
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewTestTerraformer initializes a Terraform directory. It just
|
||
|
|
// returns an initialized project, it does not perform any actions on
|
||
|
|
// it.
|
||
|
|
func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
|
||
|
|
tfPath, err := exec.LookPath("terraform")
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("LookPath error: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if workDir == "" {
|
||
|
|
return nil, errors.New("workDir is empty")
|
||
|
|
}
|
||
|
|
|
||
|
|
return &TestTerraformer{
|
||
|
|
tfPath: tfPath,
|
||
|
|
workDir: workDir,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Deploy runs the following commands, in order: init, apply, plan,
|
||
|
|
// json. The output of the JSON command is then imported to the
|
||
|
|
// internal state.
|
||
|
|
func (t *TestTerraformer) Deploy() error {
|
||
|
|
if err := t.init(); err != nil {
|
||
|
|
return fmt.Errorf("error running Terraform init: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := t.apply(); err != nil {
|
||
|
|
return fmt.Errorf("error running Terraform apply: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := t.plan(); err != nil {
|
||
|
|
return fmt.Errorf("error running Terraform plan: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
output, err := t.showJson()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("error getting Terraform plan JSON data: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
newState := new(tfjson.Plan)
|
||
|
|
if err := json.Unmarshal(output, newState); err != nil {
|
||
|
|
return fmt.Errorf("error reading new Terraform plan JSON data: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
t.state = newState.PriorState
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Destroy runs the following commands, in order: init, destroy. The
|
||
|
|
// state is also cleared.
|
||
|
|
func (t *TestTerraformer) Destroy() error {
|
||
|
|
if err := t.init(); err != nil {
|
||
|
|
return fmt.Errorf("error running Terraform init: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := t.destroy(); err != nil {
|
||
|
|
return fmt.Errorf("error running Terraform destroy: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
t.state = nil
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetOutput returns the value of the output located at key.
|
||
|
|
//
|
||
|
|
// Outputs for the root module are supported only. It's an error to
|
||
|
|
// run this when no state exists.
|
||
|
|
func (t *TestTerraformer) GetOutput(key string) (interface{}, error) {
|
||
|
|
if key == "" {
|
||
|
|
return nil, errors.New("key must not be empty")
|
||
|
|
}
|
||
|
|
|
||
|
|
if t.state == nil {
|
||
|
|
return nil, errors.New("state is nil")
|
||
|
|
}
|
||
|
|
|
||
|
|
if t.state.Values == nil {
|
||
|
|
return nil, errors.New("state integrity error: no state values")
|
||
|
|
}
|
||
|
|
|
||
|
|
if t.state.Values.Outputs == nil {
|
||
|
|
return nil, errors.New("state integrity error: outputs is nil")
|
||
|
|
}
|
||
|
|
|
||
|
|
output, ok := t.state.Values.Outputs[key]
|
||
|
|
if !ok {
|
||
|
|
return nil, fmt.Errorf("output with key %q not found", key)
|
||
|
|
}
|
||
|
|
|
||
|
|
if output == nil {
|
||
|
|
return nil, fmt.Errorf("state integrity error: output at key %q is nil", key)
|
||
|
|
}
|
||
|
|
|
||
|
|
return output.Value, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// init runs "terraform init" in the project's working directory.
|
||
|
|
//
|
||
|
|
// This is only ever done once.
|
||
|
|
func (t *TestTerraformer) init() error {
|
||
|
|
t.initOnce.Do(func() {
|
||
|
|
cmd := exec.Command(t.tfPath, "init")
|
||
|
|
cmd.Dir = t.workDir
|
||
|
|
stdoutStderr, err := cmd.CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
t.initErr = fmt.Errorf("%s\n%s", stdoutStderr, err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
return t.initErr
|
||
|
|
}
|
||
|
|
|
||
|
|
// apply runs "terraform apply" in the project's working directory.
|
||
|
|
func (t *TestTerraformer) apply() error {
|
||
|
|
cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve")
|
||
|
|
cmd.Dir = t.workDir
|
||
|
|
stdoutStderr, err := cmd.CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("%s\n%s", stdoutStderr, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// destroy runs "terraform destroy" in the project's working directory.
|
||
|
|
func (t *TestTerraformer) destroy() error {
|
||
|
|
cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve")
|
||
|
|
cmd.Dir = t.workDir
|
||
|
|
stdoutStderr, err := cmd.CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("%s\n%s", stdoutStderr, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// plan runs "terraform plan" in the project's working directory.
|
||
|
|
//
|
||
|
|
// Note that plan is not run as part of the apply workflow; rather,
|
||
|
|
// it's run to get the data for for importing the state via
|
||
|
|
// terraform-json. The apply method ignores the plan generated here.
|
||
|
|
func (t *TestTerraformer) plan() error {
|
||
|
|
cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan")
|
||
|
|
cmd.Dir = t.workDir
|
||
|
|
stdoutStderr, err := cmd.CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("%s\n%s", stdoutStderr, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// showJson runs "terraform show -json". The output is returned along
|
||
|
|
// with any error.
|
||
|
|
//
|
||
|
|
// Output is only the results of stdout and should be suitable for
|
||
|
|
// parsing.
|
||
|
|
//
|
||
|
|
// This method expects the plan.tfplan file to be generated in the
|
||
|
|
// working directory already. This can be done with the plan method.
|
||
|
|
func (t *TestTerraformer) showJson() ([]byte, error) {
|
||
|
|
cmd := exec.Command(t.tfPath, "show", "-json", "plan.tfplan")
|
||
|
|
cmd.Dir = t.workDir
|
||
|
|
output, err := cmd.Output()
|
||
|
|
if err != nil {
|
||
|
|
exitErr, ok := err.(*exec.ExitError)
|
||
|
|
if !ok {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil, fmt.Errorf("%s\n%s", exitErr.Stderr, exitErr)
|
||
|
|
}
|
||
|
|
|
||
|
|
return output, nil
|
||
|
|
}
|