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
6 changed files with 640 additions and 126 deletions
Showing only changes of commit b07563a815 - Show all commits

View file

@ -68,7 +68,7 @@ cast diff <org>/<repo> --env <env> [--full]
cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>] cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
cast inventory <org>/<repo> --env <env> cast inventory <org>/<repo> --env <env>
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22] cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke [<org>/<repo>] --env <env> cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] cast team [--env <env>]
``` ```
@ -93,9 +93,13 @@ cast team [--env <env>]
- **`smoke`** — contract test against the project's `smoke_target`: proves - **`smoke`** — contract test against the project's `smoke_target`: proves
Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after
every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior, every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior,
and the published OpenAPI does not describe it accurately. Pass the repo whose and the published OpenAPI does not describe it accurately. It **writes** (two
target you mean; without one, only the deprecated state-file-scoped canary env vars onto that one application, then deletes them), so the repo is
`smoke_target` can answer. required: the target is resolved *inside the project and environment it was
declared under*, with `--project` / `--environment` if the box names either
differently, and it refuses rather than guessing when no application of that
name is there. A bare app name is unique nowhere else — one instance carrying
prod and staging is enough for the first `core` on it to be prod's.
- **`team`** — prints the team the configured token acts as. With `--env`, also - **`team`** — prints the team the configured token acts as. With `--env`, also
checks it against that environment's `team:` binding and exits non-zero on a checks it against that environment's `team:` binding and exits non-zero on a
mismatch — the dry run for "would `apply` refuse?", answered without touching mismatch — the dry run for "would `apply` refuse?", answered without touching
@ -260,13 +264,18 @@ yours:
| `--environment <name>` | the environment isn't named after `--env` (Coolify's default is `production`, not `prod`) | | `--environment <name>` | the environment isn't named after `--env` (Coolify's default is `production`, not `prod`) |
| `--resource <manifest>=<live>` | a resource isn't named after the manifest's (`core` is `Incubator Stack v2` over there). Repeatable | | `--resource <manifest>=<live>` | a resource isn't named after the manifest's (`core` is `Incubator Stack v2` over there). Repeatable |
All three are **read-side only**`diff`, `capture`, `inventory`. They are None of them is ever a manifest field: they are arguments to a single run, because
arguments to a one-off read, never manifest fields: a manifest that recorded a a manifest that recorded a legacy box's names would carry a dead machine's
legacy box's names would carry a dead machine's vocabulary forever. And `apply` vocabulary forever.
refuses `--resource` outright, because it creates resources under the manifest's
own names — an alias there could only mean *adopt the existing one instead*, `--project` and `--environment` are how a verb that must *find* a target says
which is a different operation and would otherwise silently create a duplicate where to look — `diff`, `capture`, `inventory`, `apply`, and `smoke`, which
beside the resource you were pointing at. resolves its `smoke_target` in exactly that project and that environment, and
refuses when it is not there (#29). `--resource` is **read-side only** (`diff`,
`capture`, `inventory`): `apply` refuses it outright, because it creates
resources under the manifest's own names — an alias there could only mean *adopt
the existing one instead*, which is a different operation and would otherwise
silently create a duplicate beside the resource you were pointing at.
`--env` stays **ours**: it selects the manifest block, the `environments.yaml` `--env` stays **ours**: it selects the manifest block, the `environments.yaml`
binding, the age key, the store path. `--environment` is *theirs*, on the wire, binding, the age key, the store path. `--environment` is *theirs*, on the wire,

View file

@ -52,6 +52,11 @@ const ProjectBindingSchema = z
// app, and the day a second project deploys into this environment, an // app, and the day a second project deploys into this environment, an
// environment-scoped (let alone the state-file-scoped one it replaces) // environment-scoped (let alone the state-file-scoped one it replaces)
// `smoke_target: core` is simply wrong. // `smoke_target: core` is simply wrong.
//
// It is declared here AND resolved here (#29): `smoke` looks the name up in
// this project, in this environment, and refuses when it is not there. A
// bare app name is unique nowhere else — the instance-wide lookup it used to
// do could pick prod's `core` while smoking staging.
smoke_target: z.string().optional(), smoke_target: z.string().optional(),
}) })
.strict(); .strict();
@ -146,22 +151,61 @@ const BindingsSchema = z
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so // slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
// existing state files keep working. // existing state files keep working.
github_apps: z.record(z.string()), github_apps: z.record(z.string()),
// DEPRECATED — moved to environments.<env>.projects.<repo>.smoke_target. // GONE — nothing reads this any more (#29). It is still DECLARED here, and
// Still read (see smokeTargetFor) so state files written before the move // refused below with a message, precisely because it is gone: this schema is
// keep working, on the same reasoning as the bare-`<repo>` github_apps key. // .strict(), so deleting the field outright would make an unmigrated state
// It is wrong at TWO levels: it names one project's app (`core`) from a key // file fail with a raw zod "unrecognized key" — and loadBindings runs for
// scoped to the whole state file, so it cannot distinguish two projects and // EVERY verb, so `diff`, `apply`, `capture` and `inventory` would all die on
// cannot distinguish prod's app from staging's either. // a key none of them ever read, mid-migration, with a message about nothing.
// A key that has to be removed by hand gets a sentence saying how.
smoke_target: z.string().optional(), smoke_target: z.string().optional(),
}) })
.strict() .strict()
// The registry only earns its keep if it is TRUE. Every check here defends the // Every check here defends one failure, from two ends: state that a command
// same failure: a project that a fleet run never visits, because a fleet run // will silently fail to act on. A registry that lies makes a fleet run skip a
// that skips a project prints exactly what a fleet run over a clean project // project — and a skipped project prints exactly what a clean one prints,
// prints — nothing. Silence is the one report that must never be ambiguous, so // nothing. A removed key that is still present makes `smoke` look like it has
// these are parse-time errors (every verb loads bindings, so every verb refuses // a target when nothing reads it. Silence is the one report that must never be
// a registry that lies) rather than warnings some command might print. // ambiguous, so these are parse-time errors (every verb loads bindings, so
// every verb refuses) rather than warnings some command might print.
.superRefine((bindings, ctx) => { .superRefine((bindings, ctx) => {
// GONE, not merely deprecated (#29) — see the field's note above. Reported
// first, and without returning: a file may well carry both this and a
// registry that needs fixing, and the operator should learn about both in
// one run rather than one per run.
if (bindings.smoke_target !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["smoke_target"],
// The key could never be fixed, only carried: `smoke` now resolves its
// target inside the project and environment the target was declared
// under, and a key scoped to the whole state file has no project to
// scope to. Carrying it meant keeping the instance-wide name lookup
// alive for exactly the invocation that most needed it dead —
// `cast smoke --env prod`.
message: [
"the top-level `smoke_target` key is no longer read (#29)",
"",
` found: smoke_target: ${bindings.smoke_target} (at the top level of this file)`,
"",
"It named ONE project's application from a key scoped to the whole state file,",
"so it could not tell two projects apart — or even prod's app from staging's.",
"`cast smoke` now resolves that name inside the project and environment it was",
"declared under, and this key names no project to resolve it in.",
"",
"Move it under the project it belongs to, and pass that repo to `cast smoke`:",
"",
" environments:",
" <env>:",
" projects:",
" <org>/<repo>:",
` smoke_target: ${bindings.smoke_target}`,
"",
" cast smoke <org>/<repo> --env <env>",
].join("\n"),
});
}
const registry = bindings.projects; const registry = bindings.projects;
if (!registry) return; if (!registry) return;
@ -338,25 +382,21 @@ export function projectBindingFor(
return projects[orgRepo] ?? projects[repoShort]; return projects[orgRepo] ?? projects[repoShort];
} }
// The app `cast smoke` targets. Project-scoped first; the deprecated // The app `cast smoke` targets, in ONE project of ONE environment — the only
// state-file-scoped `smoke_target` is the fallback, so an unmigrated state file // scope in which a bare application name is a coordinate at all. There is no
// still smokes. // fallback and deliberately none: a name that cannot say which project and
// which environment it belongs to does not identify an application, and `smoke`
// writes to whatever it identifies (#29).
// //
// `orgRepo` is optional because `cast smoke` did not take one until the target // `orgRepo` is required for the same reason: it is the project. Absence is not
// became project-scoped — without it there is no project to look up and only // an error here — an environment may simply declare no smoke target — but the
// the old key can answer, which is exactly what the old invocation did. // caller has to say so itself, and `smoke` does.
export function smokeTargetFor( export function smokeTargetFor(
bindings: Bindings, bindings: Bindings,
envName: string, envName: string,
orgRepo?: string, orgRepo: string,
): { target: string; source: "project" | "deprecated" } | undefined { ): string | undefined {
const scoped = orgRepo return projectBindingFor(bindings, envName, orgRepo)?.smoke_target;
? projectBindingFor(bindings, envName, orgRepo)?.smoke_target
: undefined;
if (scoped) return { target: scoped, source: "project" };
if (bindings.smoke_target)
return { target: bindings.smoke_target, source: "deprecated" };
return undefined;
} }
// The `<org>/<repo>` slugs registered for one environment — the list a fleet // The `<org>/<repo>` slugs registered for one environment — the list a fleet
@ -389,7 +429,9 @@ export function loadBindings(
if (!result.success) { if (!result.success) {
// Zod's own `.message` is the entire issue array as JSON — which renders the // Zod's own `.message` is the entire issue array as JSON — which renders the
// refusals above as one long line of `\n` escapes, i.e. throws away the part // refusals above as one long line of `\n` escapes, i.e. throws away the part
// of them that was worth writing. Render the issues instead. // of them that was worth writing. That matters most for the ones that are
// not typos at all but migrations (the removed `smoke_target`), where the
// message IS the instruction. Render the issues instead.
const detail = result.error.issues const detail = result.error.issues
.map((issue) => { .map((issue) => {
// A multi-line message is one WE wrote: it already names the path, the // A multi-line message is one WE wrote: it already names the path, the

View file

@ -63,7 +63,7 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--resource <m>=<l>] cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--resource <m>=<l>]
cast inventory --env <env> [--instance <name>] # no repo: SWEEP the whole instance cast inventory --env <env> [--instance <name>] # no repo: SWEEP the whole instance
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22] cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke [<org>/<repo>] --env <env> cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] cast team [--env <env>]
--state <dir> the state checkout holding environments.yaml, secrets/ and --state <dir> the state checkout holding environments.yaml, secrets/ and
@ -406,6 +406,65 @@ export function renderAbsentTarget(
].join("\n"); ].join("\n");
} }
// The same disposition as renderAbsentTarget, one level deeper, and for the one
// verb that WRITES: the project and the environment are both there, and hold no
// application of the name `smoke` was told to write to.
//
// Until #29, `smoke` never got here. It resolved its target against
// GET /applications — every application the token can see, across every project
// and every environment of the instance — and wrote to the first name match. So
// `smoke_target: core` did not name an application; it named whichever `core`
// Coolify happened to list first, and one instance carrying prod and staging is
// enough for that to be prod's. The canary vars land on an app nobody named, and
// on the failure path they stay there.
//
// This message therefore does NOT offer to look elsewhere, and the code behind it
// does not either. An application in another project is not the same application
// seen from a different angle — it is a different application, and this verb
// writes. The only thing worth saying is: here is where I looked, here is what is
// actually in there, and here is which coordinate to correct.
export function renderAbsentSmokeTarget(
target: string,
live: Array<{ kind: ResourceKind; name: string }>,
ctx: { orgRepo: string; env: string; project: string; environment: string },
): string {
const apps = live.filter((l) => l.kind === "application").map((l) => l.name);
// A service or a database of that name is not a near-miss to be accommodating
// about — smoke POSTs to /applications/<uuid>/envs, so being pointed at one
// would 404 on an endpoint that does not exist for that kind, and the operator
// would spend the afternoon on an HTTP status instead of on the name.
const sameName = live.find((l) => l.name === target);
const wrongKind =
sameName && sameName.kind !== "application"
? [
"",
` but note: "${target}" DOES exist here — as a ${sameName.kind}, not an`,
" application. `smoke` writes to an application's /envs endpoint;",
` a ${sameName.kind} of the same name is a different resource behind a`,
" different endpoint, not this one seen sideways.",
]
: [];
return [
`refusing to smoke: project "${ctx.project}" / environment "${ctx.environment}" holds no application named "${target}"`,
"",
` looked for: application "${target}"`,
` (environments.${ctx.env}.projects["${ctx.orgRepo}"].smoke_target)`,
` in: project "${ctx.project}", environment "${ctx.environment}"`,
` exists here: ${apps.join(", ") || "(no applications at all)"}`,
...wrongKind,
"",
"cast will not go looking for that name anywhere else on this instance. A bare",
"application name is unique only INSIDE a project and an environment, so the first",
`\`${target}\` the API lists may belong to another project — or to prod, while you are`,
"smoking staging (#29). `smoke` POSTs two canary env vars to the application it",
"resolves, and deletes them again; on the failure path it leaves them behind. An",
"app it was not pointed at is not a fallback.",
"",
"Name the application as it exists here, or pass --project / --environment if this",
"instance names the project or the environment differently.",
].join("\n");
}
// The third name a hand-built box does not share with you: the RESOURCE. // The third name a hand-built box does not share with you: the RESOURCE.
// //
// `--project` and `--environment` are coordinates for finding the target; // `--project` and `--environment` are coordinates for finding the target;
@ -1064,27 +1123,37 @@ async function main(): Promise<number> {
options: { options: {
state: { type: "string" }, state: { type: "string" },
env: { type: "string" }, env: { type: "string" },
project: { type: "string" },
environment: { type: "string" },
instance: { type: "string" }, instance: { type: "string" },
}, },
}); });
// Optional, unlike every other verb's — and only because the target used to // REQUIRED, like every other verb's — because the repo IS the project, and
// be state-file-scoped, so `cast smoke --env prod` with no repo at all is // the project is half of the only scope in which the target's name means
// what the runbook says today. Without it, only the deprecated key can // anything (#29). `cast smoke --env prod` with no repo used to work by
// answer; with it, the project-scoped one can. // reading the state-file-scoped `smoke_target`, which named an application
const smokeRepo = positionals[0]; // from a key that could not say which project or which environment it was
// smoke writes: it POSTs two env vars onto the live smoke_target app and // in; that key is gone (see BindingsSchema), and so is the invocation.
// deletes them again. That is a mutation, so it takes the assert like any const orgRepo = positionals[0];
// other. Without it, a wrong-team token that happened to own an app of const envName = values.env;
// the same name would have that app written to instead. if (!orgRepo || !envName) {
if (!values.env) {
console.error(USAGE); console.error(USAGE);
return 2; return 2;
} }
const stateDir = stateDirFrom(values.state); const stateDir = stateDirFrom(values.state);
const repoShort = orgRepo.split("/")[1];
// The same two read-side coordinates diff/capture/inventory take, for the
// same two reasons: a project built by hand in the UI is called whatever
// someone typed, and an environment built by hand is called whatever Coolify
// defaulted to (`production`, not `prod`). --env still selects the manifest
// block, the environments.yaml binding and the team to assert; --project and
// --environment change ONLY the names cast looks the target up under.
const projectName = values.project ?? repoShort;
const coolifyEnv = values.environment ?? envName;
const bindings = loadBindings(join(stateDir, "environments.yaml")); const bindings = loadBindings(join(stateDir, "environments.yaml"));
const binding = bindings.environments[values.env]; const binding = bindings.environments[envName];
if (!binding) { if (!binding) {
console.error(`environment ${values.env} not in environments.yaml`); console.error(`environment ${envName} not in environments.yaml`);
return 2; return 2;
} }
const { instance, client } = openCoolify( const { instance, client } = openCoolify(
@ -1092,61 +1161,69 @@ async function main(): Promise<number> {
values.instance, values.instance,
binding, binding,
); );
// smoke writes: it POSTs two env vars onto the live smoke_target app and
// deletes them again. That is a mutation, so it takes both gates — the
// read-only instance refusal and the team assert — before the first call.
// Without the assert, a wrong-team token that happened to own an app of the
// same name would have that app written to instead.
assertWritable(instance, "smoke"); assertWritable(instance, "smoke");
const team = await assertTeam(client, binding.team, values.env); const team = await assertTeam(client, binding.team, envName);
console.log(`team ${formatTeam(team)}`); console.log(`team ${formatTeam(team)}`);
const resolved = smokeTargetFor(bindings, values.env, smokeRepo); const target = smokeTargetFor(bindings, envName, orgRepo);
if (!resolved) { if (!target) {
const lookedFor = smokeRepo
? [
` looked for: environments.${values.env}.projects["${smokeRepo}"].smoke_target`,
" then the deprecated state-file-scoped smoke_target",
]
: [
" looked for: the deprecated state-file-scoped smoke_target — and only",
" that one, because no <org>/<repo> was given and so no",
" project's binding could be consulted",
];
console.error( console.error(
[ [
`no smoke_target for ${values.env}`, `no smoke_target for ${orgRepo} in ${envName}`,
"", "",
...lookedFor, ` looked for: environments.${envName}.projects["${orgRepo}"].smoke_target`,
` (a bare "${repoShort}" key resolves too)`,
"", "",
"`smoke` writes two canary env vars to one application and deletes them", "`smoke` writes two canary env vars to one application and deletes them",
"again — it has to be told which one. Name it under the project it belongs", "again — it has to be told which one, under the project that owns it:",
"to, and pass that repo:",
"", "",
" environments:", " environments:",
` ${values.env}:`, ` ${envName}:`,
" projects:", " projects:",
` ${smokeRepo ?? "<org>/<repo>"}:`, ` ${orgRepo}:`,
" smoke_target: <the application's name>", " smoke_target: <the application's name>",
].join("\n"), ].join("\n"),
); );
return 2; return 2;
} }
if (resolved.source === "deprecated") { // The fix for #29, and the whole of it: the target is resolved in the project
console.warn( // and the environment it was DECLARED under — the same lookup every read-side
`warning: smoke_target read from the deprecated state-file-scoped key. It names ONE project's application from a key that cannot tell two projects — or even prod from staging — apart. Move it to environments.${values.env}.projects.<org>/<repo>.smoke_target and pass the repo to \`cast smoke\`.`, // verb makes — instead of by name against GET /applications, which is every
// application on the instance and answers with whichever one it lists first.
const lookup = await fetchLive(client, projectName, coolifyEnv);
if (!lookup.found) {
console.error(
renderAbsentTarget(lookup, {
orgRepo,
overridden: values.project !== undefined,
envOverridden: values.environment !== undefined,
verb: "smoke",
}),
); );
}
// Resolved against the instance-wide application list, not the project's:
// that is what it did before this change and it is not this change's job to
// alter which app gets written to. It does mean the name is not actually a
// coordinate — one project's `core` and another's, or prod's and staging's
// on the same instance, are a coin flip. See #29; fixing it needs the
// read-side coordinates (--project/--environment) smoke does not yet have.
const apps = (await client.get("/applications")) as Array<{
uuid: string;
name: string;
}>;
const target = apps.find((a) => a.name === resolved.target);
if (!target) {
console.error(`smoke_target ${resolved.target} not found`);
return 2; return 2;
} }
await smoke(client, target.uuid); // Applications only. fetchLive returns every kind in the environment, and a
// service or database called `core` is not a smoke target — it is a 404 on an
// endpoint that does not exist for it (see renderAbsentSmokeTarget).
const app = lookup.live.find(
(l) => l.kind === "application" && l.name === target,
);
if (!app) {
console.error(
renderAbsentSmokeTarget(target, lookup.live, {
orgRepo,
env: envName,
project: projectName,
environment: coolifyEnv,
}),
);
return 2;
}
await smoke(client, app.uuid);
return 0; return 0;
} }
if (command === "team") { if (command === "team") {

View file

@ -18,10 +18,7 @@ function bindings(github_apps: Record<string, string>): Bindings {
} as Bindings; } as Bindings;
} }
function withProjects( function withProjects(projects: Record<string, ProjectBinding>): Bindings {
projects: Record<string, ProjectBinding>,
smoke_target?: string,
): Bindings {
return { return {
environments: { environments: {
prod: { prod: {
@ -32,7 +29,6 @@ function withProjects(
staging: { server: "staging-box", team: { id: 0, name: "Root Team" } }, staging: { server: "staging-box", team: { id: 0, name: "Root Team" } },
}, },
github_apps: {}, github_apps: {},
...(smoke_target ? { smoke_target } : {}),
} as Bindings; } as Bindings;
} }
@ -140,49 +136,36 @@ describe("projectBindingFor", () => {
}); });
describe("smokeTargetFor", () => { describe("smokeTargetFor", () => {
it("prefers the project-scoped target", () => { it("resolves the project-scoped target", () => {
const b = withProjects( const b = withProjects({
{ "heavy-duty/incubator": { smoke_target: "core" } }, "heavy-duty/incubator": { smoke_target: "core" },
"old-target",
);
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toEqual({
target: "core",
source: "project",
}); });
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toBe("core");
}); });
// The state file mid-migration still has only the old key — it must keep it("is undefined when the project declares no target", () => {
// smoking, exactly as the bare-`<repo>` github_apps key keeps resolving.
it("falls back to the deprecated state-file-scoped key, and says so", () => {
const b = withProjects({}, "old-target");
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toEqual({
target: "old-target",
source: "deprecated",
});
// ...and with no repo passed at all, which is the old invocation.
expect(smokeTargetFor(b, "prod")).toEqual({
target: "old-target",
source: "deprecated",
});
});
it("is undefined when neither key names a target", () => {
expect( expect(
smokeTargetFor(withProjects({}), "prod", "heavy-duty/incubator"), smokeTargetFor(withProjects({}), "prod", "heavy-duty/incubator"),
).toBe(undefined); ).toBe(undefined);
expect(
smokeTargetFor(
withProjects({ "heavy-duty/incubator": { smoke_target: "core" } }),
"staging",
"heavy-duty/incubator",
),
).toBe(undefined);
}); });
// Two projects, each with its own smoke target: the case the old key could // Two projects, each with its own smoke target: the case the removed
// not express at all, since it named one app for the whole state file. // state-file-scoped key could not express at all, since it named one app for
// the whole file.
it("keeps two projects' smoke targets apart", () => { it("keeps two projects' smoke targets apart", () => {
const b = withProjects({ const b = withProjects({
"heavy-duty/incubator": { smoke_target: "core" }, "heavy-duty/incubator": { smoke_target: "core" },
"acme/client-site": { smoke_target: "web" }, "acme/client-site": { smoke_target: "web" },
}); });
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")?.target).toBe( expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toBe("core");
"core", expect(smokeTargetFor(b, "prod", "acme/client-site")).toBe("web");
);
expect(smokeTargetFor(b, "prod", "acme/client-site")?.target).toBe("web");
}); });
}); });
@ -207,6 +190,31 @@ github_apps: {}
}); });
}); });
// The key is gone (#29): it named one project's application from a scope that
// could not tell two projects — or prod from staging — apart, and `smoke` now
// resolves the name INSIDE the project it was declared under, which this key
// does not have. It is still declared in the schema purely so its removal
// reads as a migration instead of as a zod "unrecognized key" — loadBindings
// runs for every verb, so an unmigrated file would otherwise take `diff` and
// `apply` down with it, over a key neither of them reads.
it("refuses a state-file-scoped smoke_target, and says where to move it", () => {
const load = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
github_apps: {}
smoke_target: core
`,
});
expect(load).toThrow(/top-level `smoke_target` key is no longer read/);
expect(load).toThrow(/projects:/);
expect(load).toThrow(/smoke_target: core/);
expect(load).toThrow(/cast smoke <org>\/<repo> --env <env>/);
});
it("rejects an unknown key under a project (a typo is not a placement)", () => { it("rejects an unknown key under a project (a typo is not a placement)", () => {
expect(() => expect(() =>
loadBindings("environments.yaml", { loadBindings("environments.yaml", {

View file

@ -90,9 +90,11 @@ function stateWith(opts: {
" server: prod-box", " server: prod-box",
" team: { id: 0, name: Root Team }", " team: { id: 0, name: Root Team }",
...(opts.boundInstance ? [` instance: ${opts.boundInstance}`] : []), ...(opts.boundInstance ? [` instance: ${opts.boundInstance}`] : []),
" projects:",
" heavy-duty/incubator:",
" smoke_target: core",
"github_apps:", "github_apps:",
" incubator: hdb-coolify", " incubator: hdb-coolify",
"smoke_target: core",
"", "",
].join("\n"), ].join("\n"),
); );
@ -135,10 +137,20 @@ describe("infra cli", () => {
expect(r.output).toMatch(/--env/); expect(r.output).toMatch(/--env/);
}); });
it("refuses smoke without --env, exit non-zero", async () => { it("refuses smoke without --env, exit non-zero", async () => {
const r = await runCli(["smoke"]); const r = await runCli(["smoke", "heavy-duty/incubator"]);
expect(r.code).not.toBe(0); expect(r.code).not.toBe(0);
expect(r.output).toMatch(/--env/); expect(r.output).toMatch(/--env/);
}); });
// The repo is the PROJECT, and the project is half of the only scope in which
// `smoke_target: core` names anything (#29). `cast smoke --env prod` used to
// run — resolving the name against every application on the instance — which
// is precisely the invocation that could write prod's `core` while smoking
// staging. It is now a usage error, before any state is even read.
it("refuses smoke without the <org>/<repo> positional, exit non-zero", async () => {
const r = await runCli(["smoke", "--env", "prod"]);
expect(r.code).toBe(2);
expect(r.output).toMatch(/cast smoke\s+<org>\/<repo>/);
});
}); });
describe("--instance (multiple Coolify instances)", () => { describe("--instance (multiple Coolify instances)", () => {
@ -235,6 +247,7 @@ describe("--instance (multiple Coolify instances)", () => {
}); });
const r = await runCli([ const r = await runCli([
"smoke", "smoke",
"heavy-duty/incubator",
"--state", "--state",
dir, dir,
"--env", "--env",

365
test/smoke-cli.test.ts Normal file
View file

@ -0,0 +1,365 @@
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
// `cast smoke`, end to end, against the instance shape #29 is actually about.
//
// ONE Coolify, carrying:
//
// project incubator / environment prod → application `core` (a-prod-core)
// project incubator / environment staging → application `core` (a-staging-core)
// → database `db`
// project client-site / environment staging → application `core` (a-client-core)
//
// Three applications called `core`. That is not a contrived box — `instance:` is
// a per-environment binding, so with none set, prod and staging read the same
// .coolify.env and live on the same control plane. Until this fix, smoke resolved
// its target against GET /applications (every app the token can see, all projects,
// all environments) and wrote to the FIRST name match — so this stub lists prod's
// `core` first, which is what `cast smoke --env staging` would have written its
// canary vars onto, and (on the failure path) left them on.
//
// The wire is the witness in every test below: which uuid was written to, and —
// just as load-bearing — that the instance-wide list was never asked for at all.
type Stub = {
url: string;
hits: string[];
writes: string[];
close: () => Promise<void>;
};
const stubs: Stub[] = [];
type EnvVar = { key: string; value: string; is_buildtime: boolean };
async function stubCoolify(): Promise<Stub> {
const hits: string[] = [];
const writes: string[] = [];
// One env store per application, so a write to the wrong `core` is visible as
// a write to the wrong uuid rather than as nothing at all.
const envs: Record<string, Array<EnvVar & { uuid: string }>> = {
"a-prod-core": [],
"a-staging-core": [],
"a-client-core": [],
};
let nextUuid = 1;
const server = createServer((req, res) => {
const method = req.method ?? "GET";
const path = (req.url ?? "").replace("/api/v1", "");
hits.push(`${method} ${path}`);
if (method !== "GET") writes.push(`${method} ${path}`);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
const app = (name: string, uuid: string) => ({ name, uuid });
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/version") {
res.writeHead(200, { "content-type": "text/plain" });
return res.end("4.1.2");
}
if (path === "/projects")
return json([
{ uuid: "p-inc", name: "incubator" },
{ uuid: "p-cli", name: "client-site" },
]);
// The lookup this fix REPLACES. Answered anyway (prod's `core` first, as
// Coolify would list the older resource) so the tests can assert cast never
// asks for it — a stub that 404'd here would prove only that cast survives
// the 404.
if (path === "/applications")
return json([
app("core", "a-prod-core"),
app("core", "a-client-core"),
app("core", "a-staging-core"),
]);
if (path === "/projects/p-inc/prod")
return json({ applications: [app("core", "a-prod-core")] });
if (path === "/projects/p-inc/staging")
return json({
applications: [app("core", "a-staging-core")],
postgresqls: [app("db", "d-staging")],
});
if (path === "/projects/p-cli/staging")
return json({ applications: [app("core", "a-client-core")] });
const env = path.match(/^\/applications\/([^/]+)\/envs(\/(.+))?$/);
if (env) {
const store = envs[env[1]];
if (!store) {
res.writeHead(404);
return res.end("{}");
}
const rest = env[3];
if (method === "GET" && !rest) return json(store);
if (method === "POST" && !rest) {
let body = "";
req.on("data", (d) => {
body += String(d);
});
return req.on("end", () => {
const v = JSON.parse(body) as EnvVar;
const created = { ...v, uuid: `e-${nextUuid++}` };
store.push(created);
json(created);
});
}
// Upsert, mirroring verified Coolify 4.1.2 behavior — the property `smoke`
// exists to keep checking (see src/smoke.ts).
if (method === "PATCH" && rest === "bulk") {
let body = "";
req.on("data", (d) => {
body += String(d);
});
return req.on("end", () => {
const { data } = JSON.parse(body) as { data: EnvVar[] };
for (const v of data) {
const existing = store.find((e) => e.key === v.key);
if (existing) Object.assign(existing, v);
else store.push({ ...v, uuid: `e-${nextUuid++}` });
}
json({ ok: true });
});
}
if (method === "DELETE" && rest) {
envs[env[1]] = store.filter((e) => e.uuid !== rest);
res.writeHead(204);
return res.end();
}
}
res.writeHead(404);
res.end("{}");
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
writes,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
// `targets` is the knob: what each project's binding says `smoke` should write
// to. smoke needs no manifest, no checkout, no secret store and no age key — it
// reads the state file and the live box, and nothing else.
function state(
url: string,
targets: Record<string, string> = {
"heavy-duty/incubator": "core",
"acme/client-site": "core",
},
): string {
const dir = mkdtempSync(join(tmpdir(), "cast-smoke-"));
writeFileSync(
join(dir, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
const projects = Object.entries(targets).flatMap(([repo, target]) => [
` ${repo}:`,
` smoke_target: ${target}`,
]);
writeFileSync(
join(dir, "environments.yaml"),
[
"environments:",
" staging:",
" server: shared-box",
" team: { id: 0, name: Root Team }",
" projects:",
...projects,
" prod:",
" server: shared-box",
" team: { id: 0, name: Root Team }",
" projects:",
...projects,
"github_apps:",
" incubator: hdb-coolify",
"",
].join("\n"),
);
return dir;
}
function run(args: string[]): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", "smoke", ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
describe("cast smoke — resolved inside its project + environment (#29)", () => {
it("writes to THIS environment's app, not the first `core` the instance lists", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"staging",
]);
expect(r.code).toBe(0);
expect(r.output).toMatch(/smoke OK/);
// THE POINT. Every mutation landed on staging's `core` — and prod's, which
// the instance-wide lookup would have picked first, was never touched.
expect(stub.writes.length).toBeGreaterThan(0);
for (const w of stub.writes) expect(w).toContain("a-staging-core");
expect(stub.writes.join("\n")).not.toContain("a-prod-core");
expect(stub.writes.join("\n")).not.toContain("a-client-core");
// And the namespace that made prod reachable at all was never even asked
// for: the target is resolved through the project, like every other verb's.
expect(stub.hits).not.toContain("GET /applications");
expect(stub.hits).toContain("GET /projects/p-inc/staging");
});
it("follows --env to the other environment of the same project", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"prod",
]);
expect(r.code).toBe(0);
for (const w of stub.writes) expect(w).toContain("a-prod-core");
expect(stub.writes.join("\n")).not.toContain("a-staging-core");
});
// The other half of the coordinate: same environment, same instance, same app
// name — a different project, and therefore a different application.
it("follows the repo to the other project's app of the same name", async () => {
const stub = await stubCoolify();
const r = await run([
"acme/client-site",
"--state",
state(stub.url),
"--env",
"staging",
]);
expect(r.code).toBe(0);
for (const w of stub.writes) expect(w).toContain("a-client-core");
expect(stub.writes.join("\n")).not.toContain("a-staging-core");
});
it("reads the box's project and environment names when they are not ours", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"staging",
// `staging` is OURS: it selects the binding and the team to assert. The
// box calls this project's environment `prod`, and only the box's name
// goes on the wire.
"--project",
"incubator",
"--environment",
"prod",
]);
expect(r.code).toBe(0);
for (const w of stub.writes) expect(w).toContain("a-prod-core");
});
});
describe("cast smoke — refusing rather than guessing (#29)", () => {
it("refuses when this project + environment holds no app of that name, and says what it does hold", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url, { "heavy-duty/incubator": "web" }),
"--env",
"staging",
]);
expect(r.code).toBe(2);
expect(r.output).toContain('holds no application named "web"');
// What IS there — the finding, and the whole reason this is not a 404.
expect(r.output).toMatch(/exists here:\s+core/);
expect(r.output).toContain("smoke_target");
// Not "…so I looked on the rest of the instance and found one". An app in
// another project is a different app, and this verb writes.
expect(stub.hits).not.toContain("GET /applications");
expect(stub.writes).toEqual([]);
});
it("refuses a target that exists here but is not an application", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url, { "heavy-duty/incubator": "db" }),
"--env",
"staging",
]);
expect(r.code).toBe(2);
// smoke POSTs to /applications/<uuid>/envs. Pointed at the postgres, it
// would 404 on an endpoint that does not exist for a database, and the
// operator would debug the status code instead of the name.
expect(r.output).toMatch(/"db" DOES exist here — as a database/);
expect(r.output).toMatch(/not an\s+application/);
expect(r.output).toContain("/envs endpoint");
expect(stub.writes).toEqual([]);
});
// The project/environment refusal, reached through the same fetchLive every
// read-side verb uses — so smoke inherits it verbatim (see renderAbsentTarget).
it("refuses an absent environment as absent, naming --environment", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"staging",
"--environment",
"production",
]);
expect(r.code).toBe(2);
expect(r.output).toContain("refusing to smoke");
expect(r.output).toContain('has no environment "production"');
expect(stub.writes).toEqual([]);
});
it("refuses when the project declares no smoke_target at all", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url, { "acme/client-site": "core" }),
"--env",
"staging",
]);
expect(r.code).toBe(2);
expect(r.output).toContain("no smoke_target for heavy-duty/incubator");
expect(r.output).toContain("smoke_target: <the application's name>");
expect(stub.writes).toEqual([]);
});
});