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
73 changes: 59 additions & 14 deletions cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"flag"
"fmt"
"os"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -444,22 +445,66 @@ func resourceSpecFromResolvedModule(r *config.ResolvedModule) interfaces.Resourc
return spec
}

// secretGenKeys returns the variable names declared in cfg.Secrets.Generate.
// declaredSecretKeys returns the variable names declared as workflow secrets.
// These keys are preserved as literal ${VAR} references during plan-time
// config expansion so that desiredStateHash produces the same result
// regardless of whether the variable is currently set in the process
// environment. This fixes the "plan stale: config hash mismatch" error that
// occurs when a generated secret (e.g. STAGING_PG_PASSWORD) is referenced
// outside env_vars — for example in a Droplet user_data cloud-init script —
// where the variable is absent at plan time but present at apply time.
func secretGenKeys(cfg *config.WorkflowConfig) []string {
if cfg == nil || cfg.Secrets == nil {
// environment. This covers generated secrets and externally supplied required
// secrets declared through either the top-level secrets block or secrets.*
// modules.
func declaredSecretKeys(cfg *config.WorkflowConfig) []string {
if cfg == nil {
return nil
}
keys := make([]string, 0, len(cfg.Secrets.Generate))
for _, g := range cfg.Secrets.Generate {
if g.Key != "" {
keys = append(keys, g.Key)
keys := map[string]struct{}{}
if cfg.Secrets != nil {
for _, g := range cfg.Secrets.Generate {
if g.Key != "" {
keys[g.Key] = struct{}{}
}
}
for _, entry := range cfg.Secrets.Entries {
if entry.Name != "" {
keys[entry.Name] = struct{}{}
}
}
}
for _, m := range cfg.Modules {
if m.Type != "secrets.generate" && m.Type != "secrets.requires" {
continue
}
for _, field := range []string{"generate", "requires"} {
for _, key := range secretModuleKeys(m.Config, field) {
keys[key] = struct{}{}
}
}
}
out := make([]string, 0, len(keys))
for key := range keys {
out = append(out, key)
}
sort.Strings(out)
return out
}

func secretModuleKeys(moduleCfg map[string]any, field string) []string {
raw, ok := moduleCfg[field]
if !ok {
return nil
}
items, ok := raw.([]any)
if !ok {
return nil
}
keys := make([]string, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
continue
}
key, ok := m["key"].(string)
if ok && key != "" {
keys = append(keys, key)
}
}
return keys
Comment thread
intel352 marked this conversation as resolved.
Expand All @@ -472,7 +517,7 @@ func parseInfraResourceSpecs(cfgFile string) ([]interfaces.ResourceSpec, error)
if err != nil {
return nil, fmt.Errorf("load %s: %w", cfgFile, err)
}
secretVars := secretGenKeys(cfg)
secretVars := declaredSecretKeys(cfg)
var specs []interfaces.ResourceSpec
for _, m := range cfg.Modules {
if !isInfraType(m.Type) {
Expand Down Expand Up @@ -517,7 +562,7 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error)
if envName != "" && cfg.Environments != nil {
topEnv = cfg.Environments[envName]
}
secretVars := secretGenKeys(cfg)
secretVars := declaredSecretKeys(cfg)
var out []*config.ResolvedModule
for i := range cfg.Modules {
m := &cfg.Modules[i]
Expand Down Expand Up @@ -574,7 +619,7 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error)
// ${VAR} literals through plan serialization (apply-time injection
// resolves them when the plugin creates/updates the resource).
// secretVars are also preserved so that fields like user_data that
// reference generated secrets produce the same hash at plan time
// reference declared secrets produce the same hash at plan time
// (variable unset) and apply time (variable set).
resolved.Config = config.ExpandEnvInMapPreservingVars(resolved.Config, infraPreserveKeys, secretVars)
out = append(out, resolved)
Expand Down
34 changes: 4 additions & 30 deletions cmd/wfctl/infra_align_rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,43 +68,17 @@ func buildAlignContext(cfgFile string) (*alignContext, error) {
case m.Type == "infra.database":
ctx.databases = append(ctx.databases, m)
case m.Type == "secrets.generate" || m.Type == "secrets.requires":
if gen, ok := extractSecretKeys(m.Config, "generate"); ok {
for _, k := range gen {
ctx.secretKeys[k] = struct{}{}
}
for _, k := range secretModuleKeys(m.Config, "generate") {
ctx.secretKeys[k] = struct{}{}
}
if req, ok := extractSecretKeys(m.Config, "requires"); ok {
for _, k := range req {
ctx.secretKeys[k] = struct{}{}
}
for _, k := range secretModuleKeys(m.Config, "requires") {
ctx.secretKeys[k] = struct{}{}
}
}
}
return ctx, nil
}

// extractSecretKeys extracts key names from config[field] which is expected
// to be []any where each element is map[string]any with a "key" field.
func extractSecretKeys(cfg map[string]any, field string) ([]string, bool) {
raw, ok := cfg[field]
if !ok {
return nil, false
}
items, ok := raw.([]any)
if !ok {
return nil, false
}
var keys []string
for _, item := range items {
if m, ok := item.(map[string]any); ok {
if k, ok := m["key"].(string); ok && k != "" {
keys = append(keys, k)
}
}
}
return keys, true
}

// ── R-A1: container/runtime alignment ──────────────────────────────────────

func checkRA1(ctx *alignContext) []AlignFinding {
Expand Down
104 changes: 104 additions & 0 deletions cmd/wfctl/infra_plan_env_vars_preserve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestParseInfraResourceSpecs_PreservesEnvVarRefs(t *testing.T) {
t.Setenv("IMAGE_REF", "registry.example.com/api:abc123")
t.Setenv("AUTH_TOKEN", "would-be-resolved-secret")
t.Setenv("DATABASE_URL", "postgres://would-be-resolved")
t.Setenv("EXTERNAL_API_TOKEN", "would-be-resolved-required-secret")
// POSTGRES_PASSWORD is in secrets.generate — leave it unset to simulate plan time.

specs, err := parseInfraResourceSpecs("testdata/infra-with-env-var-refs.yaml")
Expand Down Expand Up @@ -142,6 +143,103 @@ func TestParseInfraResourceSpecs_PreservesSecretGenVarsInUserData(t *testing.T)
}
}

func TestParseInfraResourceSpecs_PreservesRequiredSecretVarsInUserData(t *testing.T) {
t.Setenv("DIGITALOCEAN_TOKEN", "actual-do-token")
t.Setenv("IMAGE_REF", "registry.example.com/api:abc123")
t.Setenv("AUTH_TOKEN", "would-be-resolved-secret")
t.Setenv("DATABASE_URL", "postgres://would-be-resolved")
t.Setenv("POSTGRES_PASSWORD", "deadbeef1234567890abcdef12345678")

t.Setenv("EXTERNAL_API_TOKEN", "")
specsAtPlan, err := parseInfraResourceSpecs("testdata/infra-with-env-var-refs.yaml")
if err != nil {
t.Fatalf("parseInfraResourceSpecs (plan): %v", err)
}
hashAtPlan := desiredStateHash(specsAtPlan)

t.Setenv("EXTERNAL_API_TOKEN", "required-secret-value")
specsAtApply, err := parseInfraResourceSpecs("testdata/infra-with-env-var-refs.yaml")
if err != nil {
t.Fatalf("parseInfraResourceSpecs (apply): %v", err)
}
hashAtApply := desiredStateHash(specsAtApply)

if hashAtPlan != hashAtApply {
planJSON, _ := json.MarshalIndent(specsAtPlan, "", " ")
applyJSON, _ := json.MarshalIndent(specsAtApply, "", " ")
t.Errorf("desiredStateHash mismatch between plan and apply:\n"+
" plan hash: %s\n apply hash: %s\n\nplan specs:\n%s\n\napply specs:\n%s",
hashAtPlan, hashAtApply, planJSON, applyJSON)
}
Comment thread
intel352 marked this conversation as resolved.

var dropletCfg map[string]any
for _, s := range specsAtApply {
if s.Name == "example-droplet" {
dropletCfg = s.Config
break
}
}
if dropletCfg == nil {
t.Fatal("example-droplet spec not found in parsed specs")
}
ud, _ := dropletCfg["user_data"].(string)
if !strings.Contains(ud, "${EXTERNAL_API_TOKEN}") {
t.Errorf("user_data should contain literal ${EXTERNAL_API_TOKEN}, got:\n%s", ud)
}
if strings.Contains(ud, "required-secret-value") {
t.Errorf("user_data should NOT contain the resolved required secret value, got:\n%s", ud)
}
}

func TestParseInfraResourceSpecs_PreservesSecretEntriesInUserData(t *testing.T) {
t.Setenv("DIGITALOCEAN_TOKEN", "actual-do-token")
t.Setenv("IMAGE_REF", "registry.example.com/api:abc123")
t.Setenv("AUTH_TOKEN", "would-be-resolved-secret")
t.Setenv("DATABASE_URL", "postgres://would-be-resolved")
t.Setenv("POSTGRES_PASSWORD", "deadbeef1234567890abcdef12345678")
t.Setenv("EXTERNAL_API_TOKEN", "required-secret-value")

t.Setenv("MANUAL_API_TOKEN", "")
specsAtPlan, err := parseInfraResourceSpecs("testdata/infra-with-env-var-refs.yaml")
if err != nil {
t.Fatalf("parseInfraResourceSpecs (plan): %v", err)
}
hashAtPlan := desiredStateHash(specsAtPlan)

t.Setenv("MANUAL_API_TOKEN", "manual-secret-value")
specsAtApply, err := parseInfraResourceSpecs("testdata/infra-with-env-var-refs.yaml")
if err != nil {
t.Fatalf("parseInfraResourceSpecs (apply): %v", err)
}
hashAtApply := desiredStateHash(specsAtApply)

if hashAtPlan != hashAtApply {
planJSON, _ := json.MarshalIndent(specsAtPlan, "", " ")
applyJSON, _ := json.MarshalIndent(specsAtApply, "", " ")
t.Errorf("desiredStateHash mismatch between plan and apply:\n"+
" plan hash: %s\n apply hash: %s\n\nplan specs:\n%s\n\napply specs:\n%s",
hashAtPlan, hashAtApply, planJSON, applyJSON)
}

var dropletCfg map[string]any
for _, s := range specsAtApply {
if s.Name == "example-droplet" {
dropletCfg = s.Config
break
}
}
if dropletCfg == nil {
t.Fatal("example-droplet spec not found in parsed specs")
}
ud, _ := dropletCfg["user_data"].(string)
if !strings.Contains(ud, "${MANUAL_API_TOKEN}") {
t.Errorf("user_data should contain literal ${MANUAL_API_TOKEN}, got:\n%s", ud)
}
if strings.Contains(ud, "manual-secret-value") {
t.Errorf("user_data should NOT contain the resolved secret entry value, got:\n%s", ud)
}
}

// TestPlanEnvVarPreserveTestdataExists ensures the fixture file exists and
// has the env_vars_secret block required for the preservation test.
func TestPlanEnvVarPreserveTestdataExists(t *testing.T) {
Expand All @@ -162,4 +260,10 @@ func TestPlanEnvVarPreserveTestdataExists(t *testing.T) {
if !strings.Contains(string(b), "secrets:") {
t.Errorf("fixture missing secrets: section — needed for secret-gen preservation test")
}
if !strings.Contains(string(b), "entries:") {
t.Errorf("fixture missing secrets.entries section — needed for secret-entry preservation test")
}
if !strings.Contains(string(b), "secrets.requires") {
t.Errorf("fixture missing secrets.requires module — needed for required-secret preservation test")
}
}
58 changes: 45 additions & 13 deletions cmd/wfctl/infra_resolve_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,23 +132,55 @@ func buildResolvedSecretsFromState(
return out
}

// buildRuntimeOnlySecretKeys returns the set of SecretGen keys whose Type
// is NOT "infra_output". These secrets (random_hex, random_base64,
// random_alphanumeric, provider_credential, etc.) are runtime-resolved: the
// runtime JIT resolver substitutes them from env at apply time. They MUST
// NOT be substituted at plan time — even if the value is present in the
// process environment — because doing so would embed a literal secret value
// in the plan, which security-check R4 flags as a potential secret literal
// in env_vars. See ADR 0014.
// buildRuntimeOnlySecretKeys returns the set of declared secret keys that must
// resolve only at apply/runtime. Generated non-infra_output secrets and
// externally supplied required secrets MUST NOT be substituted at plan time —
// even if present in the process environment — because doing so would embed a
// literal secret value in the plan, which security-check R4 flags as a
// potential secret literal in env_vars. See ADR 0014.
func buildRuntimeOnlySecretKeys(cfg *config.WorkflowConfig) map[string]struct{} {
if cfg == nil || cfg.Secrets == nil || len(cfg.Secrets.Generate) == 0 {
if cfg == nil {
return nil
}
out := make(map[string]struct{}, len(cfg.Secrets.Generate))
for _, gen := range cfg.Secrets.Generate {
if gen.Type != "infra_output" {
out[gen.Key] = struct{}{}
out := make(map[string]struct{})
if cfg.Secrets != nil {
for _, gen := range cfg.Secrets.Generate {
if gen.Type != "infra_output" && gen.Key != "" {
out[gen.Key] = struct{}{}
}
}
for _, entry := range cfg.Secrets.Entries {
if entry.Name != "" {
out[entry.Name] = struct{}{}
}
}
}
for _, m := range cfg.Modules {
switch m.Type {
case "secrets.requires":
for _, key := range secretModuleKeys(m.Config, "requires") {
out[key] = struct{}{}
}
case "secrets.generate":
raw, ok := m.Config["generate"].([]any)
if !ok {
continue
}
for _, item := range raw {
gen, ok := item.(map[string]any)
if !ok {
continue
}
key, _ := gen["key"].(string)
typ, _ := gen["type"].(string)
if key != "" && typ != "infra_output" {
out[key] = struct{}{}
}
}
}
}
if len(out) == 0 {
return nil
}
return out
}
Expand Down
Loading
Loading