diff --git a/README.md b/README.md index 6441a00..eda135a 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ cast diff / --env [--full] cast capture / --env [--generated ] [--override ] cast inventory / --env cast server add --ip --key --env [--user root] [--port 22] -cast smoke [/] --env +cast smoke / --env [--project ] [--environment ] cast team [--env ] ``` @@ -93,9 +93,13 @@ cast team [--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 ` | the environment isn't named after `--env` (Coolify's default is `production`, not `prod`) | | `--resource =` | 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, diff --git a/src/bindings.ts b/src/bindings.ts index c0ff16b..56a77f7 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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 `` key still resolves (see githubAppNameFor) so // existing state files keep working. github_apps: z.record(z.string()), - // DEPRECATED — moved to environments..projects..smoke_target. - // Still read (see smokeTargetFor) so state files written before the move - // keep working, on the same reasoning as the bare-`` 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:", + " :", + " projects:", + " /:", + ` smoke_target: ${bindings.smoke_target}`, + "", + " cast smoke / --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 `/` 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 diff --git a/src/cli.ts b/src/cli.ts index 99c97bd..f285dde 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -63,7 +63,7 @@ const USAGE = `usage: cast apply / --env [--path ] [-- cast inventory / --env [--path ] [--project ] [--environment ] [--resource =] cast inventory --env [--instance ] # no repo: SWEEP the whole instance cast server add --ip --key --env [--user root] [--port 22] - cast smoke [/] --env + cast smoke / --env [--project ] [--environment ] cast team [--env ] --state 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//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 { 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 { 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 / 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 ?? "/"}:`, + ` ${orgRepo}:`, " smoke_target: ", ].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./.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") { diff --git a/test/bindings.test.ts b/test/bindings.test.ts index b38c3c0..e51449e 100644 --- a/test/bindings.test.ts +++ b/test/bindings.test.ts @@ -18,10 +18,7 @@ function bindings(github_apps: Record): Bindings { } as Bindings; } -function withProjects( - projects: Record, - smoke_target?: string, -): Bindings { +function withProjects(projects: Record): 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-`` 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 \/ --env /); + }); + it("rejects an unknown key under a project (a typo is not a placement)", () => { expect(() => loadBindings("environments.yaml", { diff --git a/test/cli.test.ts b/test/cli.test.ts index d15b20a..bdc99b5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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 / positional, exit non-zero", async () => { + const r = await runCli(["smoke", "--env", "prod"]); + expect(r.code).toBe(2); + expect(r.output).toMatch(/cast smoke\s+\//); + }); }); 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", diff --git a/test/smoke-cli.test.ts b/test/smoke-cli.test.ts new file mode 100644 index 0000000..abcc068 --- /dev/null +++ b/test/smoke-cli.test.ts @@ -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; +}; +const stubs: Stub[] = []; + +type EnvVar = { key: string; value: string; is_buildtime: boolean }; + +async function stubCoolify(): Promise { + 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> = { + "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((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((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 = { + "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//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: "); + expect(stub.writes).toEqual([]); + }); +});