feat(capture): --generated-only, the bootstrap's missing pass 2 #58
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#58
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/capture-generated-only"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The problem
A manifest that declares
generated_secrets:bootstraps in two passes, by construction — the value does not exist until Coolify makes it. cast had pass 1 and no pass 2:captureplaceholds the generated names (pending-coolify-generated) — correctly: copying a source box'sDATABASE_URLinto a new box's store is the silent, unrecoverable failure that rule exists to prevent.applycreates the database; Coolify generates the real URL.So the operator did step 3 by hand: decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's age recipient, against production, holding the prod key, with a
jqfilter that must not pick the wrong row.Until it runs, the store says
pending-coolify-generatedwhile the live value is real — exactly the state in which the next routineapplyoverwrites a working secret (#47). #47 stops the shot; this removes the gun. It is also on the DR path: rebuild the control plane from state means apply-from-nothing, so every generated secret in every store is a placeholder again.What changed
cast capture <org>/<repo> --env <env> --generated-only [--from <NAME>=<db>] [--force]Same verb, same ceremony, same store-writing code path. The flag inverts
capture.ts's disposition rule: names ingenerated_secretsare the ones it fills, and every other name is left exactly as the store has it, byte for byte — never re-read from the box, so a secret rotated by hand last month survives it. The store must already exist: pass 2 fills names, it does not create them.Each refusal, and the assertion:
--from--force(it would silently rotate a live credential)pending-coolify-generatedremain, and the name count is unchangedThe plan prints names only —
capture.ts:166's rule holds, and the only value-shaped thing printed is the placeholder literal being replaced.applydeliberately does not do this automatically after a create, for the reason the issue gives.On not guessing which database a name comes from. Nothing in the system carries that edge:
generated_secrets:is a flat list of names, the env template knows onlyDATABASE_URL=${DATABASE_URL}, and the box does not say. So cast infers only when the inference cannot be wrong (one generated name, one database) and otherwise refuses with a ready-to-paste--from DATABASE_URL=incubator-db. Reading the type out of the name (REDIS_URL→ the redis one) is precisely the bug this must not have: a name-directed pick is wrong silently, and what it writes is a well-formed URL to somebody else's database. The clean long-term fix is a manifest that can express the edge (generated_secrets: {DATABASE_URL: {from: postgres}}) — that is amanifest.tsschema change, outside this PR's file scope; noted below.Field-name evidence, from the Coolify source
The vendored
reference/coolify-openapi-4.1.2.jsondocuments theGET /databasesbody as "Content is very complex. Will be implemented later.", so the field names were settled fromcoollabsio/coolifyv4.1.2 directly:internal_db_urlis the same key on BOTH database types.app/Models/StandalonePostgresql.php:61andapp/Models/StandaloneRedis.php:54each carryprotected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];. It is an appended accessor, not a column. The Redis field name isinternal_db_url— the issue anticipated it might differ, and it does not. Only the URL the accessor builds differs:postgres://{user}:{pw}@{uuid}:5432/{db}(StandalonePostgresql.php:307-325) vsredis://{user}:{pw}@{uuid}:6379/0(StandaloneRedis.php:295-315, which also switches torediss:///6380 underenable_ssl, and omits the username segment below Redis 6.0).The value is read from
GET /projects/{uuid}/{env}, notGET /databases— and this is a deliberate deviation from the issue's letter, on two independent grounds:ProjectController@environment_details(ProjectController.php:167) eager-loads['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']for this environment. The instance-wideGET /databases(DatabasesController@databases:80-105) merges every database of every project the team owns — so picking ours out of it means matching by name across a list where a collision is possible and silent (#29 in another hat; the hand-runjqcarried a comment about not taking the third row, umami's bundled Postgres). On the environment route that bug cannot be expressed — the scoping is structural rather than a filter cast has to get right.DatabasesController::removeSensitiveData()(DatabasesController.php:30-49) callsmakeHidden(['internal_db_url', 'external_db_url', 'postgres_password', 'redis_password', …])wheneverrequest()->attributes->get('can_read_sensitive', false) === false— so onGET /databasesthe field is absent for a token without the sensitive-read permission, silently.environment_detailscalls noremoveSensitiveData()and returnsserializeApiResponse($environment), which (bootstrap/helpers/api.php:29-88) only sorts keys and hides nothing — so the appended attribute rides along, ungated. The route the issue proposed would have failed open on a lesser token; this one does not. cast still refuses loudly if a database reports nointernal_db_url, rather than filling a secret with"".Tests added (+32; 294 → 326, all green)
npm run check && npm run build && npm test— the gate CI runs, in that order.test/capture.test.ts(unit, +16): the mapping refuses to guess across two databases and names the candidates; infers the only database when there is exactly one; still refuses when one database must serve two generated names; refuses a--fromnaming a database outside the project+env. The plan fills and keeps byte-for-byte, refuses OCCUPIED,--forceoverrides it, refuses ABSENT and PENDING, and never double-reports a refused name askeep. The renderer prints no value.assertGeneratedCompletecatches a placeholder left standing, a name lost on the way through, and a name added.test/capture-cli.test.ts(end-to-end, +16): the realdist/cli.js, a realageidentity round-trip, a stub Coolify. A pass-1 store is seeded, pass 2 runs, and the ciphertext is decrypted and asserted on:DATABASE_URLgets the Postgres URL,REDIS_URLgets the Redis URL (not the Postgres one under a Redis name),MAILGUN_API_KEYsurvives byte-for-byte, zero placeholders remain, name count unchanged. The stub records every path requested, and one test asserts cast never touches/databases— where the stub serves a third row, another project'sincubator-db, that a name-directed lookup would eventually take. Plus: no secret value on the console, every refusal above, the typed confirmation, and the three flag-pairing refusals (--override,--fromwithout--generated-only,--resource).No live Coolify was contacted; there are no credentials in this box.
Noticed, not fixed (staying inside my files)
generated_secrets:isz.array(z.string()).--fromis the operator-supplied edge; amanifest.tsschema change would let cast infer it with no flag and no guess. Worth its own issue.capture's--forcenow carries two meanings depending on the pass (pass 1: overwrite the whole store; pass 2: rotate a generated name that already holds a real value). They are documented and cannot collide — pass 2 requires the store to exist — but a future--rotatemight read better.Closes #48.