diff --git a/README.md b/README.md index e84ce29..610ea3c 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,13 @@ cast team [--env ] default branch). - **`diff`** — reports drift, manifest → Coolify. Structural by default; `--full` also compares env vars. Exits non-zero when dirty, so CI can gate on it. -- **`inventory`** — what is actually *on* a box, and how it lines up with the - manifest: resources and env var **keys** (never values), sorted into - on-both / manifest-only / box-only. Needs no store, no age key, and no - recipient — it runs *before* adoption, which is the point of it. A document, - read by a person; nothing here is consumed by `apply`. See *Adopting a - hand-built instance*. +- **`inventory`** — what is actually *on* a box. **With no repo it sweeps the + instance** (every project, every environment, every resource — no manifest + involved); with a repo it reconciles, showing resources and env var **keys** + (never values) sorted into on-both / manifest-only / box-only. Needs no store, + no age key, and no recipient — it runs *before* adoption, which is the point of + it. A document, read by a person; nothing here is consumed by `apply`. See + *Adopting a hand-built instance*. - **`capture`** — the adoption path: reads a hand-built instance's live env and writes the environment's age store from it. See *Adopting a hand-built instance* below. @@ -173,9 +174,40 @@ clicked *New Resource* that afternoon. `inventory` shows you both sides at once, so those differences arrive together, as a document — instead of one at a time, as refusals from a verb that is already halfway through a migration. +**First, sweep it — you cannot aim at coordinates you do not have yet:** + +```sh +cast inventory --env prod --instance legacy +``` + +``` +sweep — instance legacy (https://coolify.example.com) + + Incubator + production (empty) + staging 2 applications, 2 databases, 1 service + application Incubator Stack v2 + application Incubator Landing + database Incubator Database v2 + … + La Familia Site + production 1 application + application lafamilia-web +``` + +Note what that costs you to *not* have: Coolify auto-creates a `production` +environment in every project, so the obvious guess is empty and the live system +is somewhere else entirely — under a name someone typed, in a project you may +not have known was there. An environment with **zero** resources is far more +often the wrong coordinate than an empty one, and `inventory` says so rather than +quietly reporting that the manifest has five things the box lacks. + +**Then reconcile**, against a target you now know exists: + ```sh cast inventory heavy-duty/incubator --env prod --instance legacy \ - --project Incubator --environment production + --project Incubator --environment staging \ + --resource core="Incubator Stack v2" ``` It never reads a value, needs no store and no key, and its output is **not** diff --git a/src/cli.ts b/src/cli.ts index ea06016..4526243 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,14 @@ import { renderDiff, } from "./diff.js"; import { assertEnvVarPolicy } from "./envtemplate.js"; -import { type LiveResource, reconcile, renderInventory } from "./inventory.js"; +import { + type LiveResource, + type SweepEnvironment, + type SweepProject, + reconcile, + renderInventory, + renderSweep, +} from "./inventory.js"; import { desiredFromManifest, manifestResources, @@ -48,7 +55,8 @@ import { assertTeam, formatTeam } from "./team.js"; const USAGE = `usage: cast apply / --env [--path ] [--project ] [--environment ] [--hostname-overlay ] cast diff / --env [--full] [--project ] [--environment ] cast capture / --env [--path ] [--project ] [--environment ] [--generated ] [--override ] [--force] - cast inventory / --env [--path ] [--project ] [--environment ] + 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 team [--env ] @@ -863,11 +871,55 @@ async function main(): Promise { }); const orgRepo = positionals[0]; const envName = values.env; - if (!orgRepo || !envName) { + if (!envName) { console.error(USAGE); return 2; } const stateDir = stateDirFrom(values.state); + const sweepBindings = loadBindings(join(stateDir, "environments.yaml")); + const sweepBinding = sweepBindings.environments[envName]; + if (!sweepBinding) { + console.error(`environment ${envName} not in environments.yaml`); + return 2; + } + // NO REPO → SWEEP. There is nothing to reconcile against, so don't pretend + // to: just show what is on the box. This is the pass that has to come first + // when the box is one you did not build, and requiring coordinates for it + // made inventory a discovery verb that needed you to have already + // discovered. + if (!orgRepo) { + const { instance, client } = openCoolify( + stateDir, + values.instance, + sweepBinding, + ); + // The team assert matters MORE here than anywhere: Coolify scopes what a + // token can see to its team, so a wrong-team token sweeps an instance and + // truthfully reports that it is empty. + const team = await assertTeam(client, sweepBinding.team, envName); + console.log(`team ${formatTeam(team)} ✓`); + const projects: SweepProject[] = []; + for (const p of await client.projects()) { + const environments: SweepEnvironment[] = []; + for (const name of await client.environments(p.uuid)) { + const found = await fetchLive(client, p.name, name); + environments.push({ + name, + resources: found.found + ? found.live.map((l) => ({ kind: l.kind, name: l.name })) + : [], + }); + } + projects.push({ name: p.name, environments }); + } + console.log( + renderSweep(projects, { + instance: instance.name, + baseUrl: instance.baseUrl, + }), + ); + return 0; + } const repoShort = orgRepo.split("/")[1]; const projectName = values.project ?? repoShort; const coolifyEnv = values.environment ?? envName; diff --git a/src/coolify.ts b/src/coolify.ts index 6bbd117..364d94b 100644 --- a/src/coolify.ts +++ b/src/coolify.ts @@ -109,6 +109,44 @@ export class CoolifyClient { this.resolve("github app", "/github-apps", name); projectUuid = (name: string) => this.resolve("project", "/projects", name); + // Every project the TOKEN can see. Team-scoped by Coolify itself, which is + // why a sweep still asserts the team first: a wrong-team token sees nothing, + // and "nothing" would render as "this instance is empty". + async projects(): Promise> { + return (await this.get("/projects")) as Array<{ + uuid: string; + name: string; + }>; + } + + // A project's environment NAMES. + // + // Two roads, because the vendored OpenAPI has been wrong before and this is a + // discovery path — the one place where failing to enumerate is worse than + // being slow. GET /projects/{uuid}/environments is documented; if a given + // instance does not serve it, GET /projects/{uuid} carries the same list as a + // relation on the project (ProjectController@show eager-loads it). Falling + // back beats reporting "no environments" for a project that has several. + async environments(projectUuid: string): Promise { + const names = (items: unknown): string[] => + Array.isArray(items) + ? items + .map((e) => (e as { name?: unknown })?.name) + .filter((n): n is string => typeof n === "string") + : []; + try { + const direct = await this.get(`/projects/${projectUuid}/environments`); + const found = names(direct); + if (found.length > 0) return found; + } catch (err) { + if (!(err instanceof HttpError) || err.status !== 404) throw err; + } + const project = (await this.get(`/projects/${projectUuid}`)) as { + environments?: unknown; + } | null; + return names(project?.environments); + } + async deploy(uuid: string): Promise { await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`); } diff --git a/src/inventory.ts b/src/inventory.ts index ede0d11..0f952a1 100644 --- a/src/inventory.ts +++ b/src/inventory.ts @@ -54,6 +54,75 @@ export type Reconciliation = { const sorted = (xs: Iterable) => [...xs].sort(); +// --- The sweep: what is on this box, before any manifest is involved --- +// +// The verb's premise is that you are looking at a box you did not build, so you +// do NOT know its coordinates yet. Requiring a project and an environment to +// look at it made it a discovery tool that needed you to have already +// discovered — and the operator went back to hand-curling /projects to find out +// where anything lived. This is that pass, and it needs no manifest at all. + +export type SweepEnvironment = { + name: string; + resources: Array<{ kind: string; name: string }>; +}; + +export type SweepProject = { + name: string; + environments: SweepEnvironment[]; +}; + +export function renderSweep( + projects: SweepProject[], + ctx: { instance: string; baseUrl: string }, +): string { + const lines = [ + `sweep — instance ${ctx.instance} (${ctx.baseUrl})`, + "", + " Every project, every environment, every resource this token can see.", + " No manifest involved: this is what is HERE, not how it compares to anything.", + "", + ]; + if (projects.length === 0) { + lines.push( + " (no projects at all)", + "", + " An instance with no projects is more often a token that cannot see them", + " than an empty instance — the team assert above is what rules that out.", + ); + return lines.join("\n"); + } + for (const p of projects) { + lines.push(` ${p.name}`); + if (p.environments.length === 0) { + lines.push(" (no environments)"); + } + for (const e of p.environments) { + const counts = Object.entries( + e.resources.reduce>((acc, r) => { + acc[r.kind] = (acc[r.kind] ?? 0) + 1; + return acc; + }, {}), + ) + .map(([kind, n]) => `${n} ${kind}${n === 1 ? "" : "s"}`) + .join(", "); + // An empty environment is worth seeing, not hiding: Coolify auto-creates + // `production` in every project, and an operator who assumes that is where + // things live will aim every later command at nothing. + lines.push(` ${e.name.padEnd(14)} ${counts || "(empty)"}`); + for (const r of e.resources) { + lines.push(` ${r.kind.padEnd(12)} ${r.name}`); + } + } + lines.push(""); + } + lines.push( + "Point a reconciliation at one of these with --project / --environment, and", + "map any resource whose name differs with --resource =.", + ); + return lines.join("\n"); +} + export function reconcile( manifest: ManifestResource[], live: LiveResource[], @@ -98,6 +167,38 @@ export function renderInventory( environment: string; }, ): string { + // The box has NOTHING in it. Not "nothing that matched" — nothing at all. + // + // This is the third face of the D-237 lie, and the quietest: an environment + // with zero resources reads back exactly like a box you have not built yet, + // and the report then consists entirely of the manifest talking to itself. + // The overall impression — "the box has nothing, the manifest has five things" + // — is how a full-create plan gets laundered into a pass. It happened: pointed + // at a project's auto-created `production` environment, this verb reported + // five differences against a box whose resources were alive and serving + // production the whole time, in an environment named `staging`. + if (rec.matched.length === 0 && rec.boxOnly.length === 0) { + return [ + `inventory — ${ctx.orgRepo} ${ctx.env}`, + "", + ` looked in: project "${ctx.project}", environment "${ctx.environment}"`, + " found: NOTHING. Not one resource.", + "", + "This environment is EMPTY — so there is nothing here to reconcile, and the", + "manifest's list below would just be the manifest talking to itself.", + "", + "An environment with zero resources is far more often the WRONG COORDINATE", + "than an empty one. Coolify auto-creates a `production` environment in every", + "project, and a box built by hand keeps its real resources wherever someone", + "put them — which may well be an environment called something else entirely.", + "", + "Sweep the instance and see where things actually are:", + "", + ` cast inventory --env ${ctx.env} --instance ${ctx.instance}`, + "", + `(The manifest declares: ${rec.manifestOnly.map((m) => m.name).join(", ")}.)`, + ].join("\n"); + } const lines = [ `inventory — ${ctx.orgRepo} ${ctx.env}`, "", diff --git a/test/sweep-cli.test.ts b/test/sweep-cli.test.ts new file mode 100644 index 0000000..abd004e --- /dev/null +++ b/test/sweep-cli.test.ts @@ -0,0 +1,212 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, 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"; + +// The sweep, against a stub shaped like the box that made it necessary. +// +// Three projects — one ours, two unrelated third-party client sites nobody knew +// were there. Our project has TWO environments: `production`, which Coolify +// auto-created and which is EMPTY, and `staging`, which is where the live +// founder-facing system has been running the whole time because nobody ever +// swapped it. +// +// Pointed at `production`, the old inventory reported "5 differences" against a +// box whose resources were alive and serving production — the manifest talking +// to itself, and an impression ("the box has nothing") that is exactly how a +// full-create plan gets laundered into a pass. + +type Stub = { url: string; close: () => Promise }; +const stubs: Stub[] = []; + +async function stubCoolify(): Promise { + const server = createServer((req, res) => { + const path = (req.url ?? "").replace("/api/v1", ""); + const json = (body: unknown) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (path === "/teams/current") return json({ id: 0, name: "Root Team" }); + if (path === "/projects") + return json([ + { uuid: "p1", name: "Incubator" }, + { uuid: "p2", name: "La Familia Site" }, + { uuid: "p3", name: "Martin Reyes Barber Shop" }, + ]); + if (path === "/projects/p1/environments") + return json([{ name: "production" }, { name: "staging" }]); + if (path === "/projects/p2/environments") + return json([{ name: "production" }]); + if (path === "/projects/p3/environments") + return json([{ name: "production" }]); + // Coolify auto-creates `production`. It is empty. Everything real lives in + // `staging`, under names a human typed. + if (path === "/projects/p1/production") return json({}); + if (path === "/projects/p1/staging") + return json({ + applications: [ + { name: "Incubator Stack v2", uuid: "a1" }, + { name: "Incubator Landing", uuid: "a2" }, + ], + postgresqls: [{ name: "Incubator Database v2", uuid: "d1" }], + services: [{ name: "Incubator Umami", uuid: "s1" }], + }); + if (path === "/projects/p2/production") + return json({ applications: [{ name: "lafamilia-web", uuid: "a9" }] }); + if (path === "/projects/p3/production") + return json({ applications: [{ name: "barber-web", uuid: "a8" }] }); + if (path.endsWith("/envs")) return json([]); + 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}`, + close: () => + new Promise((r) => { + server.close(() => r()); + }), + }; + stubs.push(stub); + return stub; +} + +afterEach(async () => { + await Promise.all(stubs.splice(0).map((s) => s.close())); +}); + +const MANIFEST = `project: incubator +environments: + staging: + applications: + core: + source: { repo: heavy-duty/incubator, branch: main } + build: { pack: nixpacks, base_directory: / } + domains: ["http://core.example.com"] +`; + +function fixture(url: string) { + const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); + mkdirSync(join(checkout, ".infra", "env"), { recursive: true }); + writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST); + const state = mkdtempSync(join(tmpdir(), "cast-state-")); + mkdirSync(join(state, "secrets")); + writeFileSync( + join(state, ".coolify.env"), + `COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`, + ); + writeFileSync( + join(state, "environments.yaml"), + [ + "environments:", + " staging:", + " server: staging-box", + " team: { id: 0, name: Root Team }", + "github_apps:", + " incubator: hdb-coolify", + "", + ].join("\n"), + ); + return { checkout, state }; +} + +function run(args: string[]): Promise<{ code: number; output: string }> { + return new Promise((resolve) => { + const child = spawn("node", ["dist/cli.js", "inventory", ...args], { + stdio: ["pipe", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (d) => { + output += String(d); + }); + child.stderr.on("data", (d) => { + output += String(d); + }); + child.stdin.end(); + child.on("close", (code) => resolve({ code: code ?? 0, output })); + }); +} + +describe("cast inventory — the sweep (#22)", () => { + it("with no repo, shows every project, environment and resource", async () => { + const f = fixture((await stubCoolify()).url); + const r = await run(["--env", "staging", "--state", f.state]); + expect(r.code).toBe(0); + + // Every project the token can see — including the two nobody knew were on + // the box. (On the real instance, that discovery is what stopped a plan + // whose next step would have deleted the box.) + expect(r.output).toContain("Incubator"); + expect(r.output).toContain("La Familia Site"); + expect(r.output).toContain("Martin Reyes Barber Shop"); + + // Both environments, and the empty one is SHOWN, not hidden — an operator + // who assumes `production` is where things live aims every later command at + // nothing. + expect(r.output).toContain("production"); + expect(r.output).toContain("(empty)"); + expect(r.output).toContain("staging"); + + // And the resources, under the names the box actually uses. + expect(r.output).toContain("Incubator Stack v2"); + expect(r.output).toContain("Incubator Database v2"); + }); + + it("needs no repo, no manifest, no store — it runs before adoption exists", async () => { + const f = fixture((await stubCoolify()).url); + // No org/repo positional at all: nothing is cloned, no manifest is read. + const r = await run(["--env", "staging", "--state", f.state]); + expect(r.code).toBe(0); + expect(r.output).toContain("No manifest involved"); + }); + + it("still asserts the team — a wrong-team token would sweep an empty instance", async () => { + const f = fixture((await stubCoolify()).url); + writeFileSync( + join(f.state, "environments.yaml"), + [ + "environments:", + " staging:", + " server: staging-box", + " team: { id: 9, name: Some Other Team }", + "", + ].join("\n"), + ); + const r = await run(["--env", "staging", "--state", f.state]); + // Coolify scopes what a token can see to its team, so an unasserted sweep + // would truthfully report that the instance is empty. + expect(r.code).not.toBe(0); + expect(r.output).not.toContain("La Familia Site"); + }); +}); + +describe("an empty environment shouts (#22)", () => { + it("says NOTHING is here, and names the sweep — not '5 differences'", async () => { + const f = fixture((await stubCoolify()).url); + const r = await run([ + "heavy-duty/incubator", + "--env", + "staging", + "--state", + f.state, + "--path", + f.checkout, + "--project", + "Incubator", + "--environment", + "production", // auto-created by Coolify, and empty + ]); + expect(r.code).toBe(0); + expect(r.output).toContain("NOTHING"); + expect(r.output).toContain("WRONG COORDINATE"); + expect(r.output).toContain("cast inventory --env staging"); + // NOT the old output, whose overall impression was "the box has nothing and + // the manifest has five things" — a full-create plan in all but name. + expect(r.output).not.toContain("difference(s) between the manifest"); + }); +});