Skip to content

Commit

Permalink
feat(scripts): add cloud_sync_preview + cloud_sync_layer (#1458)
Browse files Browse the repository at this point in the history
## What this PR does / why we need it:

Add `cloud_sync_preview` option to script block.

## Which issue(s) this PR fixes:

## Special notes for your reviewer:

Depends on #1493

## Does this PR introduce a user-facing change?
```
Yes, users will be able to use the `cloud_sync_preview` option in the script block to store previews in TMC.
```
  • Loading branch information
wmalik authored Mar 1, 2024
2 parents 52780d8 + 00aef9b commit d77f1e7
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 34 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Given a version number `MAJOR.MINOR.PATCH`, we increment the:
stack drift details from script jobs.
- Add --cloud-sync-layer to allow users to specify a preview layer, e.g.: `stg`, `prod` etc.
- This is useful when users want to preview changes in a specific terraform workspace.
- Add `--cloud-sync-layer` and `--cloud-sync-preview` to `script` block, this would allow users to synchronize previews to Terramate Cloud via script jobs.

### Fixed

Expand Down
26 changes: 25 additions & 1 deletion cloud/preview/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

package preview

import "github.com/terramate-io/terramate/errors"
import (
"unicode"

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

// StackStatus is the status of a stack in a preview run
type StackStatus string
Expand Down Expand Up @@ -32,6 +36,26 @@ func (p StackStatus) String() string {
return string(p)
}

// Layer represents a cloud sync layer e.g. "dev", "staging", "prod" etc.
type Layer string

// String returns the string representation of the layer
func (l Layer) String() string {
return string(l)
}

// Validate validates the cloud sync layer (only alphanumeric characters and
// hyphens are allowed). An empty string is also allowed.
func (l Layer) Validate() error {
for _, c := range string(l) {
if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '-' {
return errors.E("invalid --cloud-sync-layer, only alphanumeric characters and hyphens are allowed")
}
}

return nil
}

// Validate validates the stack status
func (p StackStatus) Validate() error {
switch p {
Expand Down
42 changes: 13 additions & 29 deletions cmd/terramate/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ import (
"strconv"
"strings"
"time"
"unicode"

"github.com/google/uuid"
hhcl "github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclwrite"
"github.com/terramate-io/go-checkpoint"
"github.com/terramate-io/terramate/cloud"
"github.com/terramate-io/terramate/cloud/preview"
cloudstack "github.com/terramate-io/terramate/cloud/stack"
"github.com/terramate-io/terramate/cmd/terramate/cli/cliconfig"
"github.com/terramate-io/terramate/cmd/terramate/cli/clitest"
Expand Down Expand Up @@ -157,18 +157,18 @@ type cliSpec struct {
} `cmd:"" help:"List stacks"`

Run struct {
CloudStatus string `help:"Filter by status. Example: --cloud-status=unhealthy"`
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"`
CloudSyncPreview bool `default:"false" help:"Enable synchronization of review request previews to Terramate Cloud"`
CloudSyncLayer cloudSyncLayer `default:"" help:"Layer to use for synchronizing previews to Terramate Cloud e.g. stg, prod etc."`
CloudSyncTerraformPlanFile string `default:"" help:"Enable sync of Terraform plan file"`
DebugPreviewURL string `default:"" help:"Debug preview URL"`
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"`
CloudStatus string `help:"Filter by status. Example: --cloud-status=unhealthy"`
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"`
CloudSyncPreview bool `default:"false" help:"Enable synchronization of review request previews to Terramate Cloud"`
CloudSyncLayer preview.Layer `default:"" help:"Layer to use for synchronizing previews to Terramate Cloud e.g. stg, prod etc."`
CloudSyncTerraformPlanFile string `default:"" help:"Enable sync of Terraform plan file"`
DebugPreviewURL string `default:"" help:"Debug preview URL"`
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"`

// Note: 0 is not the real default value here, this is just a workaround.
// Kong doesn't support having 0 as the default value in case the flag isn't set, but K in case it's set without a value.
Expand Down Expand Up @@ -740,22 +740,6 @@ func (s *kongParallelFlag) Decode(ctx *kong.DecodeContext) error {
return nil
}

type cloudSyncLayer string

func (csl cloudSyncLayer) String() string {
return string(csl)
}

func (csl cloudSyncLayer) Validate() error {
for _, c := range csl {
if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '-' {
return errors.E("invalid --cloud-sync-layer, only alphanumeric characters and hyphens are allowed")
}
}

return nil
}

func (c *cli) setupSafeguards(run runSafeguardsCliSpec) {
global := c.parsedArgs.deprecatedGlobalSafeguardsCliSpec

Expand Down
5 changes: 3 additions & 2 deletions cmd/terramate/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/terramate-io/terramate/cloud"
"github.com/terramate-io/terramate/cloud/preview"
"github.com/terramate-io/terramate/config"
"github.com/terramate-io/terramate/errors"
"github.com/terramate-io/terramate/printer"
Expand Down Expand Up @@ -65,7 +66,7 @@ type stackRunTask struct {
CloudSyncDeployment bool
CloudSyncDriftStatus bool
CloudSyncPreview bool
CloudSyncLayer string
CloudSyncLayer preview.Layer
CloudSyncTerraformPlanFile string
}

Expand Down Expand Up @@ -154,7 +155,7 @@ func (c *cli) runOnStacks() {
CloudSyncDriftStatus: c.parsedArgs.Run.CloudSyncDriftStatus,
CloudSyncPreview: c.parsedArgs.Run.CloudSyncPreview,
CloudSyncTerraformPlanFile: c.parsedArgs.Run.CloudSyncTerraformPlanFile,
CloudSyncLayer: c.parsedArgs.Run.CloudSyncLayer.String(),
CloudSyncLayer: c.parsedArgs.Run.CloudSyncLayer,
},
},
}
Expand Down
9 changes: 8 additions & 1 deletion cmd/terramate/cli/script_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ func (c *cli) runScript() {
if cmd.Options != nil {
task.CloudSyncDeployment = cmd.Options.CloudSyncDeployment
task.CloudSyncDriftStatus = cmd.Options.CloudSyncDriftStatus
task.CloudSyncPreview = cmd.Options.CloudSyncPreview
task.CloudSyncLayer = cmd.Options.CloudSyncLayer
task.CloudSyncTerraformPlanFile = cmd.Options.CloudSyncTerraformPlan
}
run.Tasks = append(run.Tasks, task)
Expand Down Expand Up @@ -130,7 +132,8 @@ func (c *cli) prepareScriptForCloudSync(runs []stackRun) {

deployRuns := selectCloudStackTasks(runs, isDeploymentTask)
driftRuns := selectCloudStackTasks(runs, isDriftTask)
if len(deployRuns) == 0 && len(driftRuns) == 0 {
previewRuns := selectCloudStackTasks(runs, isPreviewTask)
if len(deployRuns) == 0 && len(driftRuns) == 0 && len(previewRuns) == 0 {
return
}

Expand Down Expand Up @@ -169,6 +172,10 @@ func (c *cli) prepareScriptForCloudSync(runs []stackRun) {
}
c.ensureAllStackHaveIDs(sortableDriftStacks)
}

if len(previewRuns) > 0 {
c.cloud.run.stackPreviews = c.createCloudPreview(previewRuns)
}
}

// printScriptCommand pretty prints the cmd and attaches a "prompt" style prefix to it
Expand Down
1 change: 0 additions & 1 deletion cmd/terramate/e2etests/core/run_script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,6 @@ func TestRunScriptOnChangedStacks(t *testing.T) {

s := sandbox.New(t)

// stack must run after stack2 but stack2 didn't change.
s.BuildTree([]string{
terramateConfig,
`s:stack`,
Expand Down
46 changes: 46 additions & 0 deletions config/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

hhcl "github.com/hashicorp/hcl/v2"
"github.com/terramate-io/terramate/cloud/preview"
"github.com/terramate-io/terramate/errors"
"github.com/terramate-io/terramate/hcl"
"github.com/terramate-io/terramate/hcl/eval"
Expand All @@ -33,6 +34,8 @@ const MaxScriptNameRunes = 128
type ScriptCmdOptions struct {
CloudSyncDeployment bool
CloudSyncDriftStatus bool
CloudSyncPreview bool
CloudSyncLayer preview.Layer
CloudSyncTerraformPlan string
}

Expand Down Expand Up @@ -147,6 +150,21 @@ func EvalScript(evalctx *eval.Context, script hcl.Script) (Script, error) {
))
}

var cmdsWithCloudSyncPreview []string
for jobIdx, job := range evaluatedScript.Jobs {
for cmdIdx, cmd := range job.Commands() {
if cmd.Options != nil && cmd.Options.CloudSyncPreview {
cmdsWithCloudSyncPreview = append(cmdsWithCloudSyncPreview, fmt.Sprintf("job:%d.%d", jobIdx, cmdIdx))
}
}
}
if len(cmdsWithCloudSyncPreview) > 1 {
errs.Append(errors.E(ErrScriptInvalidCmdOptions,
"only a single command per script may have 'cloud_sync_preview' enabled, but was enabled by: %v",
strings.Join(cmdsWithCloudSyncDeployment, " "),
))
}

if err := errs.AsError(); err != nil {
return Script{}, err
}
Expand Down Expand Up @@ -233,6 +251,12 @@ func unmarshalScriptJobCommand(cmdValues cty.Value, expr hhcl.Expression) (*Scri
if elem.Type().IsObjectType() {
var err error
r.Options, err = unmarshalScriptCommandOptions(elem, expr)
if r.Options != nil &&
r.Options.CloudSyncPreview &&
(r.Options.CloudSyncDriftStatus || r.Options.CloudSyncDeployment) {
errs.Append(errors.E(ErrScriptInvalidCmdOptions, expr.Range(),
"cloud_sync_preview cannot be used with cloud_sync_deployment or cloud_sync_drift_status"))
}
errs.Append(err)
} else {
errs.Append(errors.E(ErrScriptInvalidTypeCommand, expr.Range(),
Expand Down Expand Up @@ -289,6 +313,28 @@ func unmarshalScriptCommandOptions(obj cty.Value, expr hhcl.Expression) (*Script
break
}
r.CloudSyncDriftStatus = v.True()
case "cloud_sync_preview":
if v.Type() != cty.Bool {
errs.Append(errors.E(ErrScriptInvalidCmdOptions, expr.Range(),
"command option '%s' must be a bool, but has type %s",
ks, v.Type().FriendlyName()))
break
}
r.CloudSyncPreview = v.True()

case "cloud_sync_layer":
if v.Type() != cty.String {
errs.Append(errors.E(ErrScriptInvalidCmdOptions, expr.Range(),
"command option '%s' must be a string, but has type %s",
ks, v.Type().FriendlyName()))
break
}

r.CloudSyncLayer = preview.Layer(v.AsString())
if r.CloudSyncLayer.Validate() != nil {
errs.Append(errors.E(ErrScriptInvalidCmdOptions, expr.Range(),
"command option '%s' must contain only alphanumeric characters and hyphens", ks))
}

case "cloud_sync_terraform_plan_file":
if v.Type() != cty.String {
Expand Down
84 changes: 84 additions & 0 deletions config/script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,90 @@ func TestScriptEval(t *testing.T) {
},
},
},
{
name: "command options with cloud_sync_deployment and cloud_sync_preview",
script: hcl.Script{
Labels: labels,
Description: hcl.NewScriptDescription(
makeAttribute(t, "description", `"some description"`)),
Jobs: []*hcl.ScriptJob{
{
Commands: makeCommands(t, `
[
["echo", "hello", {
cloud_sync_deployment = true
cloud_sync_preview = true
cloud_sync_terraform_plan_file = "plan_a"
}],
]
`),
},
},
},
wantErr: errors.E(config.ErrScriptInvalidCmdOptions),
},
{
name: "command options with invalid cloud_sync_layer",
script: hcl.Script{
Labels: labels,
Description: hcl.NewScriptDescription(
makeAttribute(t, "description", `"some description"`)),
Jobs: []*hcl.ScriptJob{
{
Commands: makeCommands(t, `
[
["echo", "hello", {
cloud_sync_preview = true
cloud_sync_terraform_plan_file = "plan_a"
cloud_sync_layer = "a+b"
}],
]
`),
},
},
},
wantErr: errors.E(config.ErrScriptInvalidCmdOptions),
},
{
name: "command options with cloud_sync_preview + planfile + layer",
script: hcl.Script{
Labels: labels,
Description: hcl.NewScriptDescription(
makeAttribute(t, "description", `"some description"`)),
Jobs: []*hcl.ScriptJob{
{
Commands: makeCommands(t, `
[
["echo", "hello", {
cloud_sync_preview = true
cloud_sync_terraform_plan_file = "plan_a"
cloud_sync_layer = "staging"
}],
]
`),
},
},
},
want: config.Script{
Labels: labels,
Description: "some description",
Jobs: []config.ScriptJob{
{
Cmds: []*config.ScriptCmd{
{
Args: []string{"echo", "hello"},
Options: &config.ScriptCmdOptions{
CloudSyncDeployment: false,
CloudSyncPreview: true,
CloudSyncLayer: "staging",
CloudSyncTerraformPlan: "plan_a",
},
},
},
},
},
},
},
{
name: "command options",
script: hcl.Script{
Expand Down

0 comments on commit d77f1e7

Please sign in to comment.