diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 1a43559e..878d8581 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -441,13 +441,20 @@ func extractDependsOn(cfg map[string]any) []string { // populating Size and DependsOn from the resolved Config. Used by both the // --env and no-env paths so field extraction never diverges. func resourceSpecFromResolvedModule(r *config.ResolvedModule) interfaces.ResourceSpec { + cfg := cloneMap(r.Config) + if r.Protected { + if cfg == nil { + cfg = map[string]any{} + } + cfg["protected"] = true + } spec := interfaces.ResourceSpec{ Name: r.Name, Type: r.Type, - Config: r.Config, - DependsOn: extractDependsOn(r.Config), + Config: cfg, + DependsOn: extractDependsOn(cfg), } - if size, ok := r.Config["size"].(string); ok { + if size, ok := cfg["size"].(string); ok { spec.Size = interfaces.Size(size) } return spec @@ -531,7 +538,7 @@ func parseInfraResourceSpecs(cfgFile string) ([]interfaces.ResourceSpec, error) if !isInfraType(m.Type) { continue } - r := &config.ResolvedModule{Name: m.Name, Type: m.Type, Config: config.ExpandEnvInMapPreservingVars(m.Config, infraPreserveKeys, secretVars)} + r := &config.ResolvedModule{Name: m.Name, Type: m.Type, Protected: m.Protected, Config: config.ExpandEnvInMapPreservingVars(m.Config, infraPreserveKeys, secretVars)} specs = append(specs, resourceSpecFromResolvedModule(r)) } return specs, nil @@ -578,7 +585,7 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error) continue } if envName == "" { - out = append(out, &config.ResolvedModule{Name: m.Name, Type: m.Type, Config: config.ExpandEnvInMapPreservingVars(m.Config, infraPreserveKeys, secretVars)}) + out = append(out, &config.ResolvedModule{Name: m.Name, Type: m.Type, Protected: m.Protected, Config: config.ExpandEnvInMapPreservingVars(m.Config, infraPreserveKeys, secretVars)}) continue } resolved, ok := m.ResolveForEnv(envName) diff --git a/cmd/wfctl/infra_test.go b/cmd/wfctl/infra_test.go index 381fb5e2..bc18dd75 100644 --- a/cmd/wfctl/infra_test.go +++ b/cmd/wfctl/infra_test.go @@ -116,6 +116,33 @@ modules: } } +func TestParseInfraResourceSpecs_TopLevelProtectedBecomesConfig(t *testing.T) { + yaml := ` +modules: + - name: app + type: infra.container_service + protected: true + config: + image: ghcr.io/example/app:latest +` + f, err := writeTempYAML(t, yaml) + if err != nil { + t.Fatal(err) + } + defer os.Remove(f) + + specs, err := parseInfraResourceSpecs(f) + if err != nil { + t.Fatal(err) + } + if len(specs) != 1 { + t.Fatalf("expected 1 spec, got %d", len(specs)) + } + if got, _ := specs[0].Config["protected"].(bool); !got { + t.Fatalf("spec.Config[protected] = %#v, want true", specs[0].Config["protected"]) + } +} + // --- formatPlanTable tests --- func TestFormatPlanTable_ShowsAllActions(t *testing.T) { diff --git a/config/config.go b/config/config.go index 8457cbc5..17b3ba23 100644 --- a/config/config.go +++ b/config/config.go @@ -74,6 +74,7 @@ type ModuleConfig struct { Name string `json:"name" yaml:"name"` Type string `json:"type" yaml:"type"` Satisfies []string `json:"satisfies,omitempty" yaml:"satisfies,omitempty"` + Protected bool `json:"protected,omitempty" yaml:"protected,omitempty"` Config map[string]any `json:"config,omitempty" yaml:"config,omitempty"` DependsOn []string `json:"dependsOn,omitempty" yaml:"dependsOn,omitempty"` Branches map[string]string `json:"branches,omitempty" yaml:"branches,omitempty"` diff --git a/config/module_resolve_env.go b/config/module_resolve_env.go index dc0b4e74..7213acf5 100644 --- a/config/module_resolve_env.go +++ b/config/module_resolve_env.go @@ -4,11 +4,12 @@ import "strings" // ResolvedModule is the effective module config for a specific environment. type ResolvedModule struct { - Name string - Type string - Provider string - Region string - Config map[string]any + Name string + Type string + Provider string + Region string + Protected bool + Config map[string]any } // ResolveForEnv returns the effective module config for envName. @@ -19,9 +20,10 @@ type ResolvedModule struct { // construction (which reads only Config) picks them up. func (m *ModuleConfig) ResolveForEnv(envName string) (*ResolvedModule, bool) { resolved := &ResolvedModule{ - Name: m.Name, - Type: m.Type, - Config: cloneMap(m.Config), + Name: m.Name, + Type: m.Type, + Protected: m.Protected, + Config: cloneMap(m.Config), } setRegionFromConfig(resolved) diff --git a/config/module_resolve_env_test.go b/config/module_resolve_env_test.go index 7ba5de1f..13c737f7 100644 --- a/config/module_resolve_env_test.go +++ b/config/module_resolve_env_test.go @@ -17,6 +17,22 @@ func TestResolveForEnv_NoEnvironments_ReturnsTopLevel(t *testing.T) { } } +func TestResolveForEnv_PreservesTopLevelProtected(t *testing.T) { + m := &ModuleConfig{ + Name: "db", + Type: "infra.database", + Protected: true, + Config: map[string]any{"size": "small"}, + } + resolved, ok := m.ResolveForEnv("prod") + if !ok { + t.Fatal("want ok=true") + } + if !resolved.Protected { + t.Fatal("want Protected=true") + } +} + func TestResolveForEnv_OverridesMerge(t *testing.T) { m := &ModuleConfig{ Name: "db",