From 18660041f9552a402ecfba32806f99264efdb839 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 20:03:35 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20a=20project=20registry=20=E2=80=94?= =?UTF-8?q?=20the=20list=20of=20what=20exists=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit environments.yaml could say where things deploy to, and how a project you have already named is placed once it is there. It could not say which projects exist. "Every project" was a thing the operator remembered — so fleet operations (#26) had nothing to iterate, and rebuild-from-state (#27) was an assumption, since you cannot restore what you cannot enumerate. A new optional top-level block, keyed by the full / slug: projects: heavy-duty/incubator: environments: [prod, staging] The key IS the repo — no `repo:` field, because a second place to write the same string is a second place for it to be wrong. No bare- fallback, unlike github_apps and environments..projects: those carry one because state files in the wild are keyed that way, and this block has none to support. A bare is unique only within an org, which is why it is not a key (#12, twice learned). Validated in loadBindings, so every verb refuses a registry that lies: - an environment no `environments:` block defines is an error — the project would be registered into an environment no command can visit - every environments..projects. binding must be registered for that env, or the two blocks describe two different fleets: a destination or smoke_target real enough for a direct apply, invisible to every fleet run. Only enforced when `projects:` is present, so pre-registry state files keep loading unchanged. Both defend one failure: a silently skipped project reads exactly like a clean one. Errors render multi-line now — zod's own .message is the issue array as JSON, which flattened the refusals into a line of \n escapes. projectsIn(bindings, env) gives an environment's slugs, sorted; [] with no registry. The --all flag that consumes it is #26's, not here. Co-Authored-By: Claude Opus 4.8 --- README.md | 50 +++++++++ docs/semantics.md | 45 ++++++++ src/bindings.ts | 209 +++++++++++++++++++++++++++++++++++++- test/bindings.test.ts | 231 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 533 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f43a88..6441a00 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ environments.yaml # bindings: the team each env's token must belon # which server it deploys onto, the S3 destination, # GitHub App name, guards — and, per project, # the destination it deploys onto + its smoke target + # …plus `projects:`, the registry: which projects + # exist, and in which environments secrets/..env.age # age-encrypted values for the ${…} placeholders .coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit) .coolify/.env # …the same, for a NAMED instance (see below) @@ -357,6 +359,54 @@ own (it hangs off a project) and no API path scopes by one: **Coolify environments are an organizational construct, not an auth boundary.** The team is the only boundary there is, so it is the one cast asserts. +## The registry: which projects exist + +`environments.yaml` says where things deploy *to*, and how a project you have +already named is placed once it is there. Until the `projects:` block, nothing in +it said **which projects exist at all** — "every project" was a thing the +operator remembered: + +```yaml +projects: + heavy-duty/incubator: + environments: [prod, staging] + acme/client-site: + environments: [prod] +``` + +Keyed by the **full `/` slug**, and the key *is* the repo — there is +no `repo:` field inside, because a second place to write the same string is a +second place for it to be wrong. Unlike `github_apps`, a bare `` key is +**refused** rather than resolved: this block is new, so it has no state files in +the wild to keep working, and a bare `` is unique only *within* an org — +which is exactly why it is not a key. `environments:` lists **our** environment +names (the values `--env` takes), never Coolify's. + +The block is optional; a state file written before it loads unchanged. + +**It has to be true, so cast checks that it is — at parse time, for every verb.** +Two ways it could quietly stop being true, both refused: + +- an environment name that no `environments:` block defines (a typo). The project + is real and its environment imaginary, so a fleet run visits nothing for it, + reports nothing, and exits clean. +- an `environments..projects.` binding — a destination, a smoke target + — in an environment the registry does not register that project for. The two + blocks then describe two different fleets: state real enough for a direct + `cast apply` to use, invisible to every fleet run. (Checked only when + `projects:` is present.) + +Both refusals defend one failure: **a silently skipped project reads exactly like +a clean one.** Silence is the one report that must never be ambiguous. + +What it unlocks, neither of which was possible without a list to iterate: + +- **fleet operations** — `cast diff --all` / `apply --all` over every project in + an environment. +- **rebuild-from-state** — "restore this Coolify from the state repo" cannot even + be *attempted* without knowing what was on it. The registry is the difference + between a documented recovery and an archaeology exercise. + ## Two projects, one box: destinations A **destination** is the Docker network a resource is created on. A server has a diff --git a/docs/semantics.md b/docs/semantics.md index 409bd34..668b284 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -158,6 +158,51 @@ softened by an implementation detail): refuses `--path` combined with `--env prod`: prod always reads the default branch, so a feature-branch checkout can never reach it. +## The registry (`projects:`) + +The top-level `projects:` block is the list of **which projects exist**, and in +which environments. It is the only place that says so: `environments:` says where +things deploy to, `environments..projects.` says how an already-named +project is placed, `github_apps` says how to clone one you have already named. + +```yaml +projects: + heavy-duty/incubator: + environments: [prod, staging] +``` + +- **Keyed by the full `/` slug, with no bare-`` fallback.** The + key is the repo; there is no `repo:` field. `github_apps` and + `environments..projects` accept a bare key because state files in the wild + are written that way; this block is new and has none, so it requires the slug — + a bare `` is unique only *within* an org. +- **`environments:` names OUR environments** — the keys of the `environments:` + block, the values `--env` takes — never Coolify's. Non-empty. +- **Optional.** A state file with no `projects:` block loads unchanged, and + `projectsIn` reports `[]` for every environment. + +**Validated at parse time, so every verb refuses a registry that lies.** Two +refusals, both defending the same failure — *a silently skipped project reads +exactly like a clean one*, which makes silence, the most common report there is, +ambiguous: + +1. **An environment that does not exist** (a typo in `projects..environments`) + is an error naming the unknown environment and listing the known ones. Left + alone, the project would be registered into an environment no command can + visit: a fleet run skips it, reports nothing, exits clean. +2. **A binding the registry does not register.** Every + `environments..projects.` key must be a project the registry + registers *for that environment*. Otherwise the two blocks describe two + different fleets: a `destination_uuid` or `smoke_target` real enough for a + direct `cast apply --env ` to act on, and invisible to every + fleet run over that environment. Enforced **only when `projects:` is + present**, so pre-registry state files keep loading. + +The registry is what makes two things possible, neither of which can be attempted +without a list to iterate: **fleet operations** (`--all`), and +**rebuild-from-state** — restoring a Coolify from the state repo, which is +otherwise an assumption, since you cannot restore what you cannot enumerate. + ## Placement (destinations) A **destination** is the Docker network a resource is created on. It is declared diff --git a/src/bindings.ts b/src/bindings.ts index c414cc0..c0ff16b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -58,6 +58,32 @@ const ProjectBindingSchema = z export type ProjectBinding = z.infer; +// One project in the registry — the top-level `projects:` block, which is the +// list of what EXISTS. Nothing else in this file says that: `environments:` +// says where things are deployed to, `environments..projects.` says +// how one project is placed once you already know it is there, and +// `github_apps` says how to clone one you have already named. "Every project" +// was, until this block, a thing the operator remembered. +// +// Two things need the list, and neither can be built without it: fleet +// operations (`cast diff --all`, #26 — iterating "every project in this +// environment") and rebuild-from-state (#27 — a Coolify restored from the state +// repo, which cannot even be attempted without knowing what was on it). +const RegisteredProjectSchema = z + .object({ + // OUR environment names — the values `--env` takes, the keys of the + // `environments:` block above — never Coolify's. The distinction is the same + // one `--env` vs `--environment` draws everywhere else in cast. + // + // Non-empty: a project registered into no environment is not a registration, + // it is a line of YAML that reads like one. It would be skipped by every + // fleet run silently. + environments: z.array(z.string().min(1)).nonempty(), + }) + .strict(); + +export type RegisteredProject = z.infer; + const BindingsSchema = z .object({ environments: z.record( @@ -100,6 +126,22 @@ const BindingsSchema = z }) .strict(), ), + // THE REGISTRY: which projects exist at all, keyed by the full `/` + // slug. The key IS the repo — there is no `repo:` field inside, because a + // second place to write the same string is a second place for it to be + // wrong. + // + // Full slug REQUIRED, with no bare-`` fallback — the one place in this + // file where that fallback does not exist. `github_apps` and + // `environments..projects` carry one because they predate the lesson + // (#12) and there are state files in the wild keyed the old way; this block + // is new, has no such files, and so gets to be right from the start. A bare + // `` is unique only *within* an org, which is precisely why it is not + // a key. + // + // Optional: a state file written before the registry existed keeps loading + // untouched, and `projectsIn` answers `[]` for it. + projects: z.record(RegisteredProjectSchema).optional(), // Keyed by the repo the App clones for. Prefer the FULL `/` // slug; a bare `` key still resolves (see githubAppNameFor) so // existing state files keep working. @@ -112,7 +154,136 @@ const BindingsSchema = z // cannot distinguish prod's app from staging's either. smoke_target: z.string().optional(), }) - .strict(); + .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. + .superRefine((bindings, ctx) => { + const registry = bindings.projects; + if (!registry) return; + + const knownEnvs = Object.keys(bindings.environments); + const knownEnvList = knownEnvs.join(", ") || "(none)"; + + for (const [slug, project] of Object.entries(registry)) { + // A key with no `/` is not a repo. See the schema note above: no fallback. + if (!slug.includes("/")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["projects", slug], + message: [ + `projects["${slug}"] is not a repo — a registry key has no meaning without its org`, + "", + ` found: projects["${slug}"]`, + " wanted: a full / slug", + "", + "A bare is unique only *within* an org: heavy-duty/incubator and", + "acme/incubator collapse onto one entry, and the registry then claims one", + "project where there are two. Unlike github_apps, this block is new and has", + "no legacy state files to support, so there is no bare- fallback.", + "", + " projects:", + ` /${slug}:`, + ` environments: [${project.environments.join(", ")}]`, + ].join("\n"), + }); + } + + // Every environment named here must be one that actually exists. A typo + // makes the project real but its environment imaginary — so `--all` visits + // nothing for it, reports nothing about it, and exits clean. + for (const envName of project.environments) { + if (!(envName in bindings.environments)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["projects", slug, "environments"], + message: [ + `projects["${slug}"] is registered in environment "${envName}", which does not exist`, + "", + ` registered: projects["${slug}"].environments → ${project.environments.join(", ")}`, + ` known envs: ${knownEnvList}`, + "", + "An environment nothing defines is one that no command can visit: a fleet", + "run would skip this project, and a silently skipped project reads exactly", + "like a clean one. Fix the name, or declare the environment:", + "", + " environments:", + ` ${envName}:`, + " server: ", + " team: { id: , name: }", + ].join("\n"), + }); + } + } + } + + // The other direction, and the one that rots quietly: a project carrying + // per-environment state (destination_uuid, smoke_target — #21) in an + // environment the registry does not register it for. That state is then real + // enough to be used by a direct `cast apply --env ` and invisible + // to every fleet run — the two blocks describing two different fleets. + // + // Only checked when `projects:` is present, so state files written before the + // registry keep loading exactly as they did. + for (const [envName, env] of Object.entries(bindings.environments)) { + for (const key of Object.keys(env.projects ?? {})) { + const entry = registry[key]; + if (entry?.environments.includes(envName)) continue; + + // The likeliest cause, worth saying out loud: a legacy bare- key + // (which projectBindingFor still resolves) under a registry that is + // correctly keyed by slug. The fix is a rename, not a registration. + const slugFor = key.includes("/") + ? undefined + : Object.keys(registry).find((s) => s.endsWith(`/${key}`)); + + const cause = slugFor + ? [ + ` registry has: projects["${slugFor}"]`, + "", + "The binding uses the legacy bare- key. The registry is keyed by the", + `full slug, so rename it to match — environments.${envName}.projects["${slugFor}"].`, + ] + : entry + ? [ + ` registered for: ${entry.environments.join(", ")}`, + "", + "Register the project in this environment, or drop the binding — state that", + "no fleet run will ever visit is state that stops being true without anyone", + "finding out:", + "", + " projects:", + ` ${key}:`, + ` environments: [${[...entry.environments, envName].join(", ")}]`, + ] + : [ + ` registry has: ${Object.keys(registry).join(", ") || "(nothing)"}`, + "", + "The project is not in the registry at all, so no fleet run will ever visit", + "it — while this binding says it is deployed here. Register it, or drop the", + "binding:", + "", + " projects:", + ` ${key}:`, + ` environments: [${envName}]`, + ]; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["environments", envName, "projects", key], + message: [ + `environments.${envName}.projects["${key}"] is bound in an environment the registry does not register it for`, + "", + ` bound at: environments.${envName}.projects["${key}"]`, + ...cause, + ].join("\n"), + }); + } + } + }); export type Bindings = z.infer; @@ -188,6 +359,27 @@ export function smokeTargetFor( return undefined; } +// The `/` slugs registered for one environment — the list a fleet +// operation iterates (`cast diff --all`, #26) and a rebuild reads (#27). +// +// SORTED, deliberately: the order of keys in a YAML file is an accident of who +// typed what when, and a fleet run's output — which a human reads top to bottom, +// and CI diffs — must not reshuffle because someone appended a project. The +// registry is a set; this returns it as one. +// +// `[]` when there is no registry, which is every state file written before this +// block existed. That is the honest answer for "which projects are registered +// here" when nothing is registered anywhere — and it makes a fleet verb over an +// unmigrated state file a clean no-op rather than a crash. +export function projectsIn(bindings: Bindings, envName: string): string[] { + const registry = bindings.projects; + if (!registry) return []; + return Object.entries(registry) + .filter(([, project]) => project.environments.includes(envName)) + .map(([slug]) => slug) + .sort(); +} + export function loadBindings( path: string, opts: { overrideText?: string } = {}, @@ -195,7 +387,20 @@ export function loadBindings( const text = opts.overrideText ?? readFileSync(path, "utf8"); const result = BindingsSchema.safeParse(parse(text)); if (!result.success) { - throw new Error(`invalid bindings ${path}: ${result.error.message}`); + // 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. + const detail = result.error.issues + .map((issue) => { + // A multi-line message is one WE wrote: it already names the path, the + // cause, and the YAML to write. A one-line message is zod's ("Required"), + // and is useless without the path it happened at. + if (issue.message.includes("\n")) return issue.message; + const where = issue.path.map(String).join("."); + return where ? `${where}: ${issue.message}` : issue.message; + }) + .join("\n\n"); + throw new Error(`invalid bindings ${path}:\n\n${detail}`); } return result.data; } diff --git a/test/bindings.test.ts b/test/bindings.test.ts index e9cace5..b38c3c0 100644 --- a/test/bindings.test.ts +++ b/test/bindings.test.ts @@ -5,6 +5,7 @@ import { githubAppNameFor, loadBindings, projectBindingFor, + projectsIn, smokeTargetFor, } from "../src/bindings.js"; @@ -223,3 +224,233 @@ github_apps: {} ).toThrow(/invalid bindings/); }); }); + +// The registry: the list of which projects exist at all. Everything it is FOR +// (fleet iteration, rebuild-from-state) depends on it being true, and the way it +// stops being true is silent — see the refusals below. +describe("the project registry", () => { + const twoEnvs = ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + staging: + server: staging-box + team: { id: 0, name: Root Team } +`; + + it("registers projects per environment, keyed by the full slug", () => { + const b = loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [prod, staging] + acme/client-site: + environments: [prod] +github_apps: {} +`, + }); + expect(b.projects).toEqual({ + "heavy-duty/incubator": { environments: ["prod", "staging"] }, + "acme/client-site": { environments: ["prod"] }, + }); + }); + + describe("projectsIn", () => { + const b = loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [prod, staging] + acme/client-site: + environments: [prod] +github_apps: {} +`, + }); + + // Sorted, not file-order: a fleet run's output is read by a human and diffed + // by CI, and must not reshuffle because someone appended a project. + it("gives an environment's projects, sorted", () => { + expect(projectsIn(b, "prod")).toEqual([ + "acme/client-site", + "heavy-duty/incubator", + ]); + }); + + it("gives only the projects registered for that environment", () => { + expect(projectsIn(b, "staging")).toEqual(["heavy-duty/incubator"]); + }); + + it("is empty for an environment no project is registered in", () => { + expect(projectsIn(b, "nowhere")).toEqual([]); + }); + }); + + // The refusal the issue is actually about. A typo'd environment name makes the + // project real and its environment imaginary: `cast diff --all` visits nothing + // for it, reports nothing, and exits clean — and a silently skipped project + // reads exactly like a clean one. + it("refuses an environment that does not exist, naming the ones that do", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [prod, stagng] +github_apps: {} +`, + }); + expect(err).toThrow(/environment "stagng", which does not exist/); + expect(err).toThrow(/known envs:\s+prod, staging/); + }); + + // The other direction, and the one that rots quietly: per-environment state + // (#21) sitting in an environment the registry does not register the project + // for. The two blocks then describe two different fleets. + it("refuses a binding in an environment the registry does not register", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + staging: + server: staging-box + team: { id: 0, name: Root Team } + projects: + heavy-duty/incubator: + destination_uuid: dest-abc +projects: + heavy-duty/incubator: + environments: [prod] +github_apps: {} +`, + }); + expect(err).toThrow( + /environments\.staging\.projects\["heavy-duty\/incubator"\]/, + ); + expect(err).toThrow(/registered for:\s+prod/); + }); + + it("refuses a binding for a project the registry does not carry at all", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + projects: + acme/client-site: + destination_uuid: dest-client +projects: + heavy-duty/incubator: + environments: [prod] +github_apps: {} +`, + }); + expect(err).toThrow(/environments\.prod\.projects\["acme\/client-site"\]/); + expect(err).toThrow(/registry has:\s+heavy-duty\/incubator/); + }); + + // A legacy bare- binding key (projectBindingFor still resolves one) under + // a slug-keyed registry is drift with an obvious fix — say which fix. + it("tells a legacy bare- binding key which slug to rename to", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + projects: + incubator: + smoke_target: core +projects: + heavy-duty/incubator: + environments: [prod] +github_apps: {} +`, + }); + expect(err).toThrow(/registry has:\s+projects\["heavy-duty\/incubator"\]/); + expect(err).toThrow(/legacy bare- key/); + }); + + // No fallback here, unlike github_apps: this block is new, so it has no state + // files in the wild to keep working, and a bare is unique only within an + // org — which is exactly why it is not a key. + it("refuses a bare registry key — the org is not optional", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + incubator: + environments: [prod] +github_apps: {} +`, + }); + expect(err).toThrow(/projects\["incubator"\] is not a repo/); + expect(err).toThrow(/full \/ slug/); + }); + + // A project registered into nothing is a line of YAML that reads like a + // registration and is skipped by every fleet run. + it("refuses a project registered into no environment", () => { + expect(() => + loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [] +github_apps: {} +`, + }), + ).toThrow(/invalid bindings/); + }); + + it("rejects an unknown key inside a registry entry", () => { + expect(() => + loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [prod] + repo: heavy-duty/incubator +github_apps: {} +`, + }), + ).toThrow(/invalid bindings/); + }); + + // Back-compat: the registry is optional, and every state file written before it + // existed has no `projects:` block. Such a file loads unchanged — including its + // per-environment bindings, which are NOT checked against a registry that is + // not there. + describe("with no registry at all", () => { + const b = loadBindings("environments.yaml", { + overrideText: ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + projects: + heavy-duty/incubator: + destination_uuid: dest-abc + smoke_target: core +github_apps: {} +`, + }); + + it("loads, and keeps its per-environment bindings working", () => { + expect(b.projects).toBe(undefined); + expect( + projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid, + ).toBe("dest-abc"); + }); + + it("has no projects registered in any environment", () => { + expect(projectsIn(b, "prod")).toEqual([]); + }); + }); +}); -- 2.45.2 From b07563a81568778b9a31e93bd71614473e946d99 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 20:06:29 +0000 Subject: [PATCH 2/2] fix: smoke resolves its target inside the project it was declared under (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The / positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 --- README.md | 31 ++-- src/bindings.ts | 98 +++++++---- src/cli.ts | 177 ++++++++++++++------ test/bindings.test.ts | 78 +++++---- test/cli.test.ts | 17 +- test/smoke-cli.test.ts | 365 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 640 insertions(+), 126 deletions(-) create mode 100644 test/smoke-cli.test.ts 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([]); + }); +}); -- 2.45.2