Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Terraform Plan Output in Status Field #226

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apis/v1beta1/workspace_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ type WorkspaceParameters struct {

// WorkspaceObservation are the observable fields of a Workspace.
type WorkspaceObservation struct {
TFPlan string `json:"tfPlan,omitempty"`
balu-ce marked this conversation as resolved.
Show resolved Hide resolved
Checksum string `json:"checksum,omitempty"`
Outputs map[string]extensionsV1.JSON `json:"outputs,omitempty"`
}
Expand Down
17 changes: 10 additions & 7 deletions internal/controller/workspace/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ type tfclient interface {
Workspace(ctx context.Context, name string) error
Outputs(ctx context.Context) ([]terraform.Output, error)
Resources(ctx context.Context) ([]string, error)
Diff(ctx context.Context, o ...terraform.Option) (bool, error)
Diff(ctx context.Context, o ...terraform.Option) (bool, string, error)
Apply(ctx context.Context, o ...terraform.Option) error
Destroy(ctx context.Context, o ...terraform.Option) error
DeleteCurrentWorkspace(ctx context.Context) error
Expand Down Expand Up @@ -301,23 +301,24 @@ type external struct {
logger logging.Logger
}

func (c *external) checkDiff(ctx context.Context, cr *v1beta1.Workspace) (bool, error) {
func (c *external) checkDiff(ctx context.Context, cr *v1beta1.Workspace) (bool, string, error) {
o, err := c.options(ctx, cr.Spec.ForProvider)
if err != nil {
return false, errors.Wrap(err, errOptions)
return false, "", errors.Wrap(err, errOptions)
}

o = append(o, terraform.WithArgs(cr.Spec.ForProvider.PlanArgs))
differs, err := c.tf.Diff(ctx, o...)
differs, planOutput, err := c.tf.Diff(ctx, o...)

if err != nil {
if !meta.WasDeleted(cr) {
return false, errors.Wrap(err, errDiff)
return false, planOutput, errors.Wrap(err, errDiff)
}
// terraform plan can fail on deleted resources, so let the reconciliation loop
// call Delete() if there are still resources in the tfstate file
differs = false
}
return differs, nil
return differs, planOutput, nil
}

func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
Expand All @@ -326,7 +327,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex
return managed.ExternalObservation{}, errors.New(errNotWorkspace)
}

differs, err := c.checkDiff(ctx, cr)
differs, planOutput, err := c.checkDiff(ctx, cr)
if err != nil {
return managed.ExternalObservation{}, err
}
Expand Down Expand Up @@ -354,6 +355,8 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex
}
cr.Status.AtProvider.Checksum = checksum

cr.Status.AtProvider.TFPlan = planOutput

if !differs {
// TODO(negz): Allow Workspaces to optionally derive their readiness from an
// output - similar to the logic XRs use to derive readiness from a field of
Expand Down
43 changes: 31 additions & 12 deletions internal/controller/workspace/workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ import (
)

const (
tfChecksum = "checksum"
tfChecksum = "checksum"
noDiffInPlan = "No Change in terraform plan"
)

type ErrFs struct {
Expand Down Expand Up @@ -72,7 +73,7 @@ type MockTf struct {
MockWorkspace func(ctx context.Context, name string) error
MockOutputs func(ctx context.Context) ([]terraform.Output, error)
MockResources func(ctx context.Context) ([]string, error)
MockDiff func(ctx context.Context, o ...terraform.Option) (bool, error)
MockDiff func(ctx context.Context, o ...terraform.Option) (bool, string, error)
MockApply func(ctx context.Context, o ...terraform.Option) error
MockDestroy func(ctx context.Context, o ...terraform.Option) error
MockDeleteCurrentWorkspace func(ctx context.Context) error
Expand All @@ -99,7 +100,7 @@ func (tf *MockTf) Resources(ctx context.Context) ([]string, error) {
return tf.MockResources(ctx)
}

func (tf *MockTf) Diff(ctx context.Context, o ...terraform.Option) (bool, error) {
func (tf *MockTf) Diff(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return tf.MockDiff(ctx, o...)
}

Expand Down Expand Up @@ -816,7 +817,9 @@ func TestObserve(t *testing.T) {
reason: "We should return any error encountered while diffing the Terraform configuration",
fields: fields{
tf: &MockTf{
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, error) { return false, errBoom },
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return false, noDiffInPlan, errBoom
},
},
},
args: args{
Expand All @@ -830,7 +833,9 @@ func TestObserve(t *testing.T) {
reason: "We should return ResourceUpToDate true when resource is deleted and there are existing resources but terraform plan fails",
fields: fields{
tf: &MockTf{
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, error) { return false, errBoom },
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return false, "", errBoom
},
MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil },
MockOutputs: func(ctx context.Context) ([]terraform.Output, error) { return nil, nil },
MockResources: func(ctx context.Context) ([]string, error) {
Expand Down Expand Up @@ -861,7 +866,9 @@ func TestObserve(t *testing.T) {
reason: "We should return ResourceUpToDate true when resource is deleted and there are no existing resources and terraform plan fails",
fields: fields{
tf: &MockTf{
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, error) { return false, errBoom },
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return false, "", errBoom
},
MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil },
MockOutputs: func(ctx context.Context) ([]terraform.Output, error) { return nil, nil },
MockResources: func(ctx context.Context) ([]string, error) { return nil, nil },
Expand Down Expand Up @@ -891,7 +898,9 @@ func TestObserve(t *testing.T) {
reason: "We should return ResourceUpToDate true when resource is deleted and there are no existing resources and terraform plan fails",
fields: fields{
tf: &MockTf{
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, error) { return false, errBoom },
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return false, noDiffInPlan, errBoom
},
MockResources: func(ctx context.Context) ([]string, error) { return nil, nil },
MockDeleteCurrentWorkspace: func(ctx context.Context) error { return errBoom },
},
Expand All @@ -911,7 +920,9 @@ func TestObserve(t *testing.T) {
reason: "We should return any error encountered while listing extant Terraform resources",
fields: fields{
tf: &MockTf{
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, error) { return false, nil },
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return false, noDiffInPlan, nil
},
MockResources: func(ctx context.Context) ([]string, error) { return nil, errBoom },
},
},
Expand All @@ -926,7 +937,9 @@ func TestObserve(t *testing.T) {
reason: "We should return any error encountered while listing Terraform outputs",
fields: fields{
tf: &MockTf{
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, error) { return false, nil },
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return false, noDiffInPlan, nil
},
MockResources: func(ctx context.Context) ([]string, error) { return nil, nil },
MockOutputs: func(ctx context.Context) ([]terraform.Output, error) { return nil, errBoom },
},
Expand All @@ -942,7 +955,9 @@ func TestObserve(t *testing.T) {
reason: "A workspace with zero resources should be considered to be non-existent",
fields: fields{
tf: &MockTf{
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, error) { return false, nil },
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return false, "", nil
},
MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil },
MockResources: func(ctx context.Context) ([]string, error) { return []string{}, nil },
MockOutputs: func(ctx context.Context) ([]terraform.Output, error) { return nil, nil },
Expand All @@ -967,7 +982,9 @@ func TestObserve(t *testing.T) {
reason: "A workspace with resources should return its outputs as connection details",
fields: fields{
tf: &MockTf{
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, error) { return false, nil },
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return false, "", nil
},
MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil },
MockResources: func(ctx context.Context) ([]string, error) {
return []string{"cool_resource.very"}, nil
Expand Down Expand Up @@ -1010,7 +1027,9 @@ func TestObserve(t *testing.T) {
reason: "A workspace with only outputs and no resources should set ResourceExists to true",
fields: fields{
tf: &MockTf{
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, error) { return false, nil },
MockDiff: func(ctx context.Context, o ...terraform.Option) (bool, string, error) {
return false, "", nil
},
MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil },
MockResources: func(ctx context.Context) ([]string, error) {
return nil, nil
Expand Down
52 changes: 47 additions & 5 deletions internal/terraform/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ const (

const varFilePrefix = "crossplane-provider-terraform-"

// Info message to show plan change
const (
noDiffInPlan = "No Change in terraform plan"
balu-ce marked this conversation as resolved.
Show resolved Hide resolved
)

// Terraform often returns a summary of the error it encountered on a single
// line, prefixed with 'Error: '.
var tfError = regexp.MustCompile(`Error: (.+)\n`)
Expand Down Expand Up @@ -105,6 +110,35 @@ func formatTerraformErrorOutput(errorOutput string) (string, string, error) {
return summary, base64FullErr, nil
}

// Format Terraform error output as gzipped and base64 encoded string
func formatTerraformPlanOutput(output string) (string, error) {
// Gzip compress the output and base64 encode it.
var buffer bytes.Buffer
gz := gzip.NewWriter(&buffer)
balu-ce marked this conversation as resolved.
Show resolved Hide resolved

if _, err := gz.Write([]byte(output)); err != nil {
return "", err
}

if err := gz.Flush(); err != nil {
return "", err
}

if err := gz.Close(); err != nil {
return "", err
}
balu-ce marked this conversation as resolved.
Show resolved Hide resolved

if err := gz.Flush(); err != nil {
return "", err
}
balu-ce marked this conversation as resolved.
Show resolved Hide resolved

formatString := "Terraform Plan. To see the full plan run: echo \"%s\" | base64 -d | gunzip"
balu-ce marked this conversation as resolved.
Show resolved Hide resolved

base64FullPlan := base64.StdEncoding.EncodeToString(buffer.Bytes())

return fmt.Sprintf(formatString, base64FullPlan), nil
}

// NOTE(negz): The gosec linter returns a G204 warning anytime a command is
// executed with any kind of variable input. This isn't inherently a problem,
// and is apparently mostly intended to catch the attention of code auditors per
Expand Down Expand Up @@ -480,15 +514,15 @@ func WithVarFile(data []byte, f FileFormat) Option {
// Diff invokes 'terraform plan' to determine whether there is a diff between
// the desired and the actual state of the configuration. It returns true if
// there is a diff.
func (h Harness) Diff(ctx context.Context, o ...Option) (bool, error) {
func (h Harness) Diff(ctx context.Context, o ...Option) (bool, string, error) {
ao := &options{}
for _, fn := range o {
fn(ao)
}

for _, vf := range ao.varFiles {
if err := os.WriteFile(filepath.Join(h.Dir, vf.filename), vf.data, 0600); err != nil {
return false, errors.Wrap(err, errWriteVarFile)
return false, "", errors.Wrap(err, errWriteVarFile)
}
}

Expand All @@ -503,11 +537,19 @@ func (h Harness) Diff(ctx context.Context, o ...Option) (bool, error) {
// 0 - Succeeded, diff is empty (no changes)
// 1 - Errored
// 2 - Succeeded, there is a diff
_, err := cmd.Output()
planVal, err := cmd.Output()
balu-ce marked this conversation as resolved.
Show resolved Hide resolved

if cmd.ProcessState.ExitCode() == 2 {
return true, nil

base64FullPlan, err := formatTerraformPlanOutput(string(planVal))
if err != nil {
return false, "", err
}

return true, base64FullPlan, nil
}
return false, Classify(err)

return false, noDiffInPlan, Classify(err)
}

// Apply a Terraform configuration.
Expand Down
2 changes: 2 additions & 0 deletions package/crds/tf.upbound.io_workspaces.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ spec:
additionalProperties:
x-kubernetes-preserve-unknown-fields: true
type: object
tfPlan:
type: string
type: object
conditions:
description: Conditions of the resource.
Expand Down
Loading