smoke resolves its target inside the project it was declared under (#29) #31

Merged
dan-claude-bot merged 2 commits from fix/smoke-project-scoped into main 2026-07-13 20:45:40 +00:00
dan-claude-bot commented 2026-07-13 20:09:34 +00:00 (Migrated from github.com)

Closes #29.

Stacked on #30. This branch is based on feat/project-registry (#25) because both touch BindingsSchema's refinements and loadBindings' error rendering — the conflict is already resolved here. Merge #30 first; this PR's diff then collapses to the #29 change alone. Until then GitHub shows #25's commit in it too. Review the second commit.

smoke found the application it writes to by name against GET /applications — every application the token can see, across every project and every environment on the instance — and took the first name match. So smoke_target: core did not name an application; it named whichever application called core Coolify happened to list first.

It does not take two projects for this to bite. One project with two environments on one instance is enough, which is the ordinary shape: cast smoke --env staging could POST its canary vars onto prod's core, and on the failure path leave them there. The verb whose entire job is to be a write against a live application was picking its victim by a lookup that cannot distinguish prod from staging.

The fix

  • Resolve through fetchLive(client, projectName, coolifyEnv) — the same lookup every other verb uses — and filter to kind === "application". GET /applications is not called at all any more.
  • smoke gains the read-side coordinates it was the last verb to lack: --project / --environment, with diff/capture/inventory's exact semantics (the D-241 split, one level up from --resource).
  • Refuse rather than guess. An absent project/environment reuses renderAbsentTarget(…, { verb: "smoke" }). A project+environment that exists but holds no application of that name gets a new refusal listing the applications that are there — and if a resource of that name exists as a service or database, it says so, because smoke POSTs to /applications/<uuid>/envs and would otherwise 404 on an endpoint that does not exist for that kind. Neither refusal widens the search: an application in another project is not the same application seen from a different angle.

The wrinkle, resolved as the issue proposed

The <org>/<repo> positional is now required, and the deprecated state-file-scoped smoke_target is dropped — it had no project to scope to, so it could only ever be carried, and carrying it meant keeping the instance-wide lookup alive for exactly the invocation that most needed it dead (cast smoke --env prod).

Because BindingsSchema is .strict(), the key is still declared — and refused with a migration message telling the operator exactly what to write instead. Deleting the field outright would have made an unmigrated environments.yaml fail with a raw zod unrecognized key, and loadBindings runs for every verb: diff, apply, capture and inventory would all have died on a key none of them ever read.

⚠️ Operator note — this is a lockstep migration

heavy-duty/infra still carries the top-level smoke_target: core. Pull the infra migration and re-install cast together. Re-installing cast alone against an unmigrated state file breaks every verb, not just smoke. The matching infra PR moves the key under environments.prod.projects."heavy-duty/incubator" and updates the three cast smoke call sites in runbooks/coolify-bootstrap.md.

Test

test/smoke-cli.test.ts stubs a Coolify holding three applications named core — incubator/prod, incubator/staging, and a second project's — and asserts from the wire that every mutation lands on the staging one, that prod's is untouched, and that GET /applications is never requested.

Gates: npm run check, npm run build, npm test (222 passing) all green.

🤖 Generated with Claude Code

Closes #29. > **Stacked on #30.** This branch is based on `feat/project-registry` (#25) because both touch `BindingsSchema`'s refinements and `loadBindings`' error rendering — the conflict is already resolved here. **Merge #30 first**; this PR's diff then collapses to the #29 change alone. Until then GitHub shows #25's commit in it too. Review the second commit. `smoke` found the application it **writes** to by name against `GET /applications` — every application the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever application called `core` Coolify happened to list first. It does not take two projects for this to bite. **One project with two environments on one instance is enough**, which is the ordinary shape: `cast smoke --env staging` could POST its canary vars onto **prod's** `core`, and on the failure path leave them there. The verb whose entire job is to be a *write* against a live application was picking its victim by a lookup that cannot distinguish prod from staging. ### The fix - Resolve through `fetchLive(client, projectName, coolifyEnv)` — the same lookup every other verb uses — and filter to `kind === "application"`. `GET /applications` is not called at all any more. - `smoke` gains the read-side coordinates it was the last verb to lack: `--project` / `--environment`, with `diff`/`capture`/`inventory`'s exact semantics (the D-241 split, one level up from `--resource`). - **Refuse rather than guess.** An absent project/environment reuses `renderAbsentTarget(…, { verb: "smoke" })`. A project+environment that exists but holds no application of that name gets a new refusal listing the applications that *are* there — and if a resource of that name exists as a *service* or *database*, it says so, because `smoke` POSTs to `/applications/<uuid>/envs` and would otherwise 404 on an endpoint that does not exist for that kind. Neither refusal widens the search: an application in another project is not the same application seen from a different angle. ### The wrinkle, resolved as the issue proposed The `<org>/<repo>` positional is now **required**, and the deprecated state-file-scoped `smoke_target` is **dropped** — it had no project to scope to, so it could only ever be carried, and carrying it meant keeping the instance-wide lookup alive for exactly the invocation that most needed it dead (`cast smoke --env prod`). Because `BindingsSchema` is `.strict()`, the key is still *declared* — and refused with a migration message telling the operator exactly what to write instead. Deleting the field outright would have made an unmigrated `environments.yaml` fail with a raw zod *unrecognized key*, and `loadBindings` runs for **every** verb: `diff`, `apply`, `capture` and `inventory` would all have died on a key none of them ever read. ### ⚠️ Operator note — this is a lockstep migration `heavy-duty/infra` still carries the top-level `smoke_target: core`. **Pull the infra migration and re-install cast together.** Re-installing cast alone against an unmigrated state file breaks every verb, not just `smoke`. The matching infra PR moves the key under `environments.prod.projects."heavy-duty/incubator"` and updates the three `cast smoke` call sites in `runbooks/coolify-bootstrap.md`. ### Test `test/smoke-cli.test.ts` stubs a Coolify holding **three** applications named `core` — incubator/prod, incubator/staging, and a second project's — and asserts from the wire that every mutation lands on the staging one, that prod's is untouched, and that `GET /applications` is never requested. Gates: `npm run check`, `npm run build`, `npm test` (222 passing) all green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#31
No description provided.