Merge pull request #31 from claude-hdb/fix/smoke-project-scoped

smoke resolves its target inside the project it was declared under (#29)
This commit is contained in:
Daniel Marin 2026-07-13 21:45:40 +01:00 committed by GitHub
commit 637028dc28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 640 additions and 126 deletions

View file

@ -68,7 +68,7 @@ cast diff <org>/<repo> --env <env> [--full]
cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
cast inventory <org>/<repo> --env <env>
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>]
```
@ -93,9 +93,13 @@ cast team [--env <env>]
- **`smoke`** — contract test against the project's `smoke_target`: proves
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,
and the published OpenAPI does not describe it accurately. Pass the repo whose
target you mean; without one, only the deprecated state-file-scoped
`smoke_target` can answer.
and the published OpenAPI does not describe it accurately. It **writes** (two
canary env vars onto that one application, then deletes them), so the repo is
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
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
@ -260,13 +264,18 @@ yours:
| `--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 |
All three are **read-side only**`diff`, `capture`, `inventory`. They are
arguments to a one-off read, never manifest fields: a manifest that recorded a
legacy box's names would carry a dead machine's vocabulary forever. And `apply`
refuses `--resource` 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.
None of them is ever a manifest field: they are arguments to a single run, because
a manifest that recorded a legacy box's names would carry a dead machine's
vocabulary forever.
`--project` and `--environment` are how a verb that must *find* a target says
where to look — `diff`, `capture`, `inventory`, `apply`, and `smoke`, which
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`
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
// environment-scoped (let alone the state-file-scoped one it replaces)
// `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(),
})
.strict();
@ -146,22 +151,61 @@ const BindingsSchema = z
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
// existing state files keep working.
github_apps: z.record(z.string()),
// DEPRECATED — moved to environments.<env>.projects.<repo>.smoke_target.
// Still read (see smokeTargetFor) so state files written before the move
// keep working, on the same reasoning as the bare-`<repo>` github_apps key.
// It is wrong at TWO levels: it names one project's app (`core`) from a key
// scoped to the whole state file, so it cannot distinguish two projects and
// cannot distinguish prod's app from staging's either.
// GONE — nothing reads this any more (#29). It is still DECLARED here, and
// refused below with a message, precisely because it is gone: this schema is
// .strict(), so deleting the field outright would make an unmigrated state
// file fail with a raw zod "unrecognized key" — and loadBindings runs for
// EVERY verb, so `diff`, `apply`, `capture` and `inventory` would all die on
// 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(),
})
.strict()
// The registry only earns its keep if it is TRUE. Every check here defends the
// same failure: a project that a fleet run never visits, because a fleet run
// that skips a project prints exactly what a fleet run over a clean project
// prints — nothing. Silence is the one report that must never be ambiguous, so
// these are parse-time errors (every verb loads bindings, so every verb refuses
// a registry that lies) rather than warnings some command might print.
// Every check here defends one failure, from two ends: state that a command
// will silently fail to act on. A registry that lies makes a fleet run skip a
// project — and a skipped project prints exactly what a clean one prints,
// nothing. A removed key that is still present makes `smoke` look like it has
// a target when nothing reads it. Silence is the one report that must never be
// 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) => {
// 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;
if (!registry) return;
@ -338,25 +382,21 @@ export function projectBindingFor(
return projects[orgRepo] ?? projects[repoShort];
}
// The app `cast smoke` targets. Project-scoped first; the deprecated
// state-file-scoped `smoke_target` is the fallback, so an unmigrated state file
// still smokes.
// The app `cast smoke` targets, in ONE project of ONE environment — the only
// scope in which a bare application name is a coordinate at all. There is no
// 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
// became project-scoped — without it there is no project to look up and only
// the old key can answer, which is exactly what the old invocation did.
// `orgRepo` is required for the same reason: it is the project. Absence is not
// an error here — an environment may simply declare no smoke target — but the
// caller has to say so itself, and `smoke` does.
export function smokeTargetFor(
bindings: Bindings,
envName: string,
orgRepo?: string,
): { target: string; source: "project" | "deprecated" } | undefined {
const scoped = orgRepo
? 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;
orgRepo: string,
): string | undefined {
return projectBindingFor(bindings, envName, orgRepo)?.smoke_target;
}
// The `<org>/<repo>` slugs registered for one environment — the list a fleet
@ -389,7 +429,9 @@ export function loadBindings(
if (!result.success) {
// 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
// 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
.map((issue) => {
// 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 --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 smoke [<org>/<repo>] --env <env>
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>]
--state <dir> the state checkout holding environments.yaml, secrets/ and
@ -406,6 +406,65 @@ export function renderAbsentTarget(
].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.
//
// `--project` and `--environment` are coordinates for finding the target;
@ -1064,27 +1123,37 @@ async function main(): Promise<number> {
options: {
state: { type: "string" },
env: { type: "string" },
project: { type: "string" },
environment: { type: "string" },
instance: { type: "string" },
},
});
// Optional, unlike every other verb's — and only because the target used to
// be state-file-scoped, so `cast smoke --env prod` with no repo at all is
// what the runbook says today. Without it, only the deprecated key can
// answer; with it, the project-scoped one can.
const smokeRepo = positionals[0];
// smoke writes: it POSTs two env vars onto the live smoke_target app and
// deletes them again. That is a mutation, so it takes the assert like any
// other. Without it, a wrong-team token that happened to own an app of
// the same name would have that app written to instead.
if (!values.env) {
// REQUIRED, like every other verb's — because the repo IS the project, and
// the project is half of the only scope in which the target's name means
// anything (#29). `cast smoke --env prod` with no repo used to work by
// reading the state-file-scoped `smoke_target`, which named an application
// from a key that could not say which project or which environment it was
// in; that key is gone (see BindingsSchema), and so is the invocation.
const orgRepo = positionals[0];
const envName = values.env;
if (!orgRepo || !envName) {
console.error(USAGE);
return 2;
}
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 binding = bindings.environments[values.env];
const binding = bindings.environments[envName];
if (!binding) {
console.error(`environment ${values.env} not in environments.yaml`);
console.error(`environment ${envName} not in environments.yaml`);
return 2;
}
const { instance, client } = openCoolify(
@ -1092,61 +1161,69 @@ async function main(): Promise<number> {
values.instance,
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");
const team = await assertTeam(client, binding.team, values.env);
const team = await assertTeam(client, binding.team, envName);
console.log(`team ${formatTeam(team)}`);
const resolved = smokeTargetFor(bindings, values.env, smokeRepo);
if (!resolved) {
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",
];
const target = smokeTargetFor(bindings, envName, orgRepo);
if (!target) {
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",
"again — it has to be told which one. Name it under the project it belongs",
"to, and pass that repo:",
"again — it has to be told which one, under the project that owns it:",
"",
" environments:",
` ${values.env}:`,
` ${envName}:`,
" projects:",
` ${smokeRepo ?? "<org>/<repo>"}:`,
` ${orgRepo}:`,
" smoke_target: <the application's name>",
].join("\n"),
);
return 2;
}
if (resolved.source === "deprecated") {
console.warn(
`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\`.`,
// The fix for #29, and the whole of it: the target is resolved in the project
// and the environment it was DECLARED under — the same lookup every read-side
// 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;
}
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;
}
if (command === "team") {

View file

@ -18,10 +18,7 @@ function bindings(github_apps: Record<string, string>): Bindings {
} as Bindings;
}
function withProjects(
projects: Record<string, ProjectBinding>,
smoke_target?: string,
): Bindings {
function withProjects(projects: Record<string, ProjectBinding>): Bindings {
return {
environments: {
prod: {
@ -32,7 +29,6 @@ function withProjects(
staging: { server: "staging-box", team: { id: 0, name: "Root Team" } },
},
github_apps: {},
...(smoke_target ? { smoke_target } : {}),
} as Bindings;
}
@ -140,49 +136,36 @@ describe("projectBindingFor", () => {
});
describe("smokeTargetFor", () => {
it("prefers the project-scoped target", () => {
const b = withProjects(
{ "heavy-duty/incubator": { smoke_target: "core" } },
"old-target",
);
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toEqual({
target: "core",
source: "project",
it("resolves the project-scoped target", () => {
const b = withProjects({
"heavy-duty/incubator": { smoke_target: "core" },
});
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toBe("core");
});
// The state file mid-migration still has only the old key — it must keep
// 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", () => {
it("is undefined when the project declares no target", () => {
expect(
smokeTargetFor(withProjects({}), "prod", "heavy-duty/incubator"),
).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
// not express at all, since it named one app for the whole state file.
// Two projects, each with its own smoke target: the case the removed
// 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", () => {
const b = withProjects({
"heavy-duty/incubator": { smoke_target: "core" },
"acme/client-site": { smoke_target: "web" },
});
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")?.target).toBe(
"core",
);
expect(smokeTargetFor(b, "prod", "acme/client-site")?.target).toBe("web");
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toBe("core");
expect(smokeTargetFor(b, "prod", "acme/client-site")).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)", () => {
expect(() =>
loadBindings("environments.yaml", {

View file

@ -90,9 +90,11 @@ function stateWith(opts: {
" server: prod-box",
" team: { id: 0, name: Root Team }",
...(opts.boundInstance ? [` instance: ${opts.boundInstance}`] : []),
" projects:",
" heavy-duty/incubator:",
" smoke_target: core",
"github_apps:",
" incubator: hdb-coolify",
"smoke_target: core",
"",
].join("\n"),
);
@ -135,10 +137,20 @@ describe("infra cli", () => {
expect(r.output).toMatch(/--env/);
});
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.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)", () => {
@ -235,6 +247,7 @@ describe("--instance (multiple Coolify instances)", () => {
});
const r = await runCli([
"smoke",
"heavy-duty/incubator",
"--state",
dir,
"--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([]);
});
});