diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 5a3e9de0..000b39ed 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -1325,6 +1325,7 @@ func runInfraApply(args []string) error { } ctx := context.Background() + infraOutputSourceScope := parseIncludeFlag(includeFlag) // Inject secrets after bootstrap so generated secrets are available. if envName != "" { @@ -1435,6 +1436,7 @@ func runInfraApply(args []string) error { return fmt.Errorf("parse infra resource specs: %w", err) } planIncludeSet := includeSetFromNames(plan.Include) + infraOutputSourceScope = planIncludeSet if plan.DesiredHash == "" { return fmt.Errorf("plan file has no hash — regenerate with: wfctl infra plan -o plan.json") } @@ -1571,7 +1573,7 @@ func runInfraApply(args []string) error { } } } - return syncInfraOutputSecrets(ctx, secretsCfg, secretsProvider, states, wfCfg, envName, runHydrated, refreshOutputsFlag) + return syncInfraOutputSecretsScoped(ctx, secretsCfg, secretsProvider, states, wfCfg, envName, runHydrated, refreshOutputsFlag, infraOutputSourceScope) } func runInfraStatus(args []string) error { diff --git a/cmd/wfctl/infra_apply_plan_test.go b/cmd/wfctl/infra_apply_plan_test.go index b612e24a..d4db1781 100644 --- a/cmd/wfctl/infra_apply_plan_test.go +++ b/cmd/wfctl/infra_apply_plan_test.go @@ -206,6 +206,238 @@ modules: } } +func TestInfraApplyScopedPlanSyncsOnlyScopedInfraOutputSecrets(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + cfgPath := filepath.Join(dir, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(` +secrets: + provider: env + config: + prefix: TEST_SCOPED_SYNC_ + generate: + - key: DATABASE_URL + type: infra_output + source: bmw-database.uri + - key: WWW_TARGET + type: infra_output + source: bmw-dns.target +modules: + - name: test-provider + type: iac.provider + config: + provider: fake-cloud + token: "test-token" + + - name: iac-state + type: iac.state + config: + backend: filesystem + directory: `+stateDir+` + + - name: bmw-database + type: infra.database + config: + provider: test-provider + engine: postgres + size: s + + - name: bmw-dns + type: infra.dns + config: + provider: test-provider + domain: www.buymywishlist.com + target: buymywishlist.com. +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + if err := os.Unsetenv("TEST_SCOPED_SYNC_DATABASE_URL"); err != nil { + t.Fatalf("unset TEST_SCOPED_SYNC_DATABASE_URL: %v", err) + } + if err := os.Unsetenv("TEST_SCOPED_SYNC_WWW_TARGET"); err != nil { + t.Fatalf("unset TEST_SCOPED_SYNC_WWW_TARGET: %v", err) + } + t.Cleanup(func() { + _ = os.Unsetenv("TEST_SCOPED_SYNC_DATABASE_URL") + _ = os.Unsetenv("TEST_SCOPED_SYNC_WWW_TARGET") + }) + + specs, err := parseInfraResourceSpecs(cfgPath) + if err != nil { + t.Fatalf("parseInfraResourceSpecs: %v", err) + } + var dnsSpec interfaces.ResourceSpec + for _, spec := range specs { + if spec.Name == "bmw-dns" { + dnsSpec = spec + break + } + } + if dnsSpec.Name == "" { + t.Fatal("bmw-dns spec not found") + } + plan := interfaces.IaCPlan{ + ID: "scoped-dns-plan", + DesiredHash: desiredStateHash([]interfaces.ResourceSpec{dnsSpec}), + Include: []string{"bmw-dns"}, + Actions: []interfaces.PlanAction{{ + Action: "create", + Resource: dnsSpec, + }}, + CreatedAt: time.Now().UTC(), + } + planData, err := json.Marshal(plan) + if err != nil { + t.Fatalf("marshal plan: %v", err) + } + planPath := filepath.Join(dir, "plan.json") + if err := os.WriteFile(planPath, planData, 0o600); err != nil { + t.Fatalf("write plan: %v", err) + } + + provider := &stateReturningProvider{ + applyResult: &interfaces.ApplyResult{ + Resources: []interfaces.ResourceOutput{{ + Name: "bmw-dns", + Type: "infra.dns", + ProviderID: "dns-www", + Outputs: map[string]any{ + "target": "buymywishlist.com.", + }, + }}, + }, + } + origResolve := resolveIaCProvider + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return provider, nil, nil + } + t.Cleanup(func() { resolveIaCProvider = origResolve }) + provider.installAsV2Dispatch(t) + + if err := runInfraApply([]string{"--auto-approve", "--config", cfgPath, "--plan", planPath, "--skip-bootstrap"}); err != nil { + t.Fatalf("runInfraApply scoped plan: %v", err) + } + if got := os.Getenv("TEST_SCOPED_SYNC_WWW_TARGET"); got != "buymywishlist.com." { + t.Fatalf("TEST_SCOPED_SYNC_WWW_TARGET = %q, want DNS target", got) + } + if got := os.Getenv("TEST_SCOPED_SYNC_DATABASE_URL"); got != "" { + t.Fatalf("TEST_SCOPED_SYNC_DATABASE_URL = %q, want untouched", got) + } +} + +func TestInfraApplyScopedPlanSyncsEnvRenamedInfraOutputSecrets(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + cfgPath := filepath.Join(dir, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(` +secrets: + provider: env + config: + prefix: TEST_SCOPED_ENV_SYNC_ + generate: + - key: DATABASE_URL + type: infra_output + source: bmw-database.uri +modules: + - name: test-provider + type: iac.provider + config: + provider: fake-cloud + token: "test-token" + + - name: iac-state + type: iac.state + config: + backend: filesystem + directory: `+stateDir+` + + - name: bmw-database + type: infra.database + config: + provider: test-provider + engine: postgres + size: s + environments: + staging: + config: + name: bmw-staging-db + + - name: bmw-dns + type: infra.dns + config: + provider: test-provider + domain: www.buymywishlist.com + target: buymywishlist.com. +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + if err := os.Unsetenv("TEST_SCOPED_ENV_SYNC_DATABASE_URL"); err != nil { + t.Fatalf("unset TEST_SCOPED_ENV_SYNC_DATABASE_URL: %v", err) + } + t.Cleanup(func() { + _ = os.Unsetenv("TEST_SCOPED_ENV_SYNC_DATABASE_URL") + }) + + specs, err := parseInfraResourceSpecsForEnv(cfgPath, "staging") + if err != nil { + t.Fatalf("parseInfraResourceSpecsForEnv: %v", err) + } + var dbSpec interfaces.ResourceSpec + for _, spec := range specs { + if spec.Name == "bmw-staging-db" { + dbSpec = spec + break + } + } + if dbSpec.Name == "" { + t.Fatal("bmw-staging-db spec not found") + } + plan := interfaces.IaCPlan{ + ID: "scoped-staging-db-plan", + DesiredHash: desiredStateHash([]interfaces.ResourceSpec{dbSpec}), + Include: []string{"bmw-staging-db"}, + Actions: []interfaces.PlanAction{{ + Action: "create", + Resource: dbSpec, + }}, + CreatedAt: time.Now().UTC(), + } + planData, err := json.Marshal(plan) + if err != nil { + t.Fatalf("marshal plan: %v", err) + } + planPath := filepath.Join(dir, "plan.json") + if err := os.WriteFile(planPath, planData, 0o600); err != nil { + t.Fatalf("write plan: %v", err) + } + + provider := &stateReturningProvider{ + applyResult: &interfaces.ApplyResult{ + Resources: []interfaces.ResourceOutput{{ + Name: "bmw-staging-db", + Type: "infra.database", + ProviderID: "db-staging", + Outputs: map[string]any{ + "uri": "postgres://staging.example.com/bmw", + }, + }}, + }, + } + origResolve := resolveIaCProvider + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return provider, nil, nil + } + t.Cleanup(func() { resolveIaCProvider = origResolve }) + provider.installAsV2Dispatch(t) + + if err := runInfraApply([]string{"--auto-approve", "--config", cfgPath, "--env", "staging", "--plan", planPath, "--skip-bootstrap"}); err != nil { + t.Fatalf("runInfraApply scoped staging plan: %v", err) + } + if got := os.Getenv("TEST_SCOPED_ENV_SYNC_DATABASE_URL"); got != "postgres://staging.example.com/bmw" { + t.Fatalf("TEST_SCOPED_ENV_SYNC_DATABASE_URL = %q, want staging DB URI", got) + } +} + func TestInfraApplyPlanSkipBootstrap(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "infra.yaml") diff --git a/cmd/wfctl/infra_output_secrets.go b/cmd/wfctl/infra_output_secrets.go index 73b9af53..b2b6fd73 100644 --- a/cmd/wfctl/infra_output_secrets.go +++ b/cmd/wfctl/infra_output_secrets.go @@ -131,14 +131,22 @@ func stateKeys(m map[string]map[string]any) []string { // providers like GitHub Actions). May be nil for callers that don't // have a same-process apply hand-off (e.g., wfctl infra outputs CLI). func syncInfraOutputSecrets(ctx context.Context, secretsCfg *SecretsConfig, provider secrets.Provider, states []interfaces.ResourceState, wfCfg *config.WorkflowConfig, envName string, hydrated map[string]string, refreshOutputs bool) error { + return syncInfraOutputSecretsScoped(ctx, secretsCfg, provider, states, wfCfg, envName, hydrated, refreshOutputs, nil) +} + +func syncInfraOutputSecretsScoped(ctx context.Context, secretsCfg *SecretsConfig, provider secrets.Provider, states []interfaces.ResourceState, wfCfg *config.WorkflowConfig, envName string, hydrated map[string]string, refreshOutputs bool, sourceModuleScope map[string]struct{}) error { if secretsCfg == nil { return nil } var gens []SecretGen for _, g := range secretsCfg.Generate { - if g.Type == "infra_output" { - gens = append(gens, g) + if g.Type != "infra_output" { + continue } + if !infraOutputSourceInScope(wfCfg, g.Source, envName, sourceModuleScope) { + continue + } + gens = append(gens, g) } if len(gens) == 0 { return nil @@ -219,6 +227,35 @@ func syncInfraOutputSecrets(ctx context.Context, secretsCfg *SecretsConfig, prov return nil } +func infraOutputSourceInScope(wfCfg *config.WorkflowConfig, source, envName string, scope map[string]struct{}) bool { + if len(scope) == 0 { + return true + } + dot := strings.Index(source, ".") + if dot < 1 { + return false + } + moduleName := source[:dot] + if envName != "" && wfCfg != nil { + for i := range wfCfg.Modules { + m := &wfCfg.Modules[i] + if m.Name != moduleName { + continue + } + resolved, ok := m.ResolveForEnv(envName) + if !ok { + return false + } + if resolved.Name != "" { + moduleName = resolved.Name + } + break + } + } + _, ok := scope[moduleName] + return ok +} + type providerListLookup struct { provider secrets.Provider listSet map[string]struct{} diff --git a/cmd/wfctl/infra_output_secrets_test.go b/cmd/wfctl/infra_output_secrets_test.go index 739d3937..bfb9b84e 100644 --- a/cmd/wfctl/infra_output_secrets_test.go +++ b/cmd/wfctl/infra_output_secrets_test.go @@ -215,6 +215,66 @@ func TestSyncInfraOutputSecrets_WritesMultiple(t *testing.T) { } } +func TestSyncInfraOutputSecretsScoped_SkipsOutOfScopeGenerators(t *testing.T) { + p := newSimpleProvider() + cfg := &SecretsConfig{ + Generate: []SecretGen{ + {Key: "DATABASE_URL", Type: "infra_output", Source: "bmw-database.uri"}, + {Key: "WWW_TARGET", Type: "infra_output", Source: "bmw-dns.target"}, + }, + } + states := []interfaces.ResourceState{ + { + Name: "bmw-dns", + Type: "infra.dns", + Outputs: map[string]any{ + "target": "buymywishlist.com.", + }, + }, + } + scope := map[string]struct{}{"bmw-dns": {}} + + err := syncInfraOutputSecretsScoped(context.Background(), cfg, p, states, nil, "", nil, false, scope) + if err != nil { + t.Fatalf("syncInfraOutputSecretsScoped: %v", err) + } + if _, ok := p.data["DATABASE_URL"]; ok { + t.Fatalf("out-of-scope DATABASE_URL was written: %v", p.data) + } + if got := p.data["WWW_TARGET"]; got != "buymywishlist.com." { + t.Fatalf("WWW_TARGET = %q, want DNS target", got) + } +} + +func TestInfraOutputSourceInScope_InvalidSourceExcluded(t *testing.T) { + scope := map[string]struct{}{"bmw-dns": {}} + if infraOutputSourceInScope(nil, "DATABASE_URL", "", scope) { + t.Fatal("invalid infra_output source must not match a scoped apply") + } +} + +func TestInfraOutputSourceInScope_ResolvesEnvModuleName(t *testing.T) { + scope := map[string]struct{}{"bmw-staging-db": {}} + wfCfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{{ + Name: "bmw-database", + Type: "infra.database", + Config: map[string]any{ + "provider": "test-provider", + }, + Environments: map[string]*config.InfraEnvironmentResolution{ + "staging": { + Config: map[string]any{"name": "bmw-staging-db"}, + }, + }, + }}, + } + + if !infraOutputSourceInScope(wfCfg, "bmw-database.uri", "staging", scope) { + t.Fatal("env-resolved infra_output source module should match scoped resource name") + } +} + func TestSyncInfraOutputSecrets_SkipsExisting(t *testing.T) { p := newSimpleProvider() p.data["DATABASE_URL"] = "already-set"