Skip to content

Commit

Permalink
feat(tmc): add --cloud-sync-terraform-plan-file option. (#1171)
Browse files Browse the repository at this point in the history
This new flag must be used with `--cloud-sync-drift-status` to enable
the sending of the rendered plan output to the Terramate Cloud.
  • Loading branch information
i4k-tm committed Oct 6, 2023
2 parents b93812e + 4cb76e3 commit 376be70
Show file tree
Hide file tree
Showing 17 changed files with 357 additions and 33 deletions.
27 changes: 27 additions & 0 deletions cloud/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ type (
MetaTags []string `json:"meta_tags,omitempty"`
}

// DriftDetails represents the details of a drift.
DriftDetails struct {
Provisioner string `json:"provisioner"`
ChangesetASCII string `json:"changeset_ascii,omitempty"`
ChangesetJSON string `json:"changeset_json,omitempty"`
}

// StacksResponse represents the stacks object response.
StacksResponse struct {
Stacks []StackResponse `json:"stacks"`
Expand Down Expand Up @@ -105,6 +112,7 @@ type (
DriftStackPayloadRequest struct {
Stack Stack `json:"stack"`
Status stack.Status `json:"drift_status"`
Details *DriftDetails `json:"drift_details,omitempty"`
Metadata *DeploymentMetadata `json:"metadata,omitempty"`
Command []string `json:"command"`
}
Expand Down Expand Up @@ -214,6 +222,7 @@ var (
_ = Resource(DeploymentReviewRequest{})
_ = Resource(DriftStackPayloadRequest{})
_ = Resource(DriftStackPayloadRequests{})
_ = Resource(DriftDetails{})
_ = Resource(EmptyResponse(""))
)

Expand Down Expand Up @@ -328,12 +337,30 @@ func (d DriftStackPayloadRequest) Validate() error {
}
}

if d.Details != nil {
err := d.Details.Validate()
if err != nil {
return err
}
}

return d.Status.Validate()
}

// Validate the list of drift requests.
func (ds DriftStackPayloadRequests) Validate() error { return validateResourceList(ds...) }

// Validate the drift details.
func (ds DriftDetails) Validate() error {
if ds.Provisioner == "" {
return errors.E(`field "provisioner" is required`)
}
if ds.ChangesetASCII == "" && ds.ChangesetJSON == "" {
return errors.E(`either "changeset_ascii" or "changeset_json" must be set`)
}
return nil
}

// Validate the list of deployment stack requests.
func (d DeploymentStackRequests) Validate() error { return validateResourceList(d...) }

Expand Down
21 changes: 11 additions & 10 deletions cmd/terramate/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,17 @@ type cliSpec struct {
} `cmd:"" help:"List stacks"`

Run struct {
CloudSyncDeployment bool `default:"false" help:"Enable synchronization of stack execution with the Terramate Cloud"`
CloudSyncDriftStatus bool `default:"false" help:"Enable drift detection and synchronization with the Terramate Cloud"`
DisableCheckGenCode bool `default:"false" help:"Disable outdated generated code check"`
DisableCheckGitRemote bool `default:"false" help:"Disable checking if local default branch is updated with remote"`
ContinueOnError bool `default:"false" help:"Continue executing in other stacks in case of error"`
NoRecursive bool `default:"false" help:"Do not recurse into child stacks"`
DryRun bool `default:"false" help:"Plan the execution but do not execute it"`
Reverse bool `default:"false" help:"Reverse the order of execution"`
Eval bool `default:"false" help:"Evaluate command line arguments as HCL strings"`
Command []string `arg:"" name:"cmd" predictor:"file" passthrough:"" help:"Command to execute"`
CloudSyncDeployment bool `default:"false" help:"Enable synchronization of stack execution with the Terramate Cloud"`
CloudSyncDriftStatus bool `default:"false" help:"Enable drift detection and synchronization with the Terramate Cloud"`
CloudSyncTerraformPlanFile string `default:"" help:"Enable sync of Terraform plan file"`
DisableCheckGenCode bool `default:"false" help:"Disable outdated generated code check"`
DisableCheckGitRemote bool `default:"false" help:"Disable checking if local default branch is updated with remote"`
ContinueOnError bool `default:"false" help:"Continue executing in other stacks in case of error"`
NoRecursive bool `default:"false" help:"Do not recurse into child stacks"`
DryRun bool `default:"false" help:"Plan the execution but do not execute it"`
Reverse bool `default:"false" help:"Reverse the order of execution"`
Eval bool `default:"false" help:"Evaluate command line arguments as HCL strings"`
Command []string `arg:"" name:"cmd" predictor:"file" passthrough:"" help:"Command to execute"`
} `cmd:"" help:"Run command in the stacks"`

Generate struct{} `cmd:"" help:"Generate terraform code for stacks"`
Expand Down
6 changes: 6 additions & 0 deletions cmd/terramate/cli/clitest/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ const (

// ErrCloudStacksWithoutID indicates that some stacks are missing the ID field.
ErrCloudStacksWithoutID errors.Kind = "all the cloud sync features requires that selected stacks contain an ID field"

// ErrCloudTerraformPlanFile indicates there was an error gathering the plan file details.
ErrCloudTerraformPlanFile errors.Kind = "failed to gather drift details from plan file"

// ErrCloudInvalidTerraformPlanFilePath indicates the plan file is not valid.
ErrCloudInvalidTerraformPlanFilePath errors.Kind = "invalid plan file path"
)
54 changes: 54 additions & 0 deletions cmd/terramate/cli/cloud_sync_drift.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
package cli

import (
"bytes"
"context"
"os/exec"
"path/filepath"
"time"

"github.com/rs/zerolog/log"
"github.com/terramate-io/terramate/cloud"
Expand Down Expand Up @@ -38,6 +42,16 @@ func (c *cli) cloudSyncDriftStatus(runContext ExecContext, exitCode int, err err
return
}

var driftDetails *cloud.DriftDetails

if planfile := c.parsedArgs.Run.CloudSyncTerraformPlanFile; planfile != "" {
var err error
driftDetails, err = c.getTerraformDriftDetails(runContext, planfile)
if err != nil {
logger.Error().Err(err).Msg("skipping the sync of Terraform plan details")
}
}

logger = logger.With().
Stringer("drift_status", status).
Logger()
Expand All @@ -55,6 +69,7 @@ func (c *cli) cloudSyncDriftStatus(runContext ExecContext, exitCode int, err err
MetaTags: st.Tags,
},
Status: status,
Details: driftDetails,
Metadata: c.cloud.run.metadata,
Command: runContext.Cmd,
})
Expand All @@ -65,3 +80,42 @@ func (c *cli) cloudSyncDriftStatus(runContext ExecContext, exitCode int, err err
logger.Debug().Msg("synced drift_status successfully")
}
}

func (c *cli) getTerraformDriftDetails(runContext ExecContext, planfile string) (*cloud.DriftDetails, error) {
logger := log.With().
Str("action", "getTerraformDriftDetails").
Str("planfile", planfile).
Stringer("stack", runContext.Stack.Dir).
Logger()

if filepath.IsAbs(planfile) {
return nil, errors.E(clitest.ErrCloudInvalidTerraformPlanFilePath, "path must be relative to the running stack")
}

var stdout, stderr bytes.Buffer

const tfShowTimeout = 5 * time.Second

ctx, cancel := context.WithTimeout(context.Background(), tfShowTimeout)
defer cancel()

cmd := exec.CommandContext(ctx, "terraform", "show", "-no-color", planfile)
cmd.Dir = runContext.Stack.Dir.HostPath(c.rootdir())
cmd.Stdout = &stdout
cmd.Stderr = &stderr

logger.Trace().Msgf("executing %s", cmd.String())
err := cmd.Run()
if err != nil {
logger.Debug().Str("stderr", stderr.String()).Msg("command stderr")

return nil, errors.E(clitest.ErrCloudTerraformPlanFile, "executing: %s", cmd.String())
}

logger.Trace().Msg("drift details gathered successfully")

return &cloud.DriftDetails{
Provisioner: "terraform",
ChangesetASCII: stdout.String(),
}, nil
}
4 changes: 4 additions & 0 deletions cmd/terramate/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ func (c *cli) runOnStacks() {
fatal(errors.E("--cloud-sync-deployment conflicts with --cloud-sync-drift-status"))
}

if c.parsedArgs.Run.CloudSyncDeployment && c.parsedArgs.Run.CloudSyncTerraformPlanFile != "" {
fatal(errors.E("--cloud-sync-terraform-plan-file can only be used with --cloud-sync-drift-status"))
}

if c.parsedArgs.Run.CloudSyncDeployment || c.parsedArgs.Run.CloudSyncDriftStatus {
c.ensureAllStackHaveIDs(orderedStacks)
c.detectCloudMetadata()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import (
"time"
)

func main() {
func helper() {
if len(os.Args) < 2 {
log.Fatal("test requires at least one subcommand argument")
log.Fatalf("%s requires at least one subcommand argument", os.Args[0])
}

// note: unrecovered panic() aborts the program with exit code 2 and this
Expand Down
30 changes: 30 additions & 0 deletions cmd/terramate/e2etests/cmd/test/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright 2023 Terramate GmbH
// SPDX-License-Identifier: MPL-2.0

package main

import (
"log"
"os"
"path/filepath"

"github.com/terramate-io/terramate/errors"
)

func main() {
if len(os.Args) < 2 {
log.Fatalf("%s requires at least one subcommand argument", os.Args[0])
}

ownPath, err := os.Executable()
if err != nil {
panic(errors.E(err, "cannot detect own path"))
}

name := filepath.Base(ownPath)
if name == "terraform" || name == "terraform.exe" {
terraform()
} else {
helper()
}
}
52 changes: 52 additions & 0 deletions cmd/terramate/e2etests/cmd/test/terraform.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright 2023 Terramate GmbH
// SPDX-License-Identifier: MPL-2.0

package main

import (
"flag"
"fmt"
"os"

"github.com/terramate-io/terramate/errors"
)

const envNameShowASCII = "TM_TEST_TERRAFORM_SHOW_ASCII_OUTPUT"

func terraform() {
switch os.Args[1] {
case "show":
fs := flag.NewFlagSet("terraform show", flag.ExitOnError)
_ = fs.Bool("no-color", false, "-no-color (ignored)")
err := fs.Parse(os.Args[2:])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if fs.NArg() != 1 {
fmt.Fprintf(os.Stderr, "given args: %v\n", fs.Args())
fmt.Fprintf(os.Stderr, "usage: %s show <file>\n", os.Args[0])
os.Exit(1)
}
file := fs.Arg(0)
st, err := os.Lstat(file)
if err != nil {
fmt.Fprintf(os.Stderr, "file not found: %s\n", file)
os.Exit(1)
}
if !st.Mode().IsRegular() {
fmt.Fprintf(os.Stderr, "not a regular file: %s\n", file)
os.Exit(1)
}

// file exists but ignore it

output := os.Getenv(envNameShowASCII)
if output == "" {
panic(errors.E("please set %s", envNameShowASCII))
}
fmt.Print(output)
default:
panic(errors.E("unsupported command: %s", os.Args[1]))
}
}
19 changes: 17 additions & 2 deletions cmd/terramate/e2etests/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ package e2etest

import (
"fmt"
"io/ioutil"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"

"github.com/terramate-io/terramate/errors"
"github.com/terramate-io/terramate/test"
)

// terramateTestBin is the path to the terramate binary we compiled for test purposes
Expand Down Expand Up @@ -79,7 +83,7 @@ func setupAndRunTests(m *testing.M) (status int) {
}

func buildTestHelper(goBin, testCmdPath, binDir string) (string, error) {
outBinPath := filepath.Join(binDir, "test"+platExeSuffix())
outBinPath := filepath.Join(binDir, "helper"+platExeSuffix())
cmd := exec.Command(
goBin,
"build",
Expand All @@ -91,7 +95,18 @@ func buildTestHelper(goBin, testCmdPath, binDir string) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to build test helper: %v (output: %s)", err, string(out))
}
return outBinPath, nil
data, err := ioutil.ReadFile(outBinPath)
if err != nil {
return "", errors.E(err, "reading helper binary")
}

tfPath := filepath.Join(binDir, "terraform"+platExeSuffix())
err = ioutil.WriteFile(tfPath, data, 0644)
if err != nil {
return "", errors.E(err, "writing fake terraform binary")
}
err = test.Chmod(tfPath, 0550)
return outBinPath, err
}

func buildTerramate(goBin, projectRoot, binDir string) (string, error) {
Expand Down
18 changes: 18 additions & 0 deletions cmd/terramate/e2etests/run_cloud_deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -56,6 +57,23 @@ func TestCLIRunWithCloudSyncDeployment(t *testing.T) {
},
},
},
{
name: "using --cloud-sync-terraform-plan-file with deployment sync -- fails",
layout: []string{
"s:s1",
`f:s1/out.tfplan:{}`,
},
runflags: []string{
`--cloud-sync-terraform-plan-file=out.tfplan`,
},
cmd: []string{testHelperBin, "echo", "ok"},
want: want{
run: runExpected{
Status: 1,
StderrRegex: regexp.QuoteMeta(`--cloud-sync-terraform-plan-file can only be used with --cloud-sync-drift-status`),
},
},
},
{
name: "failed command",
layout: []string{"s:stack"},
Expand Down

0 comments on commit 376be70

Please sign in to comment.