From c9db4664c5c600c8d36a092781f897c67e3fbf8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 07:39:19 +0000 Subject: [PATCH 1/3] Initial plan From 70383e1353edc45092f449e3576f304552870b05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 08:33:56 +0000 Subject: [PATCH 2/3] Add --dry-run support for wfctl infra apply and ci run --phase deploy - Add --dry-run and --format flags to `wfctl infra apply` - Add --dry-run and --format flags to `wfctl ci run` (deploy phase) - Dry-run uses same planning logic (parseInfraResourceSpecsForEnv, computePlanForInfraSpecs) as real apply path - Structured JSON output format for CI automation - Security-aware: shows secret keys but never prints values - Prints provider groups, resource actions, health-check plan, pre-deploy steps, and deploy target naming - Add 7 new tests covering table/JSON output, env resolution, secret safety, and planning logic equivalence - Update docs/WFCTL.md with new flags and examples Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/abd214b9-ada7-4c1c-ae4b-4a8e8c10421d Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/ci_run.go | 5 + cmd/wfctl/ci_run_dryrun.go | 221 ++++++++++++++++ cmd/wfctl/dryrun_test.go | 438 ++++++++++++++++++++++++++++++++ cmd/wfctl/infra.go | 9 + cmd/wfctl/infra_apply_dryrun.go | 253 ++++++++++++++++++ docs/WFCTL.md | 18 +- 6 files changed, 943 insertions(+), 1 deletion(-) create mode 100644 cmd/wfctl/ci_run_dryrun.go create mode 100644 cmd/wfctl/dryrun_test.go create mode 100644 cmd/wfctl/infra_apply_dryrun.go diff --git a/cmd/wfctl/ci_run.go b/cmd/wfctl/ci_run.go index 36bfb9f4..b0e4f4e7 100644 --- a/cmd/wfctl/ci_run.go +++ b/cmd/wfctl/ci_run.go @@ -22,6 +22,8 @@ func runCIRun(args []string) error { phases := fs.String("phase", "build,test", "Comma-separated phases: build, test, deploy") env := fs.String("env", "", "Target environment (required for deploy phase)") verbose := fs.Bool("verbose", false, "Show detailed output") + dryRun := fs.Bool("dry-run", false, "Show planned operations without executing (deploy phase only)") + dryRunFormat := fs.String("format", "table", "Dry-run output format: table, json") pluginDir := fs.String("plugin-dir", "", "Directory containing installed plugins (default: $WFCTL_PLUGIN_DIR or ./data/plugins)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl ci run [options]\n\nRun CI phases from workflow config.\n\nOptions:\n") @@ -63,6 +65,9 @@ func runCIRun(args []string) error { if *env == "" { return fmt.Errorf("--env is required for deploy phase") } + if *dryRun { + return runDeployPhaseDryRun(cfg.CI.Deploy, *env, &cfg, cfg.Services, *dryRunFormat) + } if *pluginDir != "" { os.Setenv("WFCTL_PLUGIN_DIR", *pluginDir) //nolint:errcheck } diff --git a/cmd/wfctl/ci_run_dryrun.go b/cmd/wfctl/ci_run_dryrun.go new file mode 100644 index 00000000..32a1e650 --- /dev/null +++ b/cmd/wfctl/ci_run_dryrun.go @@ -0,0 +1,221 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/GoCodeAlone/workflow/config" +) + +// DryRunDeployPlan is the structured dry-run output for ci run --phase deploy. +type DryRunDeployPlan struct { + Command string `json:"command"` + Environment string `json:"environment"` + Provider string `json:"provider"` + Strategy string `json:"strategy"` + PreDeploy []string `json:"pre_deploy,omitempty"` + DeployTarget string `json:"deploy_target"` + ImageRef string `json:"image_ref"` + ImageTagSource string `json:"image_tag_source"` + HealthCheck *DryRunHealthCheck `json:"health_check,omitempty"` + Secrets []DryRunSecretRef `json:"secrets,omitempty"` + Services []DryRunServiceEntry `json:"services,omitempty"` +} + +// DryRunHealthCheck summarizes health check configuration. +type DryRunHealthCheck struct { + Path string `json:"path"` + Timeout string `json:"timeout,omitempty"` +} + +// DryRunServiceEntry describes a service that would be deployed. +type DryRunServiceEntry struct { + Name string `json:"name"` + ImageTag string `json:"image_tag,omitempty"` +} + +// runDeployPhaseDryRun prints what the deploy phase would execute without +// performing any provider mutations, secret injection, or health polling. +func runDeployPhaseDryRun( + deploy *config.CIDeployConfig, + envName string, + wfCfg *config.WorkflowConfig, + services map[string]*config.ServiceConfig, + format string, +) error { + if deploy == nil { + return fmt.Errorf("no deploy configuration") + } + env, ok := deploy.Environments[envName] + if !ok { + available := make([]string, 0, len(deploy.Environments)) + for k := range deploy.Environments { + available = append(available, k) + } + sort.Strings(available) + return fmt.Errorf("environment %q not found (available: %s)", envName, strings.Join(available, ", ")) + } + + strategy := cmp(env.Strategy, "rolling") + imageTag := os.Getenv("IMAGE_TAG") + imageTagSource := "IMAGE_TAG env var" + if imageTag == "" { + imageTag = "(not set)" + imageTagSource = "IMAGE_TAG env var (not set)" + } + + deployTarget := envName + if env.Cluster != "" { + deployTarget = env.Cluster + } + + // Collect secret keys that would be required. + secretRefs := collectDeploySecretRefs(wfCfg, envName) + + // Collect services. + var serviceEntries []DryRunServiceEntry + if len(services) > 0 { + for name := range services { + serviceEntries = append(serviceEntries, DryRunServiceEntry{ + Name: name, + ImageTag: imageTag, + }) + } + sort.Slice(serviceEntries, func(i, j int) bool { + return serviceEntries[i].Name < serviceEntries[j].Name + }) + } + + switch format { + case "json": + return printDeployDryRunJSON(envName, env, strategy, imageTag, imageTagSource, deployTarget, secretRefs, serviceEntries) + default: + return printDeployDryRunTable(envName, env, strategy, imageTag, imageTagSource, deployTarget, secretRefs, serviceEntries) + } +} + +func printDeployDryRunTable( + envName string, + env *config.CIDeployEnvironment, + strategy, imageTag, imageTagSource, deployTarget string, + secretRefs []DryRunSecretRef, + services []DryRunServiceEntry, +) error { + fmt.Printf("Dry Run — ci deploy\n") + fmt.Printf("====================\n") + fmt.Printf("Environment: %s\n", envName) + fmt.Printf("Provider: %s\n", env.Provider) + fmt.Printf("Strategy: %s\n", strategy) + fmt.Printf("Deploy Target: %s\n", deployTarget) + fmt.Printf("Image Ref: %s\n", imageTag) + fmt.Printf("Image Source: %s\n", imageTagSource) + if env.Region != "" { + fmt.Printf("Region: %s\n", env.Region) + } + if env.Namespace != "" { + fmt.Printf("Namespace: %s\n", env.Namespace) + } + fmt.Println() + + if len(env.PreDeploy) > 0 { + fmt.Printf("Pre-Deploy Steps:\n") + for _, step := range env.PreDeploy { + fmt.Printf(" - %s\n", step) + } + fmt.Println() + } + + if len(services) > 0 { + fmt.Printf("Services:\n") + for _, s := range services { + fmt.Printf(" - %s (image: %s)\n", s.Name, s.ImageTag) + } + fmt.Println() + } + + if env.HealthCheck != nil { + fmt.Printf("Health Check:\n") + fmt.Printf(" Path: %s\n", env.HealthCheck.Path) + if env.HealthCheck.Timeout != "" { + fmt.Printf(" Timeout: %s\n", env.HealthCheck.Timeout) + } + fmt.Println() + } + + if len(secretRefs) > 0 { + fmt.Printf("Required Secrets:\n") + for _, s := range secretRefs { + store := "" + if s.Store != "" { + store = fmt.Sprintf(" (store: %s)", s.Store) + } + fmt.Printf(" - %s%s\n", s.Key, store) + } + fmt.Println() + } + + if env.RequireApproval { + fmt.Printf("NOTE: This environment requires approval before deployment.\n\n") + } + + fmt.Printf("Dry run complete. No deployment was executed.\n") + fmt.Printf("To deploy, run: wfctl ci run --phase deploy --env %s\n", envName) + return nil +} + +func printDeployDryRunJSON( + envName string, + env *config.CIDeployEnvironment, + strategy, imageTag, imageTagSource, deployTarget string, + secretRefs []DryRunSecretRef, + services []DryRunServiceEntry, +) error { + var hc *DryRunHealthCheck + if env.HealthCheck != nil { + hc = &DryRunHealthCheck{ + Path: env.HealthCheck.Path, + Timeout: env.HealthCheck.Timeout, + } + } + + output := DryRunDeployPlan{ + Command: "ci run --phase deploy", + Environment: envName, + Provider: env.Provider, + Strategy: strategy, + PreDeploy: env.PreDeploy, + DeployTarget: deployTarget, + ImageRef: imageTag, + ImageTagSource: imageTagSource, + HealthCheck: hc, + Secrets: secretRefs, + Services: services, + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(output) +} + +func collectDeploySecretRefs(cfg *config.WorkflowConfig, envName string) []DryRunSecretRef { + if cfg == nil || cfg.Secrets == nil { + return nil + } + + var refs []DryRunSecretRef + for _, entry := range cfg.Secrets.Entries { + store := "" + if entry.Store != "" { + store = entry.Store + } + refs = append(refs, DryRunSecretRef{ + Key: entry.Name, + Store: store, + Required: true, + }) + } + return refs +} diff --git a/cmd/wfctl/dryrun_test.go b/cmd/wfctl/dryrun_test.go new file mode 100644 index 00000000..ec27783d --- /dev/null +++ b/cmd/wfctl/dryrun_test.go @@ -0,0 +1,438 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/iactest" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/platform" +) + +// stubProviderForDryRunTests overrides resolveIaCProvider to return a NoopProvider +// so tests don't require a real plugin binary. +func stubProviderForDryRunTests(t *testing.T) { + t.Helper() + orig := resolveIaCProvider + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return &iactest.NoopProvider{}, nil, nil + } + t.Cleanup(func() { resolveIaCProvider = orig }) +} + +func TestInfraApplyDryRun_PrintsPlanWithoutMutations(t *testing.T) { + // Create a minimal infra config with an iac.provider and infra resource. + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + cfgContent := ` +modules: + - name: do-provider + type: iac.provider + config: + provider: mock + region: nyc1 + + - name: my-vpc + type: infra.vpc + config: + provider: do-provider + name: test-vpc + region: nyc1 +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatal(err) + } + + stubProviderForDryRunTests(t) + platform.SetDiffCacheForTest(t, nil) + + // Capture stdout. + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runInfraApply([]string{"--dry-run", "--config", cfgPath}) + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("dry-run should not error: %v", err) + } + output := buf.String() + + // Verify it includes plan information. + if !strings.Contains(output, "Dry Run") { + t.Error("expected 'Dry Run' header in output") + } + if !strings.Contains(output, "infra.yaml") || !strings.Contains(output, "infra") { + t.Errorf("expected config file name in output, got: %s", output) + } + if !strings.Contains(output, "No changes were applied") { + t.Error("expected 'No changes were applied' message") + } +} + +func TestInfraApplyDryRun_JSONFormat(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + cfgContent := ` +modules: + - name: do-provider + type: iac.provider + config: + provider: mock + region: nyc1 + + - name: my-db + type: infra.database + config: + provider: do-provider + name: test-db + engine: pg +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatal(err) + } + + stubProviderForDryRunTests(t) + platform.SetDiffCacheForTest(t, nil) + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runInfraApply([]string{"--dry-run", "--format", "json", "--config", cfgPath}) + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("dry-run json should not error: %v", err) + } + + var result DryRunApplyPlan + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("output should be valid JSON: %v\nGot: %s", err, buf.String()) + } + if result.Command != "infra apply" { + t.Errorf("expected command 'infra apply', got %q", result.Command) + } + if result.Config != cfgPath { + t.Errorf("expected config %q, got %q", cfgPath, result.Config) + } +} + +func TestInfraApplyDryRun_SharesPlanLogicWithApply(t *testing.T) { + // This test verifies that dry-run uses the same planning functions + // (parseInfraResourceSpecsForEnv + computePlanForInfraSpecs) as the + // real apply path, by checking that env-resolved resource names + // (like 'bmw-staging') are resolved in the plan. + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + cfgContent := ` +modules: + - name: do-provider + type: iac.provider + config: + provider: mock + + - name: bmw-staging + type: infra.vpc + config: + provider: do-provider + name: bmw-staging + region: nyc1 + environments: + staging: + region: fra1 + +environments: + staging: + provider: digitalocean + region: fra1 +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatal(err) + } + + stubProviderForDryRunTests(t) + platform.SetDiffCacheForTest(t, nil) + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runInfraApply([]string{"--dry-run", "--env", "staging", "--config", cfgPath}) + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("dry-run with env should not error: %v", err) + } + + output := buf.String() + // The module name 'bmw-staging' should appear in the plan output + // (it's the resource name in the plan table). + if !strings.Contains(output, "bmw-staging") { + t.Errorf("expected 'bmw-staging' in dry-run output — dry-run must use the same planning logic as apply, got: %s", output) + } +} + +func TestCIRunDeployDryRun_PrintsDeployPlan(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "app.yaml") + cfgContent := ` +version: 1 +ci: + deploy: + environments: + staging: + provider: do-app-platform + strategy: rolling + preDeploy: + - migrate-db + healthCheck: + path: /health + timeout: 60s +secrets: + entries: + - name: DATABASE_URL + store: vault +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("IMAGE_TAG", "myapp:abc1234") + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runCIRun([]string{"--config", cfgPath, "--phase", "deploy", "--env", "staging", "--dry-run"}) + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("dry-run deploy should not error: %v", err) + } + output := buf.String() + + // Verify key information is shown. + for _, expected := range []string{ + "Dry Run", + "staging", + "do-app-platform", + "rolling", + "myapp:abc1234", + "migrate-db", + "/health", + "DATABASE_URL", + "No deployment was executed", + } { + if !strings.Contains(output, expected) { + t.Errorf("expected %q in dry-run output, got:\n%s", expected, output) + } + } +} + +func TestCIRunDeployDryRun_JSONFormat(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "app.yaml") + cfgContent := ` +version: 1 +ci: + deploy: + environments: + staging: + provider: aws-ecs + strategy: blue-green + cluster: bmw-staging + healthCheck: + path: /api/health + timeout: 30s +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("IMAGE_TAG", "api:v2.0.0") + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runCIRun([]string{"--config", cfgPath, "--phase", "deploy", "--env", "staging", "--dry-run", "--format", "json"}) + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("dry-run json deploy should not error: %v", err) + } + + var result DryRunDeployPlan + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("output should be valid JSON: %v\nGot: %s", err, buf.String()) + } + + if result.Environment != "staging" { + t.Errorf("expected environment 'staging', got %q", result.Environment) + } + if result.Provider != "aws-ecs" { + t.Errorf("expected provider 'aws-ecs', got %q", result.Provider) + } + if result.Strategy != "blue-green" { + t.Errorf("expected strategy 'blue-green', got %q", result.Strategy) + } + if result.DeployTarget != "bmw-staging" { + t.Errorf("expected deploy target 'bmw-staging', got %q", result.DeployTarget) + } + if result.ImageRef != "api:v2.0.0" { + t.Errorf("expected image ref 'api:v2.0.0', got %q", result.ImageRef) + } + if result.HealthCheck == nil { + t.Fatal("expected health check in output") + } + if result.HealthCheck.Path != "/api/health" { + t.Errorf("expected health check path '/api/health', got %q", result.HealthCheck.Path) + } +} + +func TestCIRunDeployDryRun_NeverPrintsSecretValues(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "app.yaml") + cfgContent := ` +version: 1 +ci: + deploy: + environments: + staging: + provider: do-app-platform +secrets: + entries: + - name: SECRET_API_KEY + store: env + - name: DB_PASSWORD + store: vault +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatal(err) + } + + // Set secret values in environment — they must NOT appear in output. + t.Setenv("SECRET_API_KEY", "super-secret-key-12345") + t.Setenv("DB_PASSWORD", "hunter2") + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runCIRun([]string{"--config", cfgPath, "--phase", "deploy", "--env", "staging", "--dry-run"}) + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("dry-run should not error: %v", err) + } + output := buf.String() + + // Secret VALUES must never appear. + if strings.Contains(output, "super-secret-key-12345") { + t.Error("secret value leaked in dry-run output") + } + if strings.Contains(output, "hunter2") { + t.Error("secret value leaked in dry-run output") + } + + // Secret KEYS should appear. + if !strings.Contains(output, "SECRET_API_KEY") { + t.Error("expected secret key 'SECRET_API_KEY' in output") + } + if !strings.Contains(output, "DB_PASSWORD") { + t.Error("expected secret key 'DB_PASSWORD' in output") + } +} + +// TestDryRunSharesPlanningLogic proves that the dry-run deploy path +// resolves the same environment names, provider selection, and target +// resource naming as the real deploy path. +func TestDryRunSharesPlanningLogic(t *testing.T) { + deploy := &config.CIDeployConfig{ + Environments: map[string]*config.CIDeployEnvironment{ + "staging": { + Provider: "do-app-platform", + Cluster: "bmw-staging", + Strategy: "rolling", + PreDeploy: []string{"run-migrations"}, + HealthCheck: &config.CIHealthCheck{ + Path: "/health", + Timeout: "60s", + }, + }, + }, + } + + // The dry-run path uses the same env lookup and config resolution + // that runDeployPhaseWithConfig uses. + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runDeployPhaseDryRun(deploy, "staging", nil, nil, "json") + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result DryRunDeployPlan + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + // These are the same values that runDeployPhaseWithConfig would resolve. + if result.DeployTarget != "bmw-staging" { + t.Errorf("expected deploy target 'bmw-staging', got %q", result.DeployTarget) + } + if result.Provider != "do-app-platform" { + t.Errorf("expected provider 'do-app-platform', got %q", result.Provider) + } + if result.Strategy != "rolling" { + t.Errorf("expected strategy 'rolling', got %q", result.Strategy) + } + if len(result.PreDeploy) != 1 || result.PreDeploy[0] != "run-migrations" { + t.Errorf("expected pre-deploy [run-migrations], got %v", result.PreDeploy) + } +} diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index cc0ac103..35285b56 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -1034,6 +1034,10 @@ func runInfraApply(args []string) error { fs.BoolVar(&showSensitiveVal, "S", false, "Show sensitive values in plaintext (short for --show-sensitive)") var envName string fs.StringVar(&envName, "env", "", "Environment name (resolves per-module environments: overrides)") + var dryRun bool + fs.BoolVar(&dryRun, "dry-run", false, "Show planned operations without executing provider mutations") + var dryRunFormat string + fs.StringVar(&dryRunFormat, "format", "table", "Dry-run output format: table, json") var planFile string fs.StringVar(&planFile, "plan", "", "Apply from a pre-emitted plan.json (skips ComputePlan)") var refreshFlag bool @@ -1077,6 +1081,11 @@ func runInfraApply(args []string) error { } } + // --dry-run: compute and display the plan without executing any mutations. + if dryRun { + return runInfraApplyDryRun(cfgFile, envName, dryRunFormat, showSensitiveVal) + } + if !*autoApprove { fmt.Printf("Apply infrastructure changes from %s? [y/N]: ", cfgFile) var answer string diff --git a/cmd/wfctl/infra_apply_dryrun.go b/cmd/wfctl/infra_apply_dryrun.go new file mode 100644 index 00000000..99cf88c0 --- /dev/null +++ b/cmd/wfctl/infra_apply_dryrun.go @@ -0,0 +1,253 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// DryRunApplyPlan is the structured dry-run output for infra apply. +type DryRunApplyPlan struct { + Command string `json:"command"` + Config string `json:"config"` + Environment string `json:"environment,omitempty"` + Actions []DryRunAction `json:"actions"` + Secrets []DryRunSecretRef `json:"secrets,omitempty"` + Providers []DryRunProviderGroup `json:"providers,omitempty"` + Summary DryRunSummary `json:"summary"` +} + +// DryRunAction describes a single planned infrastructure operation. +type DryRunAction struct { + Action string `json:"action"` + ResourceName string `json:"resource_name"` + ResourceType string `json:"resource_type"` + Provider string `json:"provider,omitempty"` +} + +// DryRunSecretRef describes a secret key required by the operation (value never shown). +type DryRunSecretRef struct { + Key string `json:"key"` + Store string `json:"store,omitempty"` + Required bool `json:"required"` +} + +// DryRunProviderGroup summarizes resources grouped by provider. +type DryRunProviderGroup struct { + ModuleRef string `json:"module_ref"` + ProviderType string `json:"provider_type"` + ResourceCount int `json:"resource_count"` +} + +// DryRunSummary provides counts of planned operations. +type DryRunSummary struct { + Creates int `json:"creates"` + Updates int `json:"updates"` + Replaces int `json:"replaces"` + Deletes int `json:"deletes"` +} + +// runInfraApplyDryRun executes the same planning logic as a real apply +// (config resolution, environment overrides, provider selection) but +// prints the plan and exits without executing any provider mutations. +func runInfraApplyDryRun(cfgFile, envName, format string, showSensitive bool) error { + desired, err := parseInfraResourceSpecsForEnv(cfgFile, envName) + if err != nil { + return fmt.Errorf("parse infra resource specs: %w", err) + } + if err := validateUniqueInfraResourceNames(desired); err != nil { + return err + } + + current, err := loadCurrentState(cfgFile, envName) + if err != nil { + return fmt.Errorf("load current state: %w", err) + } + + plan, err := computePlanForInfraSpecs(context.Background(), cfgFile, envName, desired, current) + if err != nil { + return fmt.Errorf("compute plan: %w", err) + } + + // Collect provider groups for the summary. + providerGroups := collectProviderGroups(cfgFile, envName, desired) + + // Collect required secrets (keys only, never values). + secretRefs := collectSecretRefs(cfgFile, envName) + + switch format { + case "json": + return printDryRunJSON(cfgFile, envName, plan, providerGroups, secretRefs) + default: + return printDryRunTable(cfgFile, envName, plan, providerGroups, secretRefs, showSensitive) + } +} + +func printDryRunTable(cfgFile, envName string, plan interfaces.IaCPlan, providerGroups []DryRunProviderGroup, secretRefs []DryRunSecretRef, showSensitive bool) error { + fmt.Printf("Dry Run — infra apply\n") + fmt.Printf("=====================\n") + fmt.Printf("Config: %s\n", cfgFile) + if envName != "" { + fmt.Printf("Environment: %s\n", envName) + } + fmt.Println() + + if len(providerGroups) > 0 { + fmt.Printf("Providers:\n") + for _, pg := range providerGroups { + fmt.Printf(" - %s (%s): %d resource(s)\n", pg.ModuleRef, pg.ProviderType, pg.ResourceCount) + } + fmt.Println() + } + + fmt.Print(formatPlanTable(plan, showSensitive)) + fmt.Println() + + if len(secretRefs) > 0 { + fmt.Printf("Required Secrets:\n") + for _, s := range secretRefs { + store := "" + if s.Store != "" { + store = fmt.Sprintf(" (store: %s)", s.Store) + } + fmt.Printf(" - %s%s\n", s.Key, store) + } + fmt.Println() + } + + fmt.Printf("Dry run complete. No changes were applied.\n") + fmt.Printf("To apply, run: wfctl infra apply --env %s -c %s\n", envName, cfgFile) + return nil +} + +func printDryRunJSON(cfgFile, envName string, plan interfaces.IaCPlan, providerGroups []DryRunProviderGroup, secretRefs []DryRunSecretRef) error { + actions := make([]DryRunAction, 0, len(plan.Actions)) + for i := range plan.Actions { + a := &plan.Actions[i] + provRef, _ := a.Resource.Config["provider"].(string) + actions = append(actions, DryRunAction{ + Action: a.Action, + ResourceName: a.Resource.Name, + ResourceType: a.Resource.Type, + Provider: provRef, + }) + } + + creates, updates, deletes := countActions(plan) + replaces := 0 + for i := range plan.Actions { + if plan.Actions[i].Action == "replace" { + replaces++ + } + } + + output := DryRunApplyPlan{ + Command: "infra apply", + Config: cfgFile, + Environment: envName, + Actions: actions, + Secrets: secretRefs, + Providers: providerGroups, + Summary: DryRunSummary{ + Creates: creates, + Updates: updates, + Replaces: replaces, + Deletes: deletes, + }, + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(output) +} + +func collectProviderGroups(cfgFile, envName string, specs []interfaces.ResourceSpec) []DryRunProviderGroup { + cfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return nil + } + + // Build provider type lookup from iac.provider modules. + providerTypes := map[string]string{} + for i := range cfg.Modules { + m := &cfg.Modules[i] + if m.Type != "iac.provider" { + continue + } + var modCfg map[string]any + if envName != "" { + resolved, ok := m.ResolveForEnv(envName) + if !ok { + continue + } + modCfg = resolved.Config + } else { + modCfg = m.Config + } + pt, _ := modCfg["provider"].(string) + providerTypes[m.Name] = pt + } + + // Group specs by provider ref. + type groupAcc struct { + moduleRef string + provType string + count int + } + groups := map[string]*groupAcc{} + var order []string + for _, spec := range specs { + if !strings.HasPrefix(spec.Type, "infra.") { + continue + } + moduleRef, _ := spec.Config["provider"].(string) + if moduleRef == "" { + continue + } + if _, exists := groups[moduleRef]; !exists { + groups[moduleRef] = &groupAcc{ + moduleRef: moduleRef, + provType: providerTypes[moduleRef], + } + order = append(order, moduleRef) + } + groups[moduleRef].count++ + } + + result := make([]DryRunProviderGroup, 0, len(order)) + for _, ref := range order { + g := groups[ref] + result = append(result, DryRunProviderGroup{ + ModuleRef: g.moduleRef, + ProviderType: g.provType, + ResourceCount: g.count, + }) + } + return result +} + +func collectSecretRefs(cfgFile, envName string) []DryRunSecretRef { + cfg, err := config.LoadFromFile(cfgFile) + if err != nil || cfg.Secrets == nil { + return nil + } + + var refs []DryRunSecretRef + for _, entry := range cfg.Secrets.Entries { + store := "" + if entry.Store != "" { + store = entry.Store + } + refs = append(refs, DryRunSecretRef{ + Key: entry.Name, + Store: store, + Required: true, + }) + } + return refs +} diff --git a/docs/WFCTL.md b/docs/WFCTL.md index d5079736..e8f0b4ff 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -1225,7 +1225,7 @@ Reconcile cloud infrastructure to match the desired state declared in the config ``` wfctl infra apply [-c CONFIG] [--env ENV] [--auto-approve] [--plan FILE] [--refresh] [--allow-protected-prune] [--skip-refresh] - [--allow-replace=NAME1,NAME2,...] + [--allow-replace=NAME1,NAME2,...] [--dry-run] [--format FMT] ``` | Flag | Default | Description | @@ -1235,6 +1235,8 @@ wfctl infra apply [-c CONFIG] [--env ENV] [--auto-approve] [--plan FILE] | `-S`, `--show-sensitive` | `false` | Show sensitive values in plaintext | | `--env` | `` | Environment name (resolves per-module `environments:` overrides) | | `--plan` | `` | Apply from a pre-emitted `plan.json` (skips `ComputePlan`) | +| `--dry-run` | `false` | Show planned operations without executing provider mutations | +| `--format` | `table` | Dry-run output format: `table`, `json` | | `--refresh` | `false` | Detect drift and prune ghost-in-state entries before applying | | `--allow-protected-prune` | `false` | Allow pruning state entries for resources marked `protected: true` (requires `--refresh`) | | `--skip-refresh` | `false` | Skip the `WFCTL_REFRESH_OUTPUTS` pre-step refresh even if the env var is set | @@ -1265,6 +1267,12 @@ To authorize, re-run with the printed flag value. Names not in the list keep the **Examples:** ```bash +# Dry-run: preview what apply would do without mutations. +wfctl infra apply --dry-run --env staging -c infra.yaml + +# Dry-run with JSON output for automation. +wfctl infra apply --dry-run --format json --env staging -c infra.yaml + # Standard apply. wfctl infra apply --auto-approve -c infra.yaml --env staging @@ -1640,6 +1648,8 @@ wfctl ci run [options] | `--phase` | `build,test` | Comma-separated phases: `build`, `test`, `deploy` | | `--env` | `` | Target environment (required for `deploy` phase) | | `--verbose` | `false` | Show detailed command output | +| `--dry-run` | `false` | Show planned deploy operations without executing (deploy phase only) | +| `--format` | `table` | Dry-run output format: `table`, `json` | **Examples:** @@ -1650,6 +1660,12 @@ wfctl ci run --phase build,test # Deploy to staging wfctl ci run --phase deploy --env staging +# Dry-run deploy: preview what deploy would do without mutations. +wfctl ci run --phase deploy --dry-run --env staging + +# Dry-run with JSON output for CI automation. +wfctl ci run --phase deploy --dry-run --format json --env staging + # Full pipeline wfctl ci run --phase build,test,deploy --env production ``` From 5ac01998804ff58dcbf2b2be99955be396adb685 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 14:43:59 +0000 Subject: [PATCH 3/3] Address PR review feedback on dry-run implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gracefully handle state-load failure in infra apply dry-run (treat missing/inaccessible backend as empty rather than aborting) - Use ResolveSecretStore for both infra and CI deploy secret collection so env secretsStoreOverride / defaultStore / legacy provider are honored - Resolve deploy target via env-resolved wfCfg infra module names, mirroring newPluginDeployProvider's lookup logic (fixes bmw-staging) - Fall back to module config image field when IMAGE_TAG is unset, matching pluginDeployProvider.Deploy behavior - Fix countActions to include 'replace' and add ± symbol for replace actions; update formatPlanTable and formatPlanMarkdown summaries - Reject unknown --format values with an explicit error (no silent fallback) - Use config.LoadFromFile in ci run deploy dry-run path to honor imports: - Include --config path in follow-up command hint Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/b7c35821-d464-4ebb-93e4-5f9eb8f5ab40 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/ci_run.go | 8 +- cmd/wfctl/ci_run_dryrun.go | 177 ++++++++++++++++++--- cmd/wfctl/dryrun_test.go | 268 +++++++++++++++++++++++++++++++- cmd/wfctl/infra.go | 18 ++- cmd/wfctl/infra_apply_dryrun.go | 40 +++-- 5 files changed, 461 insertions(+), 50 deletions(-) diff --git a/cmd/wfctl/ci_run.go b/cmd/wfctl/ci_run.go index b0e4f4e7..56ddfea1 100644 --- a/cmd/wfctl/ci_run.go +++ b/cmd/wfctl/ci_run.go @@ -66,7 +66,13 @@ func runCIRun(args []string) error { return fmt.Errorf("--env is required for deploy phase") } if *dryRun { - return runDeployPhaseDryRun(cfg.CI.Deploy, *env, &cfg, cfg.Services, *dryRunFormat) + // Load via config.LoadFromFile to honor top-level imports: + // sections that yaml.Unmarshal above would not resolve. + fullCfg, loadErr := config.LoadFromFile(*configFile) + if loadErr != nil { + return fmt.Errorf("load config for dry-run: %w", loadErr) + } + return runDeployPhaseDryRun(fullCfg.CI.Deploy, *env, fullCfg, fullCfg.Services, *dryRunFormat, *configFile) } if *pluginDir != "" { os.Setenv("WFCTL_PLUGIN_DIR", *pluginDir) //nolint:errcheck diff --git a/cmd/wfctl/ci_run_dryrun.go b/cmd/wfctl/ci_run_dryrun.go index 32a1e650..25690c31 100644 --- a/cmd/wfctl/ci_run_dryrun.go +++ b/cmd/wfctl/ci_run_dryrun.go @@ -12,17 +12,17 @@ import ( // DryRunDeployPlan is the structured dry-run output for ci run --phase deploy. type DryRunDeployPlan struct { - Command string `json:"command"` - Environment string `json:"environment"` - Provider string `json:"provider"` - Strategy string `json:"strategy"` - PreDeploy []string `json:"pre_deploy,omitempty"` - DeployTarget string `json:"deploy_target"` - ImageRef string `json:"image_ref"` - ImageTagSource string `json:"image_tag_source"` - HealthCheck *DryRunHealthCheck `json:"health_check,omitempty"` - Secrets []DryRunSecretRef `json:"secrets,omitempty"` - Services []DryRunServiceEntry `json:"services,omitempty"` + Command string `json:"command"` + Environment string `json:"environment"` + Provider string `json:"provider"` + Strategy string `json:"strategy"` + PreDeploy []string `json:"pre_deploy,omitempty"` + DeployTarget string `json:"deploy_target"` + ImageRef string `json:"image_ref"` + ImageTagSource string `json:"image_tag_source"` + HealthCheck *DryRunHealthCheck `json:"health_check,omitempty"` + Secrets []DryRunSecretRef `json:"secrets,omitempty"` + Services []DryRunServiceEntry `json:"services,omitempty"` } // DryRunHealthCheck summarizes health check configuration. @@ -39,13 +39,19 @@ type DryRunServiceEntry struct { // runDeployPhaseDryRun prints what the deploy phase would execute without // performing any provider mutations, secret injection, or health polling. +// configFile is the path to the workflow config; it is used for the +// follow-up command hint and to honor top-level imports: when non-empty. func runDeployPhaseDryRun( deploy *config.CIDeployConfig, envName string, wfCfg *config.WorkflowConfig, services map[string]*config.ServiceConfig, format string, + configFile string, ) error { + if format != "table" && format != "json" { + return fmt.Errorf("unknown --format %q: supported values are table, json", format) + } if deploy == nil { return fmt.Errorf("no deploy configuration") } @@ -60,19 +66,35 @@ func runDeployPhaseDryRun( } strategy := cmp(env.Strategy, "rolling") + + // Resolve deploy target and image source. + // Priority for deploy target: env-resolved infra module name (same as + // newPluginDeployProvider) → env.Cluster → envName. + // Priority for image: IMAGE_TAG env var → module config image → "(not set)". + info := resolveDeployInfoFromConfig(wfCfg, envName, env.Provider) + imageTag := os.Getenv("IMAGE_TAG") imageTagSource := "IMAGE_TAG env var" - if imageTag == "" { + if imageTag == "" && info.Image != "" { + imageTag = info.Image + imageTagSource = "module config image field" + } else if imageTag == "" { imageTag = "(not set)" - imageTagSource = "IMAGE_TAG env var (not set)" + imageTagSource = "IMAGE_TAG env var (not set; no module config image found)" } - deployTarget := envName - if env.Cluster != "" { + deployTarget := info.Target + if deployTarget == "" { + // Fall back to cluster name or env name when no matching infra module found. deployTarget = env.Cluster + if deployTarget == "" { + deployTarget = envName + } } - // Collect secret keys that would be required. + // Collect secret keys that would be required, resolving stores via the + // same priority order (per-secret → env override → defaultStore → "env") + // that the real deploy path uses. secretRefs := collectDeploySecretRefs(wfCfg, envName) // Collect services. @@ -93,16 +115,123 @@ func runDeployPhaseDryRun( case "json": return printDeployDryRunJSON(envName, env, strategy, imageTag, imageTagSource, deployTarget, secretRefs, serviceEntries) default: - return printDeployDryRunTable(envName, env, strategy, imageTag, imageTagSource, deployTarget, secretRefs, serviceEntries) + return printDeployDryRunTable(envName, env, strategy, imageTag, imageTagSource, deployTarget, secretRefs, serviceEntries, configFile) } } +// resolvedDeployInfo holds the env-resolved deploy target and image values +// extracted from the workflow config modules. +type resolvedDeployInfo struct { + // Target is the env-resolved name of the infra module used for deployment + // (e.g. "bmw-staging" when the base module name is "bmw-app" and the + // staging environment overrides the name). + Target string + // Image is the image field from the resolved module config, used as a + // fallback when IMAGE_TAG is not set in the environment. + Image string +} + +// resolveDeployInfoFromConfig replicates the provider and target-module +// resolution that newPluginDeployProvider performs so the dry-run output +// reflects the same resource identity as a real deploy. +// +// If wfCfg is nil or contains no matching modules, an empty result is +// returned (callers fall back to cluster name or env name). +func resolveDeployInfoFromConfig(wfCfg *config.WorkflowConfig, envName, providerName string) resolvedDeployInfo { + if wfCfg == nil || len(wfCfg.Modules) == 0 || providerName == "" { + return resolvedDeployInfo{} + } + + // resolveModule mirrors the inner helper in newPluginDeployProvider. + resolveModule := func(m *config.ModuleConfig) (*config.ResolvedModule, bool) { + if envName == "" { + return &config.ResolvedModule{Name: m.Name, Type: m.Type, Config: m.Config}, true + } + return m.ResolveForEnv(envName) + } + + // Step 1: find the iac.provider module whose config.provider or module + // name matches the requested provider name. + var providerModName string + for i := range wfCfg.Modules { + m := &wfCfg.Modules[i] + if m.Type != "iac.provider" { + continue + } + resolved, ok := resolveModule(m) + if !ok { + continue + } + cfgProvider, _ := resolved.Config["provider"].(string) + if cfgProvider == providerName || resolved.Name == providerName { + providerModName = resolved.Name + break + } + } + if providerModName == "" { + return resolvedDeployInfo{} + } + + // Step 2: find the deploy-target module — same priority list as + // newPluginDeployProvider — then fall back to any infra.* module that + // references the provider. + deployTargetTypes := []string{ + "infra.container_service", + "platform.do_app", + "platform.app_platform", + "infra.k8s_cluster", + } + + findByType := func(targetType string) (resolvedDeployInfo, bool) { + for i := range wfCfg.Modules { + m := &wfCfg.Modules[i] + if m.Type != targetType { + continue + } + resolved, ok := resolveModule(m) + if !ok { + continue + } + if p, _ := resolved.Config["provider"].(string); p == providerModName { + img, _ := resolved.Config["image"].(string) + return resolvedDeployInfo{Target: resolved.Name, Image: img}, true + } + } + return resolvedDeployInfo{}, false + } + + for _, t := range deployTargetTypes { + if info, ok := findByType(t); ok { + return info + } + } + + // Fallback: first infra.* module with a matching provider reference. + for i := range wfCfg.Modules { + m := &wfCfg.Modules[i] + if m.Type == "iac.provider" || !strings.HasPrefix(m.Type, "infra.") { + continue + } + resolved, ok := resolveModule(m) + if !ok { + continue + } + if p, _ := resolved.Config["provider"].(string); p == providerModName { + img, _ := resolved.Config["image"].(string) + return resolvedDeployInfo{Target: resolved.Name, Image: img} + } + } + + return resolvedDeployInfo{} +} + func printDeployDryRunTable( envName string, env *config.CIDeployEnvironment, strategy, imageTag, imageTagSource, deployTarget string, secretRefs []DryRunSecretRef, services []DryRunServiceEntry, + configFile string, ) error { fmt.Printf("Dry Run — ci deploy\n") fmt.Printf("====================\n") @@ -162,7 +291,11 @@ func printDeployDryRunTable( } fmt.Printf("Dry run complete. No deployment was executed.\n") - fmt.Printf("To deploy, run: wfctl ci run --phase deploy --env %s\n", envName) + deployCmd := fmt.Sprintf("wfctl ci run --phase deploy --env %s", envName) + if configFile != "" { + deployCmd += fmt.Sprintf(" --config %s", configFile) + } + fmt.Printf("To deploy, run: %s\n", deployCmd) return nil } @@ -207,10 +340,10 @@ func collectDeploySecretRefs(cfg *config.WorkflowConfig, envName string) []DryRu var refs []DryRunSecretRef for _, entry := range cfg.Secrets.Entries { - store := "" - if entry.Store != "" { - store = entry.Store - } + // Use ResolveSecretStore so env-level overrides (secretsStoreOverride), + // defaultStore, and legacy provider fields are applied — matching the + // priority order the real deploy path uses when it calls injectSecrets. + store := ResolveSecretStore(entry.Name, envName, cfg) refs = append(refs, DryRunSecretRef{ Key: entry.Name, Store: store, diff --git a/cmd/wfctl/dryrun_test.go b/cmd/wfctl/dryrun_test.go index ec27783d..02fe112e 100644 --- a/cmd/wfctl/dryrun_test.go +++ b/cmd/wfctl/dryrun_test.go @@ -387,9 +387,9 @@ func TestDryRunSharesPlanningLogic(t *testing.T) { deploy := &config.CIDeployConfig{ Environments: map[string]*config.CIDeployEnvironment{ "staging": { - Provider: "do-app-platform", - Cluster: "bmw-staging", - Strategy: "rolling", + Provider: "do-app-platform", + Cluster: "bmw-staging", + Strategy: "rolling", PreDeploy: []string{"run-migrations"}, HealthCheck: &config.CIHealthCheck{ Path: "/health", @@ -405,7 +405,7 @@ func TestDryRunSharesPlanningLogic(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - err := runDeployPhaseDryRun(deploy, "staging", nil, nil, "json") + err := runDeployPhaseDryRun(deploy, "staging", nil, nil, "json", "") w.Close() os.Stdout = origStdout @@ -423,6 +423,7 @@ func TestDryRunSharesPlanningLogic(t *testing.T) { } // These are the same values that runDeployPhaseWithConfig would resolve. + // When wfCfg is nil (no modules), deploy target falls back to env.Cluster. if result.DeployTarget != "bmw-staging" { t.Errorf("expected deploy target 'bmw-staging', got %q", result.DeployTarget) } @@ -436,3 +437,262 @@ func TestDryRunSharesPlanningLogic(t *testing.T) { t.Errorf("expected pre-deploy [run-migrations], got %v", result.PreDeploy) } } + +func TestDryRunInvalidFormat_InfraApply(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(` +modules: + - name: p + type: iac.provider + config: + provider: mock +`), 0o644); err != nil { + t.Fatal(err) + } + stubProviderForDryRunTests(t) + platform.SetDiffCacheForTest(t, nil) + + err := runInfraApply([]string{"--dry-run", "--format", "jsno", "--config", cfgPath}) + if err == nil { + t.Fatal("expected error for unknown format, got nil") + } + if !strings.Contains(err.Error(), "jsno") { + t.Errorf("expected error to mention the bad value, got: %v", err) + } +} + +func TestDryRunInvalidFormat_CIDeploy(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "app.yaml") + if err := os.WriteFile(cfgPath, []byte(` +version: 1 +ci: + deploy: + environments: + staging: + provider: do-app-platform +`), 0o644); err != nil { + t.Fatal(err) + } + err := runCIRun([]string{"--config", cfgPath, "--phase", "deploy", "--env", "staging", "--dry-run", "--format", "jsno"}) + if err == nil { + t.Fatal("expected error for unknown format, got nil") + } + if !strings.Contains(err.Error(), "jsno") { + t.Errorf("expected error to mention the bad value, got: %v", err) + } +} + +func TestCIRunDeployDryRun_SecretStoreEnvOverride(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "app.yaml") + cfgContent := ` +version: 1 +ci: + deploy: + environments: + staging: + provider: do-app-platform +secrets: + defaultStore: github + entries: + - name: API_KEY + - name: DB_PASS + store: vault +environments: + staging: + secretsStoreOverride: doppler +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatal(err) + } + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runCIRun([]string{"--config", cfgPath, "--phase", "deploy", "--env", "staging", "--dry-run", "--format", "json"}) + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("dry-run should not error: %v", err) + } + + var result DryRunDeployPlan + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("invalid JSON: %v\nOutput: %s", err, buf.String()) + } + + // API_KEY: no per-secret store → env secretsStoreOverride = "doppler" + // DB_PASS: per-secret store = "vault" (highest priority) + stores := map[string]string{} + for _, s := range result.Secrets { + stores[s.Key] = s.Store + } + if stores["API_KEY"] != "doppler" { + t.Errorf("expected API_KEY store 'doppler' (env override), got %q", stores["API_KEY"]) + } + if stores["DB_PASS"] != "vault" { + t.Errorf("expected DB_PASS store 'vault' (per-secret), got %q", stores["DB_PASS"]) + } +} + +func TestDeployDryRun_ImageFromModuleConfig(t *testing.T) { + // When IMAGE_TAG is unset, the dry-run should show the image from the + // module config — matching what pluginDeployProvider.Deploy preserves. + t.Setenv("IMAGE_TAG", "") + + wfCfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + { + Name: "do-provider", + Type: "iac.provider", + Config: map[string]any{"provider": "digitalocean"}, + }, + { + Name: "my-app", + Type: "infra.container_service", + Config: map[string]any{ + "provider": "do-provider", + "image": "registry.example.com/my-app:v1.2.3", + }, + }, + }, + } + + deploy := &config.CIDeployConfig{ + Environments: map[string]*config.CIDeployEnvironment{ + "staging": {Provider: "digitalocean"}, + }, + } + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runDeployPhaseDryRun(deploy, "staging", wfCfg, nil, "json", "") + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result DryRunDeployPlan + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("invalid JSON: %v\nOutput: %s", err, buf.String()) + } + + if result.ImageRef != "registry.example.com/my-app:v1.2.3" { + t.Errorf("expected image from module config, got %q", result.ImageRef) + } + if result.ImageTagSource != "module config image field" { + t.Errorf("expected image tag source 'module config image field', got %q", result.ImageTagSource) + } +} + +func TestDeployDryRun_EnvResolvedModuleName(t *testing.T) { + // Verify that the env-resolved infra module name is used as the deploy target, + // matching the behavior of newPluginDeployProvider. + t.Setenv("IMAGE_TAG", "api:latest") + + wfCfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + { + Name: "do-provider", + Type: "iac.provider", + Config: map[string]any{"provider": "digitalocean"}, + }, + { + Name: "bmw-app", + Type: "infra.container_service", + Config: map[string]any{"provider": "do-provider"}, + Environments: map[string]*config.InfraEnvironmentResolution{ + "staging": { + Config: map[string]any{"name": "bmw-staging"}, + }, + }, + }, + }, + } + + deploy := &config.CIDeployConfig{ + Environments: map[string]*config.CIDeployEnvironment{ + "staging": {Provider: "digitalocean"}, + }, + } + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runDeployPhaseDryRun(deploy, "staging", wfCfg, nil, "json", "") + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result DryRunDeployPlan + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("invalid JSON: %v\nOutput: %s", err, buf.String()) + } + + // The env-resolved name comes from ResolveForEnv("staging") which lifts + // the "name" field from environments.staging.config into resolved.Name + // (for infra.* types). This matches exactly what newPluginDeployProvider + // uses as resourceName — the env-overridden identity "bmw-staging". + if result.DeployTarget != "bmw-staging" { + t.Errorf("expected env-resolved deploy target 'bmw-staging', got %q", result.DeployTarget) + } +} + +func TestDryRunFollowUpCommand_IncludesConfigPath(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "myconfig.yaml") + cfgContent := ` +version: 1 +ci: + deploy: + environments: + staging: + provider: do-app-platform +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatal(err) + } + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runCIRun([]string{"--config", cfgPath, "--phase", "deploy", "--env", "staging", "--dry-run"}) + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("dry-run should not error: %v", err) + } + output := buf.String() + // The suggested follow-up command must include the config file path. + if !strings.Contains(output, "--config") || !strings.Contains(output, "myconfig.yaml") { + t.Errorf("follow-up command should include --config , got:\n%s", output) + } +} + diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index caa02997..48247793 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -535,9 +535,9 @@ func formatPlanTable(plan interfaces.IaCPlan, showSensitive bool) string { fmt.Fprintln(&sb) } - creates, updates, deletes := countActions(plan) - fmt.Fprintf(&sb, "Plan: %d to create, %d to update, %d to destroy.\n", - creates, updates, deletes) + creates, updates, replaces, deletes := countActions(plan) + fmt.Fprintf(&sb, "Plan: %d to create, %d to update, %d to replace, %d to destroy.\n", + creates, updates, replaces, deletes) return sb.String() } @@ -577,9 +577,9 @@ func formatPlanMarkdown(plan interfaces.IaCPlan, showSensitive bool) string { sb.WriteString("\n\n") } - creates, updates, deletes := countActions(plan) - fmt.Fprintf(&sb, "**Plan: %d to create, %d to update, %d to destroy.**\n", - creates, updates, deletes) + creates, updates, replaces, deletes := countActions(plan) + fmt.Fprintf(&sb, "**Plan: %d to create, %d to update, %d to replace, %d to destroy.**\n", + creates, updates, replaces, deletes) return sb.String() } @@ -796,6 +796,8 @@ func actionSymbol(action string) string { return "+" case "update": return "~" + case "replace": + return "±" case "delete": return "-" default: @@ -803,13 +805,15 @@ func actionSymbol(action string) string { } } -func countActions(plan interfaces.IaCPlan) (creates, updates, deletes int) { +func countActions(plan interfaces.IaCPlan) (creates, updates, replaces, deletes int) { for i := range plan.Actions { switch plan.Actions[i].Action { case "create": creates++ case "update": updates++ + case "replace": + replaces++ case "delete": deletes++ } diff --git a/cmd/wfctl/infra_apply_dryrun.go b/cmd/wfctl/infra_apply_dryrun.go index 99cf88c0..1c56174f 100644 --- a/cmd/wfctl/infra_apply_dryrun.go +++ b/cmd/wfctl/infra_apply_dryrun.go @@ -39,9 +39,9 @@ type DryRunSecretRef struct { // DryRunProviderGroup summarizes resources grouped by provider. type DryRunProviderGroup struct { - ModuleRef string `json:"module_ref"` - ProviderType string `json:"provider_type"` - ResourceCount int `json:"resource_count"` + ModuleRef string `json:"module_ref"` + ProviderType string `json:"provider_type"` + ResourceCount int `json:"resource_count"` } // DryRunSummary provides counts of planned operations. @@ -56,6 +56,10 @@ type DryRunSummary struct { // (config resolution, environment overrides, provider selection) but // prints the plan and exits without executing any provider mutations. func runInfraApplyDryRun(cfgFile, envName, format string, showSensitive bool) error { + if format != "table" && format != "json" { + return fmt.Errorf("unknown --format %q: supported values are table, json", format) + } + desired, err := parseInfraResourceSpecsForEnv(cfgFile, envName) if err != nil { return fmt.Errorf("parse infra resource specs: %w", err) @@ -64,9 +68,15 @@ func runInfraApplyDryRun(cfgFile, envName, format string, showSensitive bool) er return err } + // Treat state-load failure as empty state so dry-run works even when the + // remote state backend does not yet exist (e.g. a fresh config whose + // iac.state Spaces bucket has not been bootstrapped yet). The same + // first-run assumption is safe here because a missing backend yields an + // all-create plan — the correct preview for that scenario. current, err := loadCurrentState(cfgFile, envName) if err != nil { - return fmt.Errorf("load current state: %w", err) + fmt.Fprintf(os.Stderr, "note: could not load current state (treating as empty for dry-run): %v\n", err) + current = nil } plan, err := computePlanForInfraSpecs(context.Background(), cfgFile, envName, desired, current) @@ -121,7 +131,11 @@ func printDryRunTable(cfgFile, envName string, plan interfaces.IaCPlan, provider } fmt.Printf("Dry run complete. No changes were applied.\n") - fmt.Printf("To apply, run: wfctl infra apply --env %s -c %s\n", envName, cfgFile) + applyCmd := fmt.Sprintf("wfctl infra apply -c %s", cfgFile) + if envName != "" { + applyCmd += fmt.Sprintf(" --env %s", envName) + } + fmt.Printf("To apply, run: %s\n", applyCmd) return nil } @@ -138,13 +152,7 @@ func printDryRunJSON(cfgFile, envName string, plan interfaces.IaCPlan, providerG }) } - creates, updates, deletes := countActions(plan) - replaces := 0 - for i := range plan.Actions { - if plan.Actions[i].Action == "replace" { - replaces++ - } - } + creates, updates, replaces, deletes := countActions(plan) output := DryRunApplyPlan{ Command: "infra apply", @@ -239,10 +247,10 @@ func collectSecretRefs(cfgFile, envName string) []DryRunSecretRef { var refs []DryRunSecretRef for _, entry := range cfg.Secrets.Entries { - store := "" - if entry.Store != "" { - store = entry.Store - } + // Use ResolveSecretStore so env-level overrides (secretsStoreOverride) + // and defaultStore are applied — matching the priority order the real + // apply path uses when it calls injectSecrets. + store := ResolveSecretStore(entry.Name, envName, cfg) refs = append(refs, DryRunSecretRef{ Key: entry.Name, Store: store,