Skip to content

fix(secrets): prevent duplicate DO Spaces key creation#732

Merged
intel352 merged 5 commits into
mainfrom
fix/do-spaces-key-debug-1779256986
May 20, 2026
Merged

fix(secrets): prevent duplicate DO Spaces key creation#732
intel352 merged 5 commits into
mainfrom
fix/do-spaces-key-debug-1779256986

Conversation

@intel352
Copy link
Copy Markdown
Contributor

DO doesn't enforce name uniqueness on Spaces keys. The provider_credential bootstrap was POSTing blindly each run, accreting orphaned keys whenever the save-to-store path failed (each orphan unrecoverable because DO only returns secret_key at create-time). This adds a list-then-create pre-check that names the conflicting key + suggests delete-via-console OR --force-rotate. 5 new tests; existing tests pass.

DO does not enforce name uniqueness on Spaces keys. The
provider_credential bootstrap path POSTed blindly every run; if the
bootstrap layer failed to persist the access_key+secret_key pair to
the configured store (or skipped existsInProvider for any reason),
the next run created another key with the same name. Repeated runs
accreted orphaned keys until the account hit DO's 200-key quota.

Each orphan is unrecoverable — DO returns secret_key once at
creation time, and List returns access_key only.

Changes:

- generateDOSpacesKey() now does a list-then-create. Pre-create call
  to https://api.digitalocean.com/v2/spaces/keys checks for a
  matching name; if found, returns an error naming the existing
  access_key with explicit recovery guidance (delete via console OR
  --force-rotate). The POST is never attempted in the conflict case.
- New lookupExistingSpacesKey() helper paginates through Spaces keys
  (10 × 100 cap) matching by exact name. Used by the pre-check AND
  by the 403 error branch (account-quota vs name-conflict
  disambiguation hint).
- Improved error wrapping: every "DO spaces key create" error now
  includes name=%q so the operator can correlate logs with DO
  console entries.
- stderr trace line on every create attempt: name + bucket + grant
  permission, so the failure mode is visible without re-running
  with extra flags.

Tests:
- TestGenerateDOSpacesKey_RejectsDuplicateNameBeforeCreate verifies
  the pre-check short-circuits and POST is never attempted when a
  duplicate exists. (postCalls == 0 asserted.)
- TestGenerateDOSpacesKey_403QuotaWithoutNameConflict covers the
  fallback hint when no name match but DO still returns 403.
- TestLookupExistingSpacesKey_PaginatedHit + _Miss cover the pager
  helper.
- Existing TestGenerateSecret_ProviderCredential_DOSpaces and
  _NoBucket / _WithBucket tests pass without changes — their GET
  handlers return 404 (no existing key) so the create path proceeds.
- TestGenerateDOSpacesKey_IncludesCreatedAt updated to stub both
  the new GET and the existing POST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 20, 2026 06:07
…rning

Two follow-ups to the duplicate-creation fix:

1. Refuse the default name fallback. The prior default
   \`workflow-spaces-key\` was project-shared; multiple workflow-
   managed projects in one DO account would all try to create or
   adopt the same key. Force the caller to set a project-unique
   slug (e.g. \`multisite-deploy-key\`, \`wfcompute-deploy-key\`).

2. Emit a stderr WARN when permission=fullaccess is granted
   (i.e. no \`bucket:\` configured). Bootstrap necessarily uses
   fullaccess because the IaC state bucket does not exist yet,
   but once it does the operator should rotate to a bucket-
   scoped key via \`wfctl secrets rotate --target SPACES --bucket
   <state-bucket>\` to limit blast radius. The warning includes
   the exact rotate command.

Test updates:
- WithBucket / IncludesCreatedAt / NoBucket tests now pass an
  explicit name (was relying on the default).
- New TestGenerateDOSpacesKey_RequiresName covers the refuse-
  default-name path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 hardens the DigitalOcean Spaces provider_credential secret generator to avoid creating duplicate-named Spaces keys (and thereby orphaning unrecoverable secret_key values) by adding a list-before-create check and better diagnostics.

Changes:

  • Added lookupExistingSpacesKey (paged list) and a pre-create duplicate-name guard in generateDOSpacesKey.
  • Expanded DO Spaces key creation errors with disambiguation hints for the “key quota exceeded” 403 case.
  • Added/updated tests to cover duplicate-name rejection, pagination, and 403 hint behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
secrets/generators.go Adds a DO Spaces key name existence lookup + pre-check and enhances error messaging/hints.
secrets/generators_test.go Updates existing stubs for list+create flow and adds new unit tests for the new behaviors.

Comment thread secrets/generators.go
Comment on lines 220 to +224
} else {
// fullaccess is required for bootstrap (the IaC state bucket
// does not exist yet, so the key cannot be scoped to it).
// Once the bucket is created, the operator should rotate to a
// bucket-scoped key via the rotate-and-prune flow.
Comment thread secrets/generators.go
Comment on lines +241 to +243
// secret store, but a single save-failure leaves us with an
// orphaned DO key whose secret_key is no longer retrievable.
// Each subsequent run then creates *another* orphan, accreting
Comment thread secrets/generators.go
Comment on lines 274 to 277
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

❌ Patch coverage is 28.65854% with 117 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/wfctl/secrets_orphans.go 0.00% 94 Missing ⚠️
secrets/generators.go 77.08% 6 Missing and 5 partials ⚠️
cmd/wfctl/infra_bootstrap.go 41.17% 7 Missing and 3 partials ⚠️
cmd/wfctl/secrets.go 60.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 20, 2026

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:274: parsing iteration count: invalid syntax
baseline-bench.txt:333041: parsing iteration count: invalid syntax
baseline-bench.txt:627396: parsing iteration count: invalid syntax
baseline-bench.txt:958006: parsing iteration count: invalid syntax
baseline-bench.txt:1255259: parsing iteration count: invalid syntax
baseline-bench.txt:1579101: parsing iteration count: invalid syntax
benchmark-results.txt:274: parsing iteration count: invalid syntax
benchmark-results.txt:326741: parsing iteration count: invalid syntax
benchmark-results.txt:603978: parsing iteration count: invalid syntax
benchmark-results.txt:918736: parsing iteration count: invalid syntax
benchmark-results.txt:1407998: parsing iteration count: invalid syntax
benchmark-results.txt:1712193: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/GoCodeAlone/workflow/dynamic
cpu: AMD EPYC 7763 64-Core Processor                
                            │ benchmark-results.txt │
                            │        sec/op         │
InterpreterCreation-4                  8.961m ± 65%
ComponentLoad-4                        3.611m ±  0%
ComponentExecute-4                     1.920µ ±  1%
PoolContention/workers-1-4             1.075µ ±  2%
PoolContention/workers-2-4             1.074µ ±  2%
PoolContention/workers-4-4             1.075µ ±  1%
PoolContention/workers-8-4             1.076µ ±  3%
PoolContention/workers-16-4            1.082µ ±  1%
ComponentLifecycle-4                   3.635m ±  1%
SourceValidation-4                     2.320µ ±  1%
RegistryConcurrent-4                   787.7n ±  7%
LoaderLoadFromString-4                 3.627m ±  0%
geomean                                18.96µ

                            │ 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

cpu: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                            │ baseline-bench.txt │
                            │       sec/op       │
InterpreterCreation-4               9.065m ± 67%
ComponentLoad-4                     3.462m ±  1%
ComponentExecute-4                  1.847µ ±  1%
PoolContention/workers-1-4          1.185µ ±  1%
PoolContention/workers-2-4          1.188µ ±  2%
PoolContention/workers-4-4          1.183µ ±  2%
PoolContention/workers-8-4          1.200µ ±  1%
PoolContention/workers-16-4         1.237µ ±  4%
ComponentLifecycle-4                3.653m ±  2%
SourceValidation-4                  2.317µ ±  0%
RegistryConcurrent-4                959.2n ±  5%
LoaderLoadFromString-4              3.625m ±  2%
geomean                             20.05µ

                            │ 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

pkg: github.com/GoCodeAlone/workflow/middleware
cpu: AMD EPYC 7763 64-Core Processor                
                                  │ benchmark-results.txt │
                                  │        sec/op         │
CircuitBreakerDetection-4                     294.5n ± 3%
CircuitBreakerExecution_Success-4             21.53n ± 1%
CircuitBreakerExecution_Failure-4             66.41n ± 0%
geomean                                       74.95n

                                  │ 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

cpu: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                  │ baseline-bench.txt │
                                  │       sec/op       │
CircuitBreakerDetection-4                  453.1n ± 0%
CircuitBreakerExecution_Success-4          59.82n ± 1%
CircuitBreakerExecution_Failure-4          65.51n ± 0%
geomean                                    121.1n

                                  │ 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

pkg: github.com/GoCodeAlone/workflow/module
cpu: AMD EPYC 7763 64-Core Processor                
                                 │ benchmark-results.txt │
                                 │        sec/op         │
IaCStateBackend_InProcess-4                 311.6n ± 27%
IaCStateBackend_GRPC-4                      9.716m ±  2%
JQTransform_Simple-4                        681.6n ± 61%
JQTransform_ObjectConstruction-4            1.714µ ±  1%
JQTransform_ArraySelect-4                   3.649µ ±  3%
JQTransform_Complex-4                       40.75µ ±  1%
JQTransform_Throughput-4                    2.037µ ±  0%
SSEPublishDelivery-4                        63.89n ±  1%
geomean                                     4.021µ

                                 │ benchmark-results.txt │
                                 │         B/op          │
IaCStateBackend_InProcess-4                 416.0 ± 0%
IaCStateBackend_GRPC-4                    5.868Mi ± 7%
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       │
IaCStateBackend_InProcess-4                 2.000 ± 0%
IaCStateBackend_GRPC-4                     6.833k ± 1%
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: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                 │ baseline-bench.txt │
                                 │       sec/op       │
IaCStateBackend_InProcess-4              343.8n ± 34%
IaCStateBackend_GRPC-4                   9.904m ±  4%
JQTransform_Simple-4                     719.2n ± 28%
JQTransform_ObjectConstruction-4         1.532µ ±  1%
JQTransform_ArraySelect-4                3.352µ ±  1%
JQTransform_Complex-4                    36.84µ ±  3%
JQTransform_Throughput-4                 1.883µ ±  1%
SSEPublishDelivery-4                     76.30n ±  1%
geomean                                  4.008µ

                                 │ baseline-bench.txt │
                                 │        B/op        │
IaCStateBackend_InProcess-4             416.0 ±  0%
IaCStateBackend_GRPC-4                5.649Mi ± 14%
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      │
IaCStateBackend_InProcess-4              2.000 ± 0%
IaCStateBackend_GRPC-4                  6.873k ± 0%
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                
                                    │ benchmark-results.txt │
                                    │        sec/op         │
SchemaValidation_Simple-4                       1.114µ ± 3%
SchemaValidation_AllFields-4                    1.650µ ± 3%
SchemaValidation_FormatValidation-4             1.588µ ± 2%
SchemaValidation_ManySchemas-4                  1.825µ ± 3%
geomean                                         1.519µ

                                    │ 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

cpu: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                    │ baseline-bench.txt │
                                    │       sec/op       │
SchemaValidation_Simple-4                   1.016µ ± 36%
SchemaValidation_AllFields-4                1.518µ ±  3%
SchemaValidation_FormatValidation-4         1.492µ ±  1%
SchemaValidation_ManySchemas-4              1.528µ ±  3%
geomean                                     1.369µ

                                    │ 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

pkg: github.com/GoCodeAlone/workflow/store
cpu: AMD EPYC 7763 64-Core Processor                
                                   │ benchmark-results.txt │
                                   │        sec/op         │
EventStoreAppend_InMemory-4                   1.262µ ± 30%
EventStoreAppend_SQLite-4                     1.526m ±  7%
GetTimeline_InMemory/events-10-4              13.35µ ±  3%
GetTimeline_InMemory/events-50-4              74.19µ ±  2%
GetTimeline_InMemory/events-100-4             146.9µ ± 16%
GetTimeline_InMemory/events-500-4             635.4µ ±  1%
GetTimeline_InMemory/events-1000-4            1.297m ±  1%
GetTimeline_SQLite/events-10-4                106.6µ ±  1%
GetTimeline_SQLite/events-50-4                250.1µ ±  1%
GetTimeline_SQLite/events-100-4               421.8µ ±  1%
GetTimeline_SQLite/events-500-4               1.790m ±  2%
GetTimeline_SQLite/events-1000-4              3.481m ±  1%
geomean                                       224.3µ

                                   │ benchmark-results.txt │
                                   │         B/op          │
EventStoreAppend_InMemory-4                     778.0 ± 9%
EventStoreAppend_SQLite-4                     1.984Ki ± 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.26Ki

                                   │ 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

cpu: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                   │ baseline-bench.txt │
                                   │       sec/op       │
EventStoreAppend_InMemory-4                1.126µ ± 18%
EventStoreAppend_SQLite-4                  968.9µ ±  6%
GetTimeline_InMemory/events-10-4           13.75µ ±  2%
GetTimeline_InMemory/events-50-4           75.05µ ±  4%
GetTimeline_InMemory/events-100-4          150.4µ ±  4%
GetTimeline_InMemory/events-500-4          625.3µ ± 20%
GetTimeline_InMemory/events-1000-4         1.285m ±  1%
GetTimeline_SQLite/events-10-4             84.59µ ±  1%
GetTimeline_SQLite/events-50-4             241.6µ ±  1%
GetTimeline_SQLite/events-100-4            433.7µ ±  1%
GetTimeline_SQLite/events-500-4            1.946m ±  2%
GetTimeline_SQLite/events-1000-4           3.821m ±  1%
geomean                                    213.5µ

                                   │ baseline-bench.txt │
                                   │        B/op        │
EventStoreAppend_InMemory-4                  776.5 ± 8%
EventStoreAppend_SQLite-4                  1.986Ki ± 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.25Ki

                                   │ 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

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

intel352 and others added 2 commits May 20, 2026 02:20
…ntial

Round 2 of the DO Spaces orphan fix (workflow#732 follow-up).

Round 1 (already in this PR) added a list-before-create pre-check so
the generator refuses to POST a duplicate name. That prevents NEW
orphans whose root cause is a save-failure-after-mint loop.

This round closes the other half: the orphan-creation event itself.

cmd/wfctl/infra_bootstrap.go — bootstrapSecrets provider_credential
path now:

1. Extracts access_key + created_at BEFORE the Set loop (was inside
   the loop, so a first-iteration Set failure left the rollback path
   blind to the just-minted credential).
2. On ANY Set failure inside the loop, invokes credRevoker.
   RevokeProviderCredential with the just-minted access_key. If no
   revoker is registered, emits a loud ORPHANED-CREDENTIAL warning
   to stderr with the wfctl list-orphans recovery command pre-filled.
3. Rollback is best-effort: failure does not mask the original Set
   error.

cmd/wfctl/secrets_orphans.go — new \`wfctl secrets list-orphans\`
subcommand:

  wfctl secrets list-orphans --source digitalocean.spaces \
    --name workflow-spaces-key                # dry-run
  wfctl secrets list-orphans --source digitalocean.spaces \
    --name workflow-spaces-key --delete       # delete all matches

Lists every upstream credential whose name field equals --name
(DO Spaces does not enforce name uniqueness, so a single name can
map to many access_keys after the orphan loop). Bounded pager
(100 × 100 = 10 000 keys). Per-orphan delete continues on failure
to surface every recoverable orphan.

Currently supports digitalocean.spaces; extending to other sources
adds one switch arm.

Tests:

- TestBootstrapSecrets_ProviderCredential_RollbackOnSetFailure —
  Set(SPACES_secret_key) returns error; assert revoker invoked with
  AK_ORPHAN; assert original error surfaces.
- TestBootstrapSecrets_ProviderCredential_RollbackOnFirstSetFailure —
  Set(SPACES_access_key) (the very first call) returns error;
  asserts the access_key extraction reorder is correct (revoker
  must receive AK_FIRST even though we never even reached the
  access_key iteration).

These two tests reproduce the pre-fix orphan-creation bug
(generator succeeds + Set fails → DO key remains, no rollback
attempted). Without the rollback wiring they fail with
"expected 1 rollback-revoke call; got 0".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing G204 false positive on a fixed-binary + url-escaped
endpoint subprocess. Documented inline; no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 20, 2026 06:29
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 7 out of 7 changed files in this pull request and generated 5 comments.

Comment thread secrets/generators.go
Comment on lines 199 to 208
name, _ := config["name"].(string)
if name == "" {
name = "workflow-spaces-key"
// Refuse the default fallback. A shared default name causes
// cross-project collisions in any account that runs more than
// one workflow-managed deploy — every project would try to
// create or adopt the same `workflow-spaces-key`. Force the
// operator to name the key per-project (e.g.
// `multisite-deploy-key`, `wfcompute-deploy-key`).
return "", fmt.Errorf("secrets: provider_credential digitalocean.spaces: `name` is required (use a project-unique slug like \"<project>-deploy-key\"; a shared default would collide across projects in the same DO account)")
}
Comment thread secrets/generators.go
// does not exist yet, so the key cannot be scoped to it).
// Once the bucket is created, the operator should rotate to a
// bucket-scoped key via the rotate-and-prune flow.
fmt.Fprintf(os.Stderr, "secrets: WARN provider_credential digitalocean.spaces name=%q is being granted permission=fullaccess (no `bucket:` configured); rotate to a bucket-scoped key after first apply via `wfctl secrets rotate --target SPACES --bucket <state-bucket>` to limit blast radius.\n", name)
Comment on lines +740 to +741
fmt.Fprintf(os.Stderr, "warn: provider_credential %q minted access_key=%s but no revoker available; the upstream credential is now ORPHANED and unrecoverable. Run `wfctl secrets list-orphans --source %s --name %s` to clean up. Original error: %v\n",
gen.Key, newAccessKey, gen.Source, gen.Key, reason)
fmt.Printf(" secret %q: created\n", fullKey)
}
}
_ = newCreatedAt // captured into RotationResult below
Comment on lines +88 to +92
//
// #nosec G204 -- the binary is the fixed string "gh" and `endpoint` is
// constructed from a literal-prefix + URL-escaped query inside this
// package (urlQueryEscape sanitises). No user-controlled shell metachars
// flow into the subprocess.
list-orphans was using local page-counter increments. Earlier runs
truncated at 100 because per_page was 100 + the locally-bounded loop
returned after page 1. DO's Spaces Keys API allows per_page up to
200 and returns an absolute next-page URL in links.pages.next; we
now follow that URL rather than incrementing locally.

Also emits a stderr scan summary so debugging is observable from
the CI log without re-running with extra flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@intel352 intel352 merged commit 23dbd8f into main May 20, 2026
2 checks passed
@intel352 intel352 deleted the fix/do-spaces-key-debug-1779256986 branch May 20, 2026 06:43
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.

2 participants