Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand Down
232 changes: 232 additions & 0 deletions cmd/wfctl/infra_apply_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
41 changes: 39 additions & 2 deletions cmd/wfctl/infra_output_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}
Expand Down
60 changes: 60 additions & 0 deletions cmd/wfctl/infra_output_secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading