Skip to content

fix: preserve secrets.generate vars outside env_vars to prevent DesiredHash mismatch#559

Merged
intel352 merged 6 commits into
mainfrom
copilot/fix-desiredhash-mismatch
May 6, 2026
Merged

fix: preserve secrets.generate vars outside env_vars to prevent DesiredHash mismatch#559
intel352 merged 6 commits into
mainfrom
copilot/fix-desiredhash-mismatch

Conversation

Copilot AI commented May 6, 2026

Copy link
Copy Markdown
Contributor

infraPreserveKeys only prevented ${VAR} expansion inside env_vars/env_vars_secret/secret_env_vars submaps. Any other field referencing a secrets.generate key (e.g. Droplet user_data cloud-init) was expanded by os.ExpandEnv — to "" at plan time (var absent) and to the actual value at apply time — producing divergent desiredStateHash values and a spurious plan stale: config hash mismatch.

Changes

  • config/env_expand.goExpandEnvInMapPreservingVars(m, preserveKeys, preserveVarNames): extends the existing key-submap preservation with a second axis. For string values outside preserved submaps, uses os.Expand with a custom mapping that returns the literal ${NAME} for any var in preserveVarNames, leaving all other vars to resolve normally.

  • cmd/wfctl/infra.gosecretGenKeys(cfg) extracts Key fields from cfg.Secrets.Generate. Both parseInfraResourceSpecs and planResourcesForEnv now call ExpandEnvInMapPreservingVars with these keys so the hash is identical whether the generated secret is in the environment or not.

  • config/env_expand_test.go — unit tests covering nil input, hash consistency (set vs unset), non-secret vars still expand, preserveKeys takes priority, nested slices.

  • cmd/wfctl/infra_plan_env_vars_preserve_test.go + testdata/infra-with-env-var-refs.yaml — regression test: POSTGRES_PASSWORD (in secrets.generate) referenced in a Droplet user_data block produces the same desiredStateHash whether the var is set or not.

secrets:
  generate:
    - key: POSTGRES_PASSWORD
      type: random_hex
      length: 32

modules:
  - name: coredump-staging-pg
    type: infra.droplet
    config:
      user_data: |
        #cloud-config
        write_files:
          - content: |
              POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}'
        # ↑ was expanded to '' at plan time, actual value at apply time → hash mismatch
        # now preserved as literal ${POSTGRES_PASSWORD} in both passes

Copilot AI requested review from Copilot and removed request for Copilot May 6, 2026 04:42
@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:260: parsing iteration count: invalid syntax
baseline-bench.txt:322893: parsing iteration count: invalid syntax
baseline-bench.txt:588733: parsing iteration count: invalid syntax
baseline-bench.txt:1048508: parsing iteration count: invalid syntax
baseline-bench.txt:1321998: parsing iteration count: invalid syntax
baseline-bench.txt:1565755: parsing iteration count: invalid syntax
benchmark-results.txt:260: parsing iteration count: invalid syntax
benchmark-results.txt:324486: parsing iteration count: invalid syntax
benchmark-results.txt:662044: parsing iteration count: invalid syntax
benchmark-results.txt:936890: parsing iteration count: invalid syntax
benchmark-results.txt:1236408: parsing iteration count: invalid syntax
benchmark-results.txt:1523023: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/GoCodeAlone/workflow/dynamic
cpu: AMD EPYC 7763 64-Core Processor                
                            │ baseline-bench.txt │
                            │       sec/op       │
InterpreterCreation-4               6.372m ± 65%
ComponentLoad-4                     3.666m ±  2%
ComponentExecute-4                  1.963µ ±  1%
PoolContention/workers-1-4          1.095µ ±  3%
PoolContention/workers-2-4          1.089µ ±  2%
PoolContention/workers-4-4          1.100µ ±  2%
PoolContention/workers-8-4          1.092µ ±  2%
PoolContention/workers-16-4         1.100µ ±  5%
ComponentLifecycle-4                3.662m ±  1%
SourceValidation-4                  2.293µ ±  1%
RegistryConcurrent-4                811.5n ±  4%
LoaderLoadFromString-4              3.793m ±  2%
geomean                             18.73µ

                            │ baseline-bench.txt │
                            │        B/op        │
InterpreterCreation-4               2.027Mi ± 0%
ComponentLoad-4                     2.180Mi ± 0%
ComponentExecute-4                  1.203Ki ± 0%
PoolContention/workers-1-4          1.203Ki ± 0%
PoolContention/workers-2-4          1.203Ki ± 0%
PoolContention/workers-4-4          1.203Ki ± 0%
PoolContention/workers-8-4          1.203Ki ± 0%
PoolContention/workers-16-4         1.203Ki ± 0%
ComponentLifecycle-4                2.183Mi ± 0%
SourceValidation-4                  1.984Ki ± 0%
RegistryConcurrent-4                1.133Ki ± 0%
LoaderLoadFromString-4              2.182Mi ± 0%
geomean                             15.25Ki

                            │ baseline-bench.txt │
                            │     allocs/op      │
InterpreterCreation-4                15.68k ± 0%
ComponentLoad-4                      18.02k ± 0%
ComponentExecute-4                    25.00 ± 0%
PoolContention/workers-1-4            25.00 ± 0%
PoolContention/workers-2-4            25.00 ± 0%
PoolContention/workers-4-4            25.00 ± 0%
PoolContention/workers-8-4            25.00 ± 0%
PoolContention/workers-16-4           25.00 ± 0%
ComponentLifecycle-4                 18.07k ± 0%
SourceValidation-4                    32.00 ± 0%
RegistryConcurrent-4                  2.000 ± 0%
LoaderLoadFromString-4               18.06k ± 0%
geomean                               183.3

cpu: AMD EPYC 9V74 80-Core Processor                
                            │ benchmark-results.txt │
                            │        sec/op         │
InterpreterCreation-4                 3.184m ± 209%
ComponentLoad-4                       3.505m ±   2%
ComponentExecute-4                    1.827µ ±   3%
PoolContention/workers-1-4            1.022µ ±   1%
PoolContention/workers-2-4            1.026µ ±   3%
PoolContention/workers-4-4            1.015µ ±   1%
PoolContention/workers-8-4            1.024µ ±   2%
PoolContention/workers-16-4           1.027µ ±   1%
ComponentLifecycle-4                  3.540m ±   5%
SourceValidation-4                    2.088µ ±   1%
RegistryConcurrent-4                  757.9n ±   4%
LoaderLoadFromString-4                3.598m ±   0%
geomean                               16.67µ

                            │ benchmark-results.txt │
                            │         B/op          │
InterpreterCreation-4                  2.027Mi ± 0%
ComponentLoad-4                        2.180Mi ± 0%
ComponentExecute-4                     1.203Ki ± 0%
PoolContention/workers-1-4             1.203Ki ± 0%
PoolContention/workers-2-4             1.203Ki ± 0%
PoolContention/workers-4-4             1.203Ki ± 0%
PoolContention/workers-8-4             1.203Ki ± 0%
PoolContention/workers-16-4            1.203Ki ± 0%
ComponentLifecycle-4                   2.183Mi ± 0%
SourceValidation-4                     1.984Ki ± 0%
RegistryConcurrent-4                   1.133Ki ± 0%
LoaderLoadFromString-4                 2.182Mi ± 0%
geomean                                15.25Ki

                            │ benchmark-results.txt │
                            │       allocs/op       │
InterpreterCreation-4                   15.68k ± 0%
ComponentLoad-4                         18.02k ± 0%
ComponentExecute-4                       25.00 ± 0%
PoolContention/workers-1-4               25.00 ± 0%
PoolContention/workers-2-4               25.00 ± 0%
PoolContention/workers-4-4               25.00 ± 0%
PoolContention/workers-8-4               25.00 ± 0%
PoolContention/workers-16-4              25.00 ± 0%
ComponentLifecycle-4                    18.07k ± 0%
SourceValidation-4                       32.00 ± 0%
RegistryConcurrent-4                     2.000 ± 0%
LoaderLoadFromString-4                  18.06k ± 0%
geomean                                  183.3

pkg: github.com/GoCodeAlone/workflow/middleware
cpu: AMD EPYC 7763 64-Core Processor                
                                  │ baseline-bench.txt │
                                  │       sec/op       │
CircuitBreakerDetection-4                  287.2n ± 1%
CircuitBreakerExecution_Success-4          21.54n ± 0%
CircuitBreakerExecution_Failure-4          65.82n ± 0%
geomean                                    74.12n

                                  │ baseline-bench.txt │
                                  │        B/op        │
CircuitBreakerDetection-4                 144.0 ± 0%
CircuitBreakerExecution_Success-4         0.000 ± 0%
CircuitBreakerExecution_Failure-4         0.000 ± 0%
geomean                                              ¹
¹ summaries must be >0 to compute geomean

                                  │ baseline-bench.txt │
                                  │     allocs/op      │
CircuitBreakerDetection-4                 1.000 ± 0%
CircuitBreakerExecution_Success-4         0.000 ± 0%
CircuitBreakerExecution_Failure-4         0.000 ± 0%
geomean                                              ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                                  │ benchmark-results.txt │
                                  │        sec/op         │
CircuitBreakerDetection-4                     297.9n ± 8%
CircuitBreakerExecution_Success-4             22.71n ± 2%
CircuitBreakerExecution_Failure-4             70.94n ± 0%
geomean                                       78.29n

                                  │ benchmark-results.txt │
                                  │         B/op          │
CircuitBreakerDetection-4                    144.0 ± 0%
CircuitBreakerExecution_Success-4            0.000 ± 0%
CircuitBreakerExecution_Failure-4            0.000 ± 0%
geomean                                                 ¹
¹ summaries must be >0 to compute geomean

                                  │ benchmark-results.txt │
                                  │       allocs/op       │
CircuitBreakerDetection-4                    1.000 ± 0%
CircuitBreakerExecution_Success-4            0.000 ± 0%
CircuitBreakerExecution_Failure-4            0.000 ± 0%
geomean                                                 ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/module
cpu: AMD EPYC 7763 64-Core Processor                
                                 │ baseline-bench.txt │
                                 │       sec/op       │
JQTransform_Simple-4                     909.2n ± 27%
JQTransform_ObjectConstruction-4         1.512µ ±  1%
JQTransform_ArraySelect-4                3.434µ ±  1%
JQTransform_Complex-4                    40.00µ ±  1%
JQTransform_Throughput-4                 1.867µ ±  1%
SSEPublishDelivery-4                     72.96n ±  1%
geomean                                  1.718µ

                                 │ baseline-bench.txt │
                                 │        B/op        │
JQTransform_Simple-4                   1.273Ki ± 0%
JQTransform_ObjectConstruction-4       1.773Ki ± 0%
JQTransform_ArraySelect-4              2.625Ki ± 0%
JQTransform_Complex-4                  16.22Ki ± 0%
JQTransform_Throughput-4               1.984Ki ± 0%
SSEPublishDelivery-4                     0.000 ± 0%
geomean                                             ¹
¹ summaries must be >0 to compute geomean

                                 │ baseline-bench.txt │
                                 │     allocs/op      │
JQTransform_Simple-4                     10.00 ± 0%
JQTransform_ObjectConstruction-4         15.00 ± 0%
JQTransform_ArraySelect-4                30.00 ± 0%
JQTransform_Complex-4                    324.0 ± 0%
JQTransform_Throughput-4                 17.00 ± 0%
SSEPublishDelivery-4                     0.000 ± 0%
geomean                                             ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                                 │ benchmark-results.txt │
                                 │        sec/op         │
JQTransform_Simple-4                        847.3n ± 28%
JQTransform_ObjectConstruction-4            1.425µ ±  1%
JQTransform_ArraySelect-4                   3.391µ ±  0%
JQTransform_Complex-4                       41.45µ ±  0%
JQTransform_Throughput-4                    1.731µ ±  1%
SSEPublishDelivery-4                        65.78n ±  3%
geomean                                     1.638µ

                                 │ benchmark-results.txt │
                                 │         B/op          │
JQTransform_Simple-4                      1.273Ki ± 0%
JQTransform_ObjectConstruction-4          1.773Ki ± 0%
JQTransform_ArraySelect-4                 2.625Ki ± 0%
JQTransform_Complex-4                     16.22Ki ± 0%
JQTransform_Throughput-4                  1.984Ki ± 0%
SSEPublishDelivery-4                        0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

                                 │ benchmark-results.txt │
                                 │       allocs/op       │
JQTransform_Simple-4                        10.00 ± 0%
JQTransform_ObjectConstruction-4            15.00 ± 0%
JQTransform_ArraySelect-4                   30.00 ± 0%
JQTransform_Complex-4                       324.0 ± 0%
JQTransform_Throughput-4                    17.00 ± 0%
SSEPublishDelivery-4                        0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/schema
cpu: AMD EPYC 7763 64-Core Processor                
                                    │ baseline-bench.txt │
                                    │       sec/op       │
SchemaValidation_Simple-4                    1.103µ ± 2%
SchemaValidation_AllFields-4                 1.672µ ± 3%
SchemaValidation_FormatValidation-4          1.586µ ± 1%
SchemaValidation_ManySchemas-4               1.810µ ± 3%
geomean                                      1.517µ

                                    │ baseline-bench.txt │
                                    │        B/op        │
SchemaValidation_Simple-4                   0.000 ± 0%
SchemaValidation_AllFields-4                0.000 ± 0%
SchemaValidation_FormatValidation-4         0.000 ± 0%
SchemaValidation_ManySchemas-4              0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

                                    │ baseline-bench.txt │
                                    │     allocs/op      │
SchemaValidation_Simple-4                   0.000 ± 0%
SchemaValidation_AllFields-4                0.000 ± 0%
SchemaValidation_FormatValidation-4         0.000 ± 0%
SchemaValidation_ManySchemas-4              0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                                    │ benchmark-results.txt │
                                    │        sec/op         │
SchemaValidation_Simple-4                       1.112µ ± 1%
SchemaValidation_AllFields-4                    1.654µ ± 5%
SchemaValidation_FormatValidation-4             1.612µ ± 2%
SchemaValidation_ManySchemas-4                  1.641µ ± 2%
geomean                                         1.485µ

                                    │ benchmark-results.txt │
                                    │         B/op          │
SchemaValidation_Simple-4                      0.000 ± 0%
SchemaValidation_AllFields-4                   0.000 ± 0%
SchemaValidation_FormatValidation-4            0.000 ± 0%
SchemaValidation_ManySchemas-4                 0.000 ± 0%
geomean                                                   ¹
¹ summaries must be >0 to compute geomean

                                    │ benchmark-results.txt │
                                    │       allocs/op       │
SchemaValidation_Simple-4                      0.000 ± 0%
SchemaValidation_AllFields-4                   0.000 ± 0%
SchemaValidation_FormatValidation-4            0.000 ± 0%
SchemaValidation_ManySchemas-4                 0.000 ± 0%
geomean                                                   ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/store
cpu: AMD EPYC 7763 64-Core Processor                
                                   │ baseline-bench.txt │
                                   │       sec/op       │
EventStoreAppend_InMemory-4                1.188µ ±  9%
EventStoreAppend_SQLite-4                  1.356m ±  4%
GetTimeline_InMemory/events-10-4           13.74µ ±  6%
GetTimeline_InMemory/events-50-4           77.54µ ± 20%
GetTimeline_InMemory/events-100-4          123.7µ ±  1%
GetTimeline_InMemory/events-500-4          633.9µ ±  1%
GetTimeline_InMemory/events-1000-4         1.299m ±  1%
GetTimeline_SQLite/events-10-4             109.0µ ±  1%
GetTimeline_SQLite/events-50-4             251.4µ ±  1%
GetTimeline_SQLite/events-100-4            423.8µ ±  1%
GetTimeline_SQLite/events-500-4            1.798m ±  1%
GetTimeline_SQLite/events-1000-4           3.531m ±  1%
geomean                                    220.0µ

                                   │ baseline-bench.txt │
                                   │        B/op        │
EventStoreAppend_InMemory-4                  786.5 ± 9%
EventStoreAppend_SQLite-4                  1.988Ki ± 2%
GetTimeline_InMemory/events-10-4           7.953Ki ± 0%
GetTimeline_InMemory/events-50-4           46.62Ki ± 0%
GetTimeline_InMemory/events-100-4          94.48Ki ± 0%
GetTimeline_InMemory/events-500-4          472.8Ki ± 0%
GetTimeline_InMemory/events-1000-4         944.3Ki ± 0%
GetTimeline_SQLite/events-10-4             16.74Ki ± 0%
GetTimeline_SQLite/events-50-4             87.14Ki ± 0%
GetTimeline_SQLite/events-100-4            175.4Ki ± 0%
GetTimeline_SQLite/events-500-4            846.1Ki ± 0%
GetTimeline_SQLite/events-1000-4           1.639Mi ± 0%
geomean                                    67.33Ki

                                   │ baseline-bench.txt │
                                   │     allocs/op      │
EventStoreAppend_InMemory-4                  7.000 ± 0%
EventStoreAppend_SQLite-4                    53.00 ± 0%
GetTimeline_InMemory/events-10-4             125.0 ± 0%
GetTimeline_InMemory/events-50-4             653.0 ± 0%
GetTimeline_InMemory/events-100-4           1.306k ± 0%
GetTimeline_InMemory/events-500-4           6.514k ± 0%
GetTimeline_InMemory/events-1000-4          13.02k ± 0%
GetTimeline_SQLite/events-10-4               382.0 ± 0%
GetTimeline_SQLite/events-50-4              1.852k ± 0%
GetTimeline_SQLite/events-100-4             3.681k ± 0%
GetTimeline_SQLite/events-500-4             18.54k ± 0%
GetTimeline_SQLite/events-1000-4            37.29k ± 0%
geomean                                     1.162k

cpu: AMD EPYC 9V74 80-Core Processor                
                                   │ benchmark-results.txt │
                                   │        sec/op         │
EventStoreAppend_InMemory-4                   1.094µ ± 17%
EventStoreAppend_SQLite-4                     1.048m ±  6%
GetTimeline_InMemory/events-10-4              12.48µ ±  2%
GetTimeline_InMemory/events-50-4              60.91µ ± 14%
GetTimeline_InMemory/events-100-4             105.8µ ±  1%
GetTimeline_InMemory/events-500-4             542.5µ ±  0%
GetTimeline_InMemory/events-1000-4            1.106m ±  1%
GetTimeline_SQLite/events-10-4                85.03µ ±  3%
GetTimeline_SQLite/events-50-4                220.4µ ±  1%
GetTimeline_SQLite/events-100-4               385.7µ ±  1%
GetTimeline_SQLite/events-500-4               1.677m ±  1%
GetTimeline_SQLite/events-1000-4              3.275m ±  1%
geomean                                       189.9µ

                                   │ benchmark-results.txt │
                                   │         B/op          │
EventStoreAppend_InMemory-4                    761.0 ± 13%
EventStoreAppend_SQLite-4                    1.984Ki ±  1%
GetTimeline_InMemory/events-10-4             7.953Ki ±  0%
GetTimeline_InMemory/events-50-4             46.62Ki ±  0%
GetTimeline_InMemory/events-100-4            94.48Ki ±  0%
GetTimeline_InMemory/events-500-4            472.8Ki ±  0%
GetTimeline_InMemory/events-1000-4           944.3Ki ±  0%
GetTimeline_SQLite/events-10-4               16.74Ki ±  0%
GetTimeline_SQLite/events-50-4               87.14Ki ±  0%
GetTimeline_SQLite/events-100-4              175.4Ki ±  0%
GetTimeline_SQLite/events-500-4              846.1Ki ±  0%
GetTimeline_SQLite/events-1000-4             1.639Mi ±  0%
geomean                                      67.13Ki

                                   │ benchmark-results.txt │
                                   │       allocs/op       │
EventStoreAppend_InMemory-4                     7.000 ± 0%
EventStoreAppend_SQLite-4                       53.00 ± 0%
GetTimeline_InMemory/events-10-4                125.0 ± 0%
GetTimeline_InMemory/events-50-4                653.0 ± 0%
GetTimeline_InMemory/events-100-4              1.306k ± 0%
GetTimeline_InMemory/events-500-4              6.514k ± 0%
GetTimeline_InMemory/events-1000-4             13.02k ± 0%
GetTimeline_SQLite/events-10-4                  382.0 ± 0%
GetTimeline_SQLite/events-50-4                 1.852k ± 0%
GetTimeline_SQLite/events-100-4                3.681k ± 0%
GetTimeline_SQLite/events-500-4                18.54k ± 0%
GetTimeline_SQLite/events-1000-4               37.29k ± 0%
geomean                                        1.162k

Benchmarks run with go test -bench=. -benchmem -count=6.
Regressions ≥ 20% are flagged. Results compared via benchstat.

…esiredHash mismatch

Add ExpandEnvInMapPreservingVars to config/env_expand.go that, in addition to
preserving entire key submaps (env_vars, env_vars_secret, secret_env_vars), also
preserves individual ${VAR} references whose variable name appears in the
cfg.Secrets.Generate list. This ensures desiredStateHash produces identical output
regardless of whether generated secret variables are present in the environment.

Fixes the "plan stale: config hash mismatch" error that occurs when a
secrets.generate key (e.g. STAGING_PG_PASSWORD) is referenced in a non-env_vars
config field (e.g. Droplet user_data cloud-init script): at plan time the var is
absent so os.ExpandEnv returns "", at apply time it holds the generated value,
causing the hash to diverge.

- config/env_expand.go: add ExpandEnvInMapPreservingVars + helpers
- config/env_expand_test.go: tests for new function including hash-consistency check
- cmd/wfctl/infra.go: add secretGenKeys helper; use ExpandEnvInMapPreservingVars in
  parseInfraResourceSpecs and planResourcesForEnv
- cmd/wfctl/infra_plan_env_vars_preserve_test.go: regression test for user_data hash mismatch
- cmd/wfctl/testdata/infra-with-env-var-refs.yaml: add secrets.generate + droplet with user_data

Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/643f6bb1-a20d-4468-94a6-ccebf7c70d3c

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 6, 2026 05:04
Copilot AI changed the title [WIP] Fix DesiredHash mismatch when secret refs appear outside env_vars fix: preserve secrets.generate vars outside env_vars to prevent DesiredHash mismatch May 6, 2026
Copilot AI requested a review from intel352 May 6, 2026 05:05
@intel352 intel352 marked this pull request as ready for review May 6, 2026 06:01
Copilot AI review requested due to automatic review settings May 6, 2026 06:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes infra plan/apply desiredStateHash mismatches caused by eager env expansion of secrets.generate variables outside the existing preserved submaps (e.g., user_data cloud-init blocks), which could otherwise lead to spurious “plan stale: config hash mismatch” errors.

Changes:

  • Added ExpandEnvInMapPreservingVars to preserve specific variable names as literal ${VAR} while still expanding all other env vars outside preserved submaps.
  • Updated wfctl infra planning code to preserve cfg.Secrets.Generate[].Key variables during config expansion.
  • Added unit + regression tests and updated infra test fixture to cover the hash-consistency scenario.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
config/env_expand.go Adds env-expansion helper that preserves specific variable names as literals while keeping preserved submap behavior.
config/env_expand_test.go Adds unit tests for var-name preservation behavior (nil input, precedence, nested slices, hash consistency).
cmd/wfctl/infra.go Extracts secrets.generate keys and uses them during env expansion for infra planning/spec parsing.
cmd/wfctl/infra_plan_env_vars_preserve_test.go Adds regression test asserting desiredStateHash stability when generated secret env var is set vs unset.
cmd/wfctl/testdata/infra-with-env-var-refs.yaml Adds secrets.generate and a droplet user_data reference to a generated secret for regression coverage.
cmd/wfctl/dryrun_test.go Removes trailing whitespace at EOF.
cmd/wfctl/ci_run_dryrun.go Formatting/alignment-only changes to struct field indentation.

Comment thread cmd/wfctl/infra_plan_env_vars_preserve_test.go Outdated
Copilot AI review requested due to automatic review settings May 6, 2026 13:44
@intel352

intel352 commented May 6, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.

Comment thread config/env_expand.go
Comment on lines +103 to +159
// ExpandEnvInMapPreservingVars is like ExpandEnvInMapPreservingKeys but adds
// a second dimension of preservation: individual ${VAR} / $VAR references
// whose variable name appears in preserveVarNames are emitted as the literal
// "${name}" instead of being substituted from the process environment.
//
// Use case: plan-time serialisation of resource specs where a known set of
// secret variable names (e.g. cfg.Secrets.Generate keys) must produce
// hash-identical output regardless of whether the variable is present in the
// current environment. Without this, fields such as user_data that contain
// ${SECRET_VAR} produce different hashes at plan time (var unset → empty
// substitution) and apply time (var set → actual value), causing a spurious
// "plan stale: config hash mismatch".
//
// Precedence: preserveKeys takes priority — if a map key is in preserveKeys
// the entire subtree is deep-copied as-is (no expansion at all, matching
// ExpandEnvInMapPreservingKeys semantics). preserveVarNames only affects
// string values in portions of the tree that are NOT inside a preserved-key
// subtree.
func ExpandEnvInMapPreservingVars(m map[string]any, preserveKeys []string, preserveVarNames []string) map[string]any {
if m == nil {
return nil
}
preserveK := make(map[string]struct{}, len(preserveKeys))
for _, k := range preserveKeys {
preserveK[k] = struct{}{}
}
preserveV := make(map[string]struct{}, len(preserveVarNames))
for _, v := range preserveVarNames {
preserveV[v] = struct{}{}
}
return expandEnvInMapWithPreserveVars(m, preserveK, preserveV)
}

func expandEnvInMapWithPreserveVars(m map[string]any, preserveK, preserveV map[string]struct{}) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
if _, isPreservedKey := preserveK[k]; isPreservedKey {
out[k] = deepCopyValue(v)
continue
}
out[k] = expandEnvInValueWithPreserveVars(v, preserveK, preserveV)
}
return out
}

func expandEnvInValueWithPreserveVars(v any, preserveK, preserveV map[string]struct{}) any {
switch val := v.(type) {
case string:
if len(preserveV) == 0 {
return os.ExpandEnv(val)
}
return os.Expand(val, func(name string) string {
if _, ok := preserveV[name]; ok {
return "${" + name + "}"
}
return os.Getenv(name)
})
Copilot AI requested review from Copilot and removed request for Copilot May 6, 2026 13:53

Copilot AI commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

@copilot apply changes based on the comments in this thread

Done in f55b82f. Replaced os.Unsetenv("POSTGRES_PASSWORD") with t.Setenv("POSTGRES_PASSWORD", "") so t.Cleanup captures the original value upfront and restores it correctly, regardless of the env state before the test runs.

@intel352

intel352 commented May 6, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Copilot AI requested review from Copilot and removed request for Copilot May 6, 2026 14:00

Copilot AI commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

@copilot apply changes based on the comments in this thread

Added TestExpandEnvInMapPreservingVars_UnbracedSecretVarCanonicalised in 9c3de52. It passes "****** region=${REGION}" and asserts that the unbraced $DB_PASSWORD is canonicalised to ${DB_PASSWORD} (not expanded), while the non-secret ${REGION} is still expanded to its value.

@intel352 intel352 merged commit 30356a1 into main May 6, 2026
20 checks passed
@intel352 intel352 deleted the copilot/fix-desiredhash-mismatch branch May 6, 2026 18:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DesiredHash mismatch when secret refs appear outside env_vars (e.g. Droplet user_data)

3 participants