diff --git a/bt/config/schema.go b/bt/config/schema.go index ff4e67e..47a6c3e 100644 --- a/bt/config/schema.go +++ b/bt/config/schema.go @@ -30,6 +30,10 @@ type TerraformProfile struct { Enabled bool Commands []Command } `json:"pre_apply_checks"` + PostApplyChecks struct { + Enabled bool + Commands []Command + } `json:"post_apply_checks"` BinaryName string `json:"binary_name"` Platforms []string `json:"platforms"` } @@ -57,6 +61,14 @@ func (t TerraformProfile) String() string { } output += fmt.Sprintf("%v", names) } + if t.PostApplyChecks.Enabled { + output += ", post_apply_checks: " + names := []string{} + for _, cmd := range t.PostApplyChecks.Commands { + names = append(names, cmd.Name) + } + output += fmt.Sprintf("%v", names) + } return output } diff --git a/bt/terraform/build.go b/bt/terraform/build.go index eb2642f..5bf8c67 100644 --- a/bt/terraform/build.go +++ b/bt/terraform/build.go @@ -22,7 +22,7 @@ func buildCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt opt.Bool("detailed-exitcode", false) opt.Bool("dry-run", false) opt.Bool("ignore-cache", false, opt.Description("Ignore the cache and re-run the plan"), opt.Alias("ic")) - opt.Bool("no-checks", false, opt.Description("Do not run pre-apply checks"), opt.Alias("nc")) + opt.Bool("no-checks", false, opt.Description("Do not run pre-apply/post-apply checks"), opt.Alias("nc")) opt.Bool("show", false, opt.Description("Show Terraform plan")) opt.Bool("lock", false, opt.Description("Run 'terraform providers lock' after init")) opt.Int("parallelism", 10*runtime.NumCPU()) @@ -89,11 +89,14 @@ func BuildRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error if cfg.TFProfile[cfg.Profile(profile)].PreApplyChecks.Enabled { tm.Add("checks", checksRun) } + if show { + tm.Add("show", showPlanRun) + } if apply { tm.Add("apply", applyRun) } - if show { - tm.Add("show", showPlanRun) + if cfg.TFProfile[cfg.Profile(profile)].PostApplyChecks.Enabled { + tm.Add("post-checks", postChecksRun) } g := dag.NewGraph(fmt.Sprintf("%s:build", component)) @@ -114,6 +117,9 @@ func BuildRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error if cfg.TFProfile[cfg.Profile(profile)].PreApplyChecks.Enabled { g.TaskDependsOn(tm.Get("apply"), tm.Get("checks")) } + if cfg.TFProfile[cfg.Profile(profile)].PostApplyChecks.Enabled { + g.TaskDependsOn(tm.Get("post-checks"), tm.Get("apply")) + } } err = g.Validate(tm) if err != nil { diff --git a/bt/terraform/checks.go b/bt/terraform/checks.go index 33b1990..6b1b3d6 100644 --- a/bt/terraform/checks.go +++ b/bt/terraform/checks.go @@ -208,3 +208,164 @@ func checksRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error return nil } + +func postChecksCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + opt := parent.NewCommand("post-checks", "Run post-apply checks") + opt.Bool("dry-run", false) + opt.StringSlice("var-file", 1, 1) + opt.Bool("no-checks", false, opt.Description("Do not run post-apply checks"), opt.Alias("nc")) + opt.Bool("ignore-cache", false, opt.Description("ignore the cache and re-run the checks"), opt.Alias("ic")) + opt.SetCommandFn(postChecksRun) + + return opt +} + +func postChecksRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + dryRun := opt.Value("dry-run").(bool) + profile := opt.Value("profile").(string) + varFiles := opt.Value("var-file").([]string) + ws := opt.Value("ws").(string) + ignoreCache := opt.Value("ignore-cache").(bool) + nc := opt.Value("no-checks").(bool) + if nc { + Logger.Printf("WARNING: no-checks flag passed. Skipping post-apply checks.\n") + return nil + } + + cfg := config.ConfigFromContext(ctx) + component := ComponentFromContext(ctx) + dir := DirFromContext(ctx) + LogConfig(cfg, profile) + + ws, err := updateWSIfSelected(cfg.Config.DefaultTerraformProfile, cfg.Profile(profile), ws) + if err != nil { + return err + } + + cwd, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("failed to get current dir: %w", err) + } + if component == "." { + component = filepath.Base(cwd) + } + component = strings.Split(component, ":")[0] + Logger.Printf("component: %s\n", component) + + ws, err = getWorkspace(cfg, profile, ws, varFiles) + if err != nil { + return err + } + + checkFile := "" + if ws == "" { + checkFile = ".tf.postcheck" + } else { + checkFile = fmt.Sprintf(".tf.postcheck-%s", ws) + } + wsEnv := ws + if ws == "" { + wsEnv = "default" + } + + env := map[string]string{ + "CONFIG_ROOT": cfg.ConfigRoot, + "TF_WORKSPACE": wsEnv, + "BT_COMPONENT": component, + } + + cmdFiles := []string{} + for _, cmd := range cfg.TFProfile[cfg.Profile(profile)].PostApplyChecks.Commands { + exp, err := fsmodtime.ExpandEnv(cmd.Files, env) + if err != nil { + return fmt.Errorf("failed to expand: %w", err) + } + for _, f := range exp { + if strings.HasPrefix(f, "/") { + cmdFiles = append(cmdFiles, filepath.Join("./", f)) + } else { + cmdFiles = append(cmdFiles, filepath.Join("./", cwd, f)) + } + } + } + globs, _, err := fsmodtime.Glob(os.DirFS("/"), false, cmdFiles) + if err != nil { + return fmt.Errorf("failed to glob sources: %w", err) + } + + // Paths tested with fs.FS can't start with "/". See https://pkg.go.dev/io/fs#ValidPath + files, modified, err := fsmodtime.Target(os.DirFS("/"), + []string{filepath.Join("./", cwd, checkFile)}, + globs) + if err != nil { + Logger.Printf("failed to check changes for: '%s'\n", checkFile) + } + + if !ignoreCache && !modified { + Logger.Printf("no changes: skipping check\n") + return nil + } + if len(files) > 0 { + modifiedFiles := []string{} + for _, f := range files { + rel, err := filepath.Rel(cwd, "/"+f) + if err != nil { + rel = f + } + modifiedFiles = append(modifiedFiles, rel) + } + Logger.Printf("modified: %v\n", modifiedFiles) + } else { + Logger.Printf("missing target: %v\n", checkFile) + } + + dataDir := fmt.Sprintf("TF_DATA_DIR=%s", getDataDir(cfg.Config.DefaultTerraformProfile, cfg.Profile(profile))) + Logger.Printf("export %s\n", dataDir) + + for _, cmd := range cfg.TFProfile[cfg.Profile(profile)].PostApplyChecks.Commands { + Logger.Printf("running check: %s\n", cmd.Name) + exp, err := fsmodtime.ExpandEnv(cmd.Command, env) + if err != nil { + return fmt.Errorf("failed to expand: %w", err) + } + ri := run.CMDCtx(ctx, exp...).Stdin().Log(). + Env(dataDir). + Env(fmt.Sprintf("CONFIG_ROOT=%s", cfg.ConfigRoot)). + Env(fmt.Sprintf("TF_WORKSPACE=%s", wsEnv)). + Env(fmt.Sprintf("BT_COMPONENT=%s", component)). + Dir(dir).DryRun(dryRun) + if cmd.OutputFile == "" { + err = ri.Run() + if err != nil { + return fmt.Errorf("failed to run: %w", err) + } + } else { + f, err := fsmodtime.ExpandEnv([]string{cmd.OutputFile}, env) + if err != nil { + return fmt.Errorf("failed to expand: %w", err) + } + fh, err := os.Create(filepath.Join(dir, f[0])) + if err != nil { + return fmt.Errorf("failed to create cmd output file: %w", err) + } + defer fh.Close() + err = ri.Run(fh, os.Stderr) + if err != nil { + return fmt.Errorf("failed to run: %w", err) + } + Logger.Printf("Saving check output to file: %s\n", filepath.Join(dir, f[0])) + } + } + + if dryRun { + return nil + } + + fh, err := os.Create(checkFile) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + fh.Close() + + return nil +} diff --git a/bt/terraform/terraform.go b/bt/terraform/terraform.go index dd0dfa1..4e2da70 100644 --- a/bt/terraform/terraform.go +++ b/bt/terraform/terraform.go @@ -58,6 +58,7 @@ func NewCommand(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetO // Custom buildCMD(ctx, opt) checksCMD(ctx, opt) + postChecksCMD(ctx, opt) return opt }