Merge pull request #31 from claude-hdb/fix/smoke-project-scoped
smoke resolves its target inside the project it was declared under (#29)
This commit is contained in:
commit
637028dc28
6 changed files with 640 additions and 126 deletions
31
README.md
31
README.md
|
|
@ -68,7 +68,7 @@ cast diff <org>/<repo> --env <env> [--full]
|
|||
cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
|
||||
cast inventory <org>/<repo> --env <env>
|
||||
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
|
||||
cast smoke [<org>/<repo>] --env <env>
|
||||
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
|
||||
cast team [--env <env>]
|
||||
```
|
||||
|
||||
|
|
@ -93,9 +93,13 @@ cast team [--env <env>]
|
|||
- **`smoke`** — contract test against the project's `smoke_target`: proves
|
||||
Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after
|
||||
every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior,
|
||||
and the published OpenAPI does not describe it accurately. Pass the repo whose
|
||||
target you mean; without one, only the deprecated state-file-scoped
|
||||
`smoke_target` can answer.
|
||||
and the published OpenAPI does not describe it accurately. It **writes** (two
|
||||
canary env vars onto that one application, then deletes them), so the repo is
|
||||
required: the target is resolved *inside the project and environment it was
|
||||
declared under*, with `--project` / `--environment` if the box names either
|
||||
differently, and it refuses rather than guessing when no application of that
|
||||
name is there. A bare app name is unique nowhere else — one instance carrying
|
||||
prod and staging is enough for the first `core` on it to be prod's.
|
||||
- **`team`** — prints the team the configured token acts as. With `--env`, also
|
||||
checks it against that environment's `team:` binding and exits non-zero on a
|
||||
mismatch — the dry run for "would `apply` refuse?", answered without touching
|
||||
|
|
@ -260,13 +264,18 @@ yours:
|
|||
| `--environment <name>` | the environment isn't named after `--env` (Coolify's default is `production`, not `prod`) |
|
||||
| `--resource <manifest>=<live>` | a resource isn't named after the manifest's (`core` is `Incubator Stack v2` over there). Repeatable |
|
||||
|
||||
All three are **read-side only** — `diff`, `capture`, `inventory`. They are
|
||||
arguments to a one-off read, never manifest fields: a manifest that recorded a
|
||||
legacy box's names would carry a dead machine's vocabulary forever. And `apply`
|
||||
refuses `--resource` outright, because it creates resources under the manifest's
|
||||
own names — an alias there could only mean *adopt the existing one instead*,
|
||||
which is a different operation and would otherwise silently create a duplicate
|
||||
beside the resource you were pointing at.
|
||||
None of them is ever a manifest field: they are arguments to a single run, because
|
||||
a manifest that recorded a legacy box's names would carry a dead machine's
|
||||
vocabulary forever.
|
||||
|
||||
`--project` and `--environment` are how a verb that must *find* a target says
|
||||
where to look — `diff`, `capture`, `inventory`, `apply`, and `smoke`, which
|
||||
resolves its `smoke_target` in exactly that project and that environment, and
|
||||
refuses when it is not there (#29). `--resource` is **read-side only** (`diff`,
|
||||
`capture`, `inventory`): `apply` refuses it outright, because it creates
|
||||
resources under the manifest's own names — an alias there could only mean *adopt
|
||||
the existing one instead*, which is a different operation and would otherwise
|
||||
silently create a duplicate beside the resource you were pointing at.
|
||||
|
||||
`--env` stays **ours**: it selects the manifest block, the `environments.yaml`
|
||||
binding, the age key, the store path. `--environment` is *theirs*, on the wire,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ const ProjectBindingSchema = z
|
|||
// app, and the day a second project deploys into this environment, an
|
||||
// environment-scoped (let alone the state-file-scoped one it replaces)
|
||||
// `smoke_target: core` is simply wrong.
|
||||
//
|
||||
// It is declared here AND resolved here (#29): `smoke` looks the name up in
|
||||
// this project, in this environment, and refuses when it is not there. A
|
||||
// bare app name is unique nowhere else — the instance-wide lookup it used to
|
||||
// do could pick prod's `core` while smoking staging.
|
||||
smoke_target: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -146,22 +151,61 @@ const BindingsSchema = z
|
|||
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
|
||||
// existing state files keep working.
|
||||
github_apps: z.record(z.string()),
|
||||
// DEPRECATED — moved to environments.<env>.projects.<repo>.smoke_target.
|
||||
// Still read (see smokeTargetFor) so state files written before the move
|
||||
// keep working, on the same reasoning as the bare-`<repo>` github_apps key.
|
||||
// It is wrong at TWO levels: it names one project's app (`core`) from a key
|
||||
// scoped to the whole state file, so it cannot distinguish two projects and
|
||||
// cannot distinguish prod's app from staging's either.
|
||||
// GONE — nothing reads this any more (#29). It is still DECLARED here, and
|
||||
// refused below with a message, precisely because it is gone: this schema is
|
||||
// .strict(), so deleting the field outright would make an unmigrated state
|
||||
// file fail with a raw zod "unrecognized key" — and loadBindings runs for
|
||||
// EVERY verb, so `diff`, `apply`, `capture` and `inventory` would all die on
|
||||
// a key none of them ever read, mid-migration, with a message about nothing.
|
||||
// A key that has to be removed by hand gets a sentence saying how.
|
||||
smoke_target: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
// The registry only earns its keep if it is TRUE. Every check here defends the
|
||||
// same failure: a project that a fleet run never visits, because a fleet run
|
||||
// that skips a project prints exactly what a fleet run over a clean project
|
||||
// prints — nothing. Silence is the one report that must never be ambiguous, so
|
||||
// these are parse-time errors (every verb loads bindings, so every verb refuses
|
||||
// a registry that lies) rather than warnings some command might print.
|
||||
// Every check here defends one failure, from two ends: state that a command
|
||||
// will silently fail to act on. A registry that lies makes a fleet run skip a
|
||||
// project — and a skipped project prints exactly what a clean one prints,
|
||||
// nothing. A removed key that is still present makes `smoke` look like it has
|
||||
// a target when nothing reads it. Silence is the one report that must never be
|
||||
// ambiguous, so these are parse-time errors (every verb loads bindings, so
|
||||
// every verb refuses) rather than warnings some command might print.
|
||||
.superRefine((bindings, ctx) => {
|
||||
// GONE, not merely deprecated (#29) — see the field's note above. Reported
|
||||
// first, and without returning: a file may well carry both this and a
|
||||
// registry that needs fixing, and the operator should learn about both in
|
||||
// one run rather than one per run.
|
||||
if (bindings.smoke_target !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["smoke_target"],
|
||||
// The key could never be fixed, only carried: `smoke` now resolves its
|
||||
// target inside the project and environment the target was declared
|
||||
// under, and a key scoped to the whole state file has no project to
|
||||
// scope to. Carrying it meant keeping the instance-wide name lookup
|
||||
// alive for exactly the invocation that most needed it dead —
|
||||
// `cast smoke --env prod`.
|
||||
message: [
|
||||
"the top-level `smoke_target` key is no longer read (#29)",
|
||||
"",
|
||||
` found: smoke_target: ${bindings.smoke_target} (at the top level of this file)`,
|
||||
"",
|
||||
"It named ONE project's application from a key scoped to the whole state file,",
|
||||
"so it could not tell two projects apart — or even prod's app from staging's.",
|
||||
"`cast smoke` now resolves that name inside the project and environment it was",
|
||||
"declared under, and this key names no project to resolve it in.",
|
||||
"",
|
||||
"Move it under the project it belongs to, and pass that repo to `cast smoke`:",
|
||||
"",
|
||||
" environments:",
|
||||
" <env>:",
|
||||
" projects:",
|
||||
" <org>/<repo>:",
|
||||
` smoke_target: ${bindings.smoke_target}`,
|
||||
"",
|
||||
" cast smoke <org>/<repo> --env <env>",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
const registry = bindings.projects;
|
||||
if (!registry) return;
|
||||
|
||||
|
|
@ -338,25 +382,21 @@ export function projectBindingFor(
|
|||
return projects[orgRepo] ?? projects[repoShort];
|
||||
}
|
||||
|
||||
// The app `cast smoke` targets. Project-scoped first; the deprecated
|
||||
// state-file-scoped `smoke_target` is the fallback, so an unmigrated state file
|
||||
// still smokes.
|
||||
// The app `cast smoke` targets, in ONE project of ONE environment — the only
|
||||
// scope in which a bare application name is a coordinate at all. There is no
|
||||
// fallback and deliberately none: a name that cannot say which project and
|
||||
// which environment it belongs to does not identify an application, and `smoke`
|
||||
// writes to whatever it identifies (#29).
|
||||
//
|
||||
// `orgRepo` is optional because `cast smoke` did not take one until the target
|
||||
// became project-scoped — without it there is no project to look up and only
|
||||
// the old key can answer, which is exactly what the old invocation did.
|
||||
// `orgRepo` is required for the same reason: it is the project. Absence is not
|
||||
// an error here — an environment may simply declare no smoke target — but the
|
||||
// caller has to say so itself, and `smoke` does.
|
||||
export function smokeTargetFor(
|
||||
bindings: Bindings,
|
||||
envName: string,
|
||||
orgRepo?: string,
|
||||
): { target: string; source: "project" | "deprecated" } | undefined {
|
||||
const scoped = orgRepo
|
||||
? projectBindingFor(bindings, envName, orgRepo)?.smoke_target
|
||||
: undefined;
|
||||
if (scoped) return { target: scoped, source: "project" };
|
||||
if (bindings.smoke_target)
|
||||
return { target: bindings.smoke_target, source: "deprecated" };
|
||||
return undefined;
|
||||
orgRepo: string,
|
||||
): string | undefined {
|
||||
return projectBindingFor(bindings, envName, orgRepo)?.smoke_target;
|
||||
}
|
||||
|
||||
// The `<org>/<repo>` slugs registered for one environment — the list a fleet
|
||||
|
|
@ -389,7 +429,9 @@ export function loadBindings(
|
|||
if (!result.success) {
|
||||
// Zod's own `.message` is the entire issue array as JSON — which renders the
|
||||
// refusals above as one long line of `\n` escapes, i.e. throws away the part
|
||||
// of them that was worth writing. Render the issues instead.
|
||||
// of them that was worth writing. That matters most for the ones that are
|
||||
// not typos at all but migrations (the removed `smoke_target`), where the
|
||||
// message IS the instruction. Render the issues instead.
|
||||
const detail = result.error.issues
|
||||
.map((issue) => {
|
||||
// A multi-line message is one WE wrote: it already names the path, the
|
||||
|
|
|
|||
177
src/cli.ts
177
src/cli.ts
|
|
@ -63,7 +63,7 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
|
|||
cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--resource <m>=<l>]
|
||||
cast inventory --env <env> [--instance <name>] # no repo: SWEEP the whole instance
|
||||
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
|
||||
cast smoke [<org>/<repo>] --env <env>
|
||||
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
|
||||
cast team [--env <env>]
|
||||
|
||||
--state <dir> the state checkout holding environments.yaml, secrets/ and
|
||||
|
|
@ -406,6 +406,65 @@ export function renderAbsentTarget(
|
|||
].join("\n");
|
||||
}
|
||||
|
||||
// The same disposition as renderAbsentTarget, one level deeper, and for the one
|
||||
// verb that WRITES: the project and the environment are both there, and hold no
|
||||
// application of the name `smoke` was told to write to.
|
||||
//
|
||||
// Until #29, `smoke` never got here. It resolved its target against
|
||||
// GET /applications — every application the token can see, across every project
|
||||
// and every environment of the instance — and wrote to the first name match. So
|
||||
// `smoke_target: core` did not name an application; it named whichever `core`
|
||||
// Coolify happened to list first, and one instance carrying prod and staging is
|
||||
// enough for that to be prod's. The canary vars land on an app nobody named, and
|
||||
// on the failure path they stay there.
|
||||
//
|
||||
// This message therefore does NOT offer to look elsewhere, and the code behind it
|
||||
// does not either. An application in another project is not the same application
|
||||
// seen from a different angle — it is a different application, and this verb
|
||||
// writes. The only thing worth saying is: here is where I looked, here is what is
|
||||
// actually in there, and here is which coordinate to correct.
|
||||
export function renderAbsentSmokeTarget(
|
||||
target: string,
|
||||
live: Array<{ kind: ResourceKind; name: string }>,
|
||||
ctx: { orgRepo: string; env: string; project: string; environment: string },
|
||||
): string {
|
||||
const apps = live.filter((l) => l.kind === "application").map((l) => l.name);
|
||||
// A service or a database of that name is not a near-miss to be accommodating
|
||||
// about — smoke POSTs to /applications/<uuid>/envs, so being pointed at one
|
||||
// would 404 on an endpoint that does not exist for that kind, and the operator
|
||||
// would spend the afternoon on an HTTP status instead of on the name.
|
||||
const sameName = live.find((l) => l.name === target);
|
||||
const wrongKind =
|
||||
sameName && sameName.kind !== "application"
|
||||
? [
|
||||
"",
|
||||
` but note: "${target}" DOES exist here — as a ${sameName.kind}, not an`,
|
||||
" application. `smoke` writes to an application's /envs endpoint;",
|
||||
` a ${sameName.kind} of the same name is a different resource behind a`,
|
||||
" different endpoint, not this one seen sideways.",
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
`refusing to smoke: project "${ctx.project}" / environment "${ctx.environment}" holds no application named "${target}"`,
|
||||
"",
|
||||
` looked for: application "${target}"`,
|
||||
` (environments.${ctx.env}.projects["${ctx.orgRepo}"].smoke_target)`,
|
||||
` in: project "${ctx.project}", environment "${ctx.environment}"`,
|
||||
` exists here: ${apps.join(", ") || "(no applications at all)"}`,
|
||||
...wrongKind,
|
||||
"",
|
||||
"cast will not go looking for that name anywhere else on this instance. A bare",
|
||||
"application name is unique only INSIDE a project and an environment, so the first",
|
||||
`\`${target}\` the API lists may belong to another project — or to prod, while you are`,
|
||||
"smoking staging (#29). `smoke` POSTs two canary env vars to the application it",
|
||||
"resolves, and deletes them again; on the failure path it leaves them behind. An",
|
||||
"app it was not pointed at is not a fallback.",
|
||||
"",
|
||||
"Name the application as it exists here, or pass --project / --environment if this",
|
||||
"instance names the project or the environment differently.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// The third name a hand-built box does not share with you: the RESOURCE.
|
||||
//
|
||||
// `--project` and `--environment` are coordinates for finding the target;
|
||||
|
|
@ -1064,27 +1123,37 @@ async function main(): Promise<number> {
|
|||
options: {
|
||||
state: { type: "string" },
|
||||
env: { type: "string" },
|
||||
project: { type: "string" },
|
||||
environment: { type: "string" },
|
||||
instance: { type: "string" },
|
||||
},
|
||||
});
|
||||
// Optional, unlike every other verb's — and only because the target used to
|
||||
// be state-file-scoped, so `cast smoke --env prod` with no repo at all is
|
||||
// what the runbook says today. Without it, only the deprecated key can
|
||||
// answer; with it, the project-scoped one can.
|
||||
const smokeRepo = positionals[0];
|
||||
// smoke writes: it POSTs two env vars onto the live smoke_target app and
|
||||
// deletes them again. That is a mutation, so it takes the assert like any
|
||||
// other. Without it, a wrong-team token that happened to own an app of
|
||||
// the same name would have that app written to instead.
|
||||
if (!values.env) {
|
||||
// REQUIRED, like every other verb's — because the repo IS the project, and
|
||||
// the project is half of the only scope in which the target's name means
|
||||
// anything (#29). `cast smoke --env prod` with no repo used to work by
|
||||
// reading the state-file-scoped `smoke_target`, which named an application
|
||||
// from a key that could not say which project or which environment it was
|
||||
// in; that key is gone (see BindingsSchema), and so is the invocation.
|
||||
const orgRepo = positionals[0];
|
||||
const envName = values.env;
|
||||
if (!orgRepo || !envName) {
|
||||
console.error(USAGE);
|
||||
return 2;
|
||||
}
|
||||
const stateDir = stateDirFrom(values.state);
|
||||
const repoShort = orgRepo.split("/")[1];
|
||||
// The same two read-side coordinates diff/capture/inventory take, for the
|
||||
// same two reasons: a project built by hand in the UI is called whatever
|
||||
// someone typed, and an environment built by hand is called whatever Coolify
|
||||
// defaulted to (`production`, not `prod`). --env still selects the manifest
|
||||
// block, the environments.yaml binding and the team to assert; --project and
|
||||
// --environment change ONLY the names cast looks the target up under.
|
||||
const projectName = values.project ?? repoShort;
|
||||
const coolifyEnv = values.environment ?? envName;
|
||||
const bindings = loadBindings(join(stateDir, "environments.yaml"));
|
||||
const binding = bindings.environments[values.env];
|
||||
const binding = bindings.environments[envName];
|
||||
if (!binding) {
|
||||
console.error(`environment ${values.env} not in environments.yaml`);
|
||||
console.error(`environment ${envName} not in environments.yaml`);
|
||||
return 2;
|
||||
}
|
||||
const { instance, client } = openCoolify(
|
||||
|
|
@ -1092,61 +1161,69 @@ async function main(): Promise<number> {
|
|||
values.instance,
|
||||
binding,
|
||||
);
|
||||
// smoke writes: it POSTs two env vars onto the live smoke_target app and
|
||||
// deletes them again. That is a mutation, so it takes both gates — the
|
||||
// read-only instance refusal and the team assert — before the first call.
|
||||
// Without the assert, a wrong-team token that happened to own an app of the
|
||||
// same name would have that app written to instead.
|
||||
assertWritable(instance, "smoke");
|
||||
const team = await assertTeam(client, binding.team, values.env);
|
||||
const team = await assertTeam(client, binding.team, envName);
|
||||
console.log(`team ${formatTeam(team)} ✓`);
|
||||
const resolved = smokeTargetFor(bindings, values.env, smokeRepo);
|
||||
if (!resolved) {
|
||||
const lookedFor = smokeRepo
|
||||
? [
|
||||
` looked for: environments.${values.env}.projects["${smokeRepo}"].smoke_target`,
|
||||
" then the deprecated state-file-scoped smoke_target",
|
||||
]
|
||||
: [
|
||||
" looked for: the deprecated state-file-scoped smoke_target — and only",
|
||||
" that one, because no <org>/<repo> was given and so no",
|
||||
" project's binding could be consulted",
|
||||
];
|
||||
const target = smokeTargetFor(bindings, envName, orgRepo);
|
||||
if (!target) {
|
||||
console.error(
|
||||
[
|
||||
`no smoke_target for ${values.env}`,
|
||||
`no smoke_target for ${orgRepo} in ${envName}`,
|
||||
"",
|
||||
...lookedFor,
|
||||
` looked for: environments.${envName}.projects["${orgRepo}"].smoke_target`,
|
||||
` (a bare "${repoShort}" key resolves too)`,
|
||||
"",
|
||||
"`smoke` writes two canary env vars to one application and deletes them",
|
||||
"again — it has to be told which one. Name it under the project it belongs",
|
||||
"to, and pass that repo:",
|
||||
"again — it has to be told which one, under the project that owns it:",
|
||||
"",
|
||||
" environments:",
|
||||
` ${values.env}:`,
|
||||
` ${envName}:`,
|
||||
" projects:",
|
||||
` ${smokeRepo ?? "<org>/<repo>"}:`,
|
||||
` ${orgRepo}:`,
|
||||
" smoke_target: <the application's name>",
|
||||
].join("\n"),
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
if (resolved.source === "deprecated") {
|
||||
console.warn(
|
||||
`warning: smoke_target read from the deprecated state-file-scoped key. It names ONE project's application from a key that cannot tell two projects — or even prod from staging — apart. Move it to environments.${values.env}.projects.<org>/<repo>.smoke_target and pass the repo to \`cast smoke\`.`,
|
||||
// The fix for #29, and the whole of it: the target is resolved in the project
|
||||
// and the environment it was DECLARED under — the same lookup every read-side
|
||||
// verb makes — instead of by name against GET /applications, which is every
|
||||
// application on the instance and answers with whichever one it lists first.
|
||||
const lookup = await fetchLive(client, projectName, coolifyEnv);
|
||||
if (!lookup.found) {
|
||||
console.error(
|
||||
renderAbsentTarget(lookup, {
|
||||
orgRepo,
|
||||
overridden: values.project !== undefined,
|
||||
envOverridden: values.environment !== undefined,
|
||||
verb: "smoke",
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Resolved against the instance-wide application list, not the project's:
|
||||
// that is what it did before this change and it is not this change's job to
|
||||
// alter which app gets written to. It does mean the name is not actually a
|
||||
// coordinate — one project's `core` and another's, or prod's and staging's
|
||||
// on the same instance, are a coin flip. See #29; fixing it needs the
|
||||
// read-side coordinates (--project/--environment) smoke does not yet have.
|
||||
const apps = (await client.get("/applications")) as Array<{
|
||||
uuid: string;
|
||||
name: string;
|
||||
}>;
|
||||
const target = apps.find((a) => a.name === resolved.target);
|
||||
if (!target) {
|
||||
console.error(`smoke_target ${resolved.target} not found`);
|
||||
return 2;
|
||||
}
|
||||
await smoke(client, target.uuid);
|
||||
// Applications only. fetchLive returns every kind in the environment, and a
|
||||
// service or database called `core` is not a smoke target — it is a 404 on an
|
||||
// endpoint that does not exist for it (see renderAbsentSmokeTarget).
|
||||
const app = lookup.live.find(
|
||||
(l) => l.kind === "application" && l.name === target,
|
||||
);
|
||||
if (!app) {
|
||||
console.error(
|
||||
renderAbsentSmokeTarget(target, lookup.live, {
|
||||
orgRepo,
|
||||
env: envName,
|
||||
project: projectName,
|
||||
environment: coolifyEnv,
|
||||
}),
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
await smoke(client, app.uuid);
|
||||
return 0;
|
||||
}
|
||||
if (command === "team") {
|
||||
|
|
|
|||
|
|
@ -18,10 +18,7 @@ function bindings(github_apps: Record<string, string>): Bindings {
|
|||
} as Bindings;
|
||||
}
|
||||
|
||||
function withProjects(
|
||||
projects: Record<string, ProjectBinding>,
|
||||
smoke_target?: string,
|
||||
): Bindings {
|
||||
function withProjects(projects: Record<string, ProjectBinding>): Bindings {
|
||||
return {
|
||||
environments: {
|
||||
prod: {
|
||||
|
|
@ -32,7 +29,6 @@ function withProjects(
|
|||
staging: { server: "staging-box", team: { id: 0, name: "Root Team" } },
|
||||
},
|
||||
github_apps: {},
|
||||
...(smoke_target ? { smoke_target } : {}),
|
||||
} as Bindings;
|
||||
}
|
||||
|
||||
|
|
@ -140,49 +136,36 @@ describe("projectBindingFor", () => {
|
|||
});
|
||||
|
||||
describe("smokeTargetFor", () => {
|
||||
it("prefers the project-scoped target", () => {
|
||||
const b = withProjects(
|
||||
{ "heavy-duty/incubator": { smoke_target: "core" } },
|
||||
"old-target",
|
||||
);
|
||||
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toEqual({
|
||||
target: "core",
|
||||
source: "project",
|
||||
it("resolves the project-scoped target", () => {
|
||||
const b = withProjects({
|
||||
"heavy-duty/incubator": { smoke_target: "core" },
|
||||
});
|
||||
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toBe("core");
|
||||
});
|
||||
|
||||
// The state file mid-migration still has only the old key — it must keep
|
||||
// smoking, exactly as the bare-`<repo>` github_apps key keeps resolving.
|
||||
it("falls back to the deprecated state-file-scoped key, and says so", () => {
|
||||
const b = withProjects({}, "old-target");
|
||||
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toEqual({
|
||||
target: "old-target",
|
||||
source: "deprecated",
|
||||
});
|
||||
// ...and with no repo passed at all, which is the old invocation.
|
||||
expect(smokeTargetFor(b, "prod")).toEqual({
|
||||
target: "old-target",
|
||||
source: "deprecated",
|
||||
});
|
||||
});
|
||||
|
||||
it("is undefined when neither key names a target", () => {
|
||||
it("is undefined when the project declares no target", () => {
|
||||
expect(
|
||||
smokeTargetFor(withProjects({}), "prod", "heavy-duty/incubator"),
|
||||
).toBe(undefined);
|
||||
expect(
|
||||
smokeTargetFor(
|
||||
withProjects({ "heavy-duty/incubator": { smoke_target: "core" } }),
|
||||
"staging",
|
||||
"heavy-duty/incubator",
|
||||
),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
// Two projects, each with its own smoke target: the case the old key could
|
||||
// not express at all, since it named one app for the whole state file.
|
||||
// Two projects, each with its own smoke target: the case the removed
|
||||
// state-file-scoped key could not express at all, since it named one app for
|
||||
// the whole file.
|
||||
it("keeps two projects' smoke targets apart", () => {
|
||||
const b = withProjects({
|
||||
"heavy-duty/incubator": { smoke_target: "core" },
|
||||
"acme/client-site": { smoke_target: "web" },
|
||||
});
|
||||
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")?.target).toBe(
|
||||
"core",
|
||||
);
|
||||
expect(smokeTargetFor(b, "prod", "acme/client-site")?.target).toBe("web");
|
||||
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toBe("core");
|
||||
expect(smokeTargetFor(b, "prod", "acme/client-site")).toBe("web");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -207,6 +190,31 @@ github_apps: {}
|
|||
});
|
||||
});
|
||||
|
||||
// The key is gone (#29): it named one project's application from a scope that
|
||||
// could not tell two projects — or prod from staging — apart, and `smoke` now
|
||||
// resolves the name INSIDE the project it was declared under, which this key
|
||||
// does not have. It is still declared in the schema purely so its removal
|
||||
// reads as a migration instead of as a zod "unrecognized key" — loadBindings
|
||||
// runs for every verb, so an unmigrated file would otherwise take `diff` and
|
||||
// `apply` down with it, over a key neither of them reads.
|
||||
it("refuses a state-file-scoped smoke_target, and says where to move it", () => {
|
||||
const load = () =>
|
||||
loadBindings("environments.yaml", {
|
||||
overrideText: `
|
||||
environments:
|
||||
prod:
|
||||
server: shared-box
|
||||
team: { id: 0, name: Root Team }
|
||||
github_apps: {}
|
||||
smoke_target: core
|
||||
`,
|
||||
});
|
||||
expect(load).toThrow(/top-level `smoke_target` key is no longer read/);
|
||||
expect(load).toThrow(/projects:/);
|
||||
expect(load).toThrow(/smoke_target: core/);
|
||||
expect(load).toThrow(/cast smoke <org>\/<repo> --env <env>/);
|
||||
});
|
||||
|
||||
it("rejects an unknown key under a project (a typo is not a placement)", () => {
|
||||
expect(() =>
|
||||
loadBindings("environments.yaml", {
|
||||
|
|
|
|||
|
|
@ -90,9 +90,11 @@ function stateWith(opts: {
|
|||
" server: prod-box",
|
||||
" team: { id: 0, name: Root Team }",
|
||||
...(opts.boundInstance ? [` instance: ${opts.boundInstance}`] : []),
|
||||
" projects:",
|
||||
" heavy-duty/incubator:",
|
||||
" smoke_target: core",
|
||||
"github_apps:",
|
||||
" incubator: hdb-coolify",
|
||||
"smoke_target: core",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
|
@ -135,10 +137,20 @@ describe("infra cli", () => {
|
|||
expect(r.output).toMatch(/--env/);
|
||||
});
|
||||
it("refuses smoke without --env, exit non-zero", async () => {
|
||||
const r = await runCli(["smoke"]);
|
||||
const r = await runCli(["smoke", "heavy-duty/incubator"]);
|
||||
expect(r.code).not.toBe(0);
|
||||
expect(r.output).toMatch(/--env/);
|
||||
});
|
||||
// The repo is the PROJECT, and the project is half of the only scope in which
|
||||
// `smoke_target: core` names anything (#29). `cast smoke --env prod` used to
|
||||
// run — resolving the name against every application on the instance — which
|
||||
// is precisely the invocation that could write prod's `core` while smoking
|
||||
// staging. It is now a usage error, before any state is even read.
|
||||
it("refuses smoke without the <org>/<repo> positional, exit non-zero", async () => {
|
||||
const r = await runCli(["smoke", "--env", "prod"]);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.output).toMatch(/cast smoke\s+<org>\/<repo>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--instance (multiple Coolify instances)", () => {
|
||||
|
|
@ -235,6 +247,7 @@ describe("--instance (multiple Coolify instances)", () => {
|
|||
});
|
||||
const r = await runCli([
|
||||
"smoke",
|
||||
"heavy-duty/incubator",
|
||||
"--state",
|
||||
dir,
|
||||
"--env",
|
||||
|
|
|
|||
365
test/smoke-cli.test.ts
Normal file
365
test/smoke-cli.test.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// `cast smoke`, end to end, against the instance shape #29 is actually about.
|
||||
//
|
||||
// ONE Coolify, carrying:
|
||||
//
|
||||
// project incubator / environment prod → application `core` (a-prod-core)
|
||||
// project incubator / environment staging → application `core` (a-staging-core)
|
||||
// → database `db`
|
||||
// project client-site / environment staging → application `core` (a-client-core)
|
||||
//
|
||||
// Three applications called `core`. That is not a contrived box — `instance:` is
|
||||
// a per-environment binding, so with none set, prod and staging read the same
|
||||
// .coolify.env and live on the same control plane. Until this fix, smoke resolved
|
||||
// its target against GET /applications (every app the token can see, all projects,
|
||||
// all environments) and wrote to the FIRST name match — so this stub lists prod's
|
||||
// `core` first, which is what `cast smoke --env staging` would have written its
|
||||
// canary vars onto, and (on the failure path) left them on.
|
||||
//
|
||||
// The wire is the witness in every test below: which uuid was written to, and —
|
||||
// just as load-bearing — that the instance-wide list was never asked for at all.
|
||||
|
||||
type Stub = {
|
||||
url: string;
|
||||
hits: string[];
|
||||
writes: string[];
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
const stubs: Stub[] = [];
|
||||
|
||||
type EnvVar = { key: string; value: string; is_buildtime: boolean };
|
||||
|
||||
async function stubCoolify(): Promise<Stub> {
|
||||
const hits: string[] = [];
|
||||
const writes: string[] = [];
|
||||
// One env store per application, so a write to the wrong `core` is visible as
|
||||
// a write to the wrong uuid rather than as nothing at all.
|
||||
const envs: Record<string, Array<EnvVar & { uuid: string }>> = {
|
||||
"a-prod-core": [],
|
||||
"a-staging-core": [],
|
||||
"a-client-core": [],
|
||||
};
|
||||
let nextUuid = 1;
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const method = req.method ?? "GET";
|
||||
const path = (req.url ?? "").replace("/api/v1", "");
|
||||
hits.push(`${method} ${path}`);
|
||||
if (method !== "GET") writes.push(`${method} ${path}`);
|
||||
const json = (body: unknown) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const app = (name: string, uuid: string) => ({ name, uuid });
|
||||
|
||||
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
|
||||
if (path === "/version") {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
return res.end("4.1.2");
|
||||
}
|
||||
if (path === "/projects")
|
||||
return json([
|
||||
{ uuid: "p-inc", name: "incubator" },
|
||||
{ uuid: "p-cli", name: "client-site" },
|
||||
]);
|
||||
// The lookup this fix REPLACES. Answered anyway (prod's `core` first, as
|
||||
// Coolify would list the older resource) so the tests can assert cast never
|
||||
// asks for it — a stub that 404'd here would prove only that cast survives
|
||||
// the 404.
|
||||
if (path === "/applications")
|
||||
return json([
|
||||
app("core", "a-prod-core"),
|
||||
app("core", "a-client-core"),
|
||||
app("core", "a-staging-core"),
|
||||
]);
|
||||
if (path === "/projects/p-inc/prod")
|
||||
return json({ applications: [app("core", "a-prod-core")] });
|
||||
if (path === "/projects/p-inc/staging")
|
||||
return json({
|
||||
applications: [app("core", "a-staging-core")],
|
||||
postgresqls: [app("db", "d-staging")],
|
||||
});
|
||||
if (path === "/projects/p-cli/staging")
|
||||
return json({ applications: [app("core", "a-client-core")] });
|
||||
|
||||
const env = path.match(/^\/applications\/([^/]+)\/envs(\/(.+))?$/);
|
||||
if (env) {
|
||||
const store = envs[env[1]];
|
||||
if (!store) {
|
||||
res.writeHead(404);
|
||||
return res.end("{}");
|
||||
}
|
||||
const rest = env[3];
|
||||
if (method === "GET" && !rest) return json(store);
|
||||
if (method === "POST" && !rest) {
|
||||
let body = "";
|
||||
req.on("data", (d) => {
|
||||
body += String(d);
|
||||
});
|
||||
return req.on("end", () => {
|
||||
const v = JSON.parse(body) as EnvVar;
|
||||
const created = { ...v, uuid: `e-${nextUuid++}` };
|
||||
store.push(created);
|
||||
json(created);
|
||||
});
|
||||
}
|
||||
// Upsert, mirroring verified Coolify 4.1.2 behavior — the property `smoke`
|
||||
// exists to keep checking (see src/smoke.ts).
|
||||
if (method === "PATCH" && rest === "bulk") {
|
||||
let body = "";
|
||||
req.on("data", (d) => {
|
||||
body += String(d);
|
||||
});
|
||||
return req.on("end", () => {
|
||||
const { data } = JSON.parse(body) as { data: EnvVar[] };
|
||||
for (const v of data) {
|
||||
const existing = store.find((e) => e.key === v.key);
|
||||
if (existing) Object.assign(existing, v);
|
||||
else store.push({ ...v, uuid: `e-${nextUuid++}` });
|
||||
}
|
||||
json({ ok: true });
|
||||
});
|
||||
}
|
||||
if (method === "DELETE" && rest) {
|
||||
envs[env[1]] = store.filter((e) => e.uuid !== rest);
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end("{}");
|
||||
});
|
||||
|
||||
await new Promise<void>((r) => {
|
||||
server.listen(0, "127.0.0.1", r);
|
||||
});
|
||||
const stub: Stub = {
|
||||
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
hits,
|
||||
writes,
|
||||
close: () =>
|
||||
new Promise<void>((r) => {
|
||||
server.close(() => r());
|
||||
}),
|
||||
};
|
||||
stubs.push(stub);
|
||||
return stub;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(stubs.splice(0).map((s) => s.close()));
|
||||
});
|
||||
|
||||
// `targets` is the knob: what each project's binding says `smoke` should write
|
||||
// to. smoke needs no manifest, no checkout, no secret store and no age key — it
|
||||
// reads the state file and the live box, and nothing else.
|
||||
function state(
|
||||
url: string,
|
||||
targets: Record<string, string> = {
|
||||
"heavy-duty/incubator": "core",
|
||||
"acme/client-site": "core",
|
||||
},
|
||||
): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-smoke-"));
|
||||
writeFileSync(
|
||||
join(dir, ".coolify.env"),
|
||||
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
||||
);
|
||||
const projects = Object.entries(targets).flatMap(([repo, target]) => [
|
||||
` ${repo}:`,
|
||||
` smoke_target: ${target}`,
|
||||
]);
|
||||
writeFileSync(
|
||||
join(dir, "environments.yaml"),
|
||||
[
|
||||
"environments:",
|
||||
" staging:",
|
||||
" server: shared-box",
|
||||
" team: { id: 0, name: Root Team }",
|
||||
" projects:",
|
||||
...projects,
|
||||
" prod:",
|
||||
" server: shared-box",
|
||||
" team: { id: 0, name: Root Team }",
|
||||
" projects:",
|
||||
...projects,
|
||||
"github_apps:",
|
||||
" incubator: hdb-coolify",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function run(args: string[]): Promise<{ code: number; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("node", ["dist/cli.js", "smoke", ...args], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (d) => {
|
||||
output += String(d);
|
||||
});
|
||||
child.stderr.on("data", (d) => {
|
||||
output += String(d);
|
||||
});
|
||||
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("cast smoke — resolved inside its project + environment (#29)", () => {
|
||||
it("writes to THIS environment's app, not the first `core` the instance lists", async () => {
|
||||
const stub = await stubCoolify();
|
||||
const r = await run([
|
||||
"heavy-duty/incubator",
|
||||
"--state",
|
||||
state(stub.url),
|
||||
"--env",
|
||||
"staging",
|
||||
]);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.output).toMatch(/smoke OK/);
|
||||
// THE POINT. Every mutation landed on staging's `core` — and prod's, which
|
||||
// the instance-wide lookup would have picked first, was never touched.
|
||||
expect(stub.writes.length).toBeGreaterThan(0);
|
||||
for (const w of stub.writes) expect(w).toContain("a-staging-core");
|
||||
expect(stub.writes.join("\n")).not.toContain("a-prod-core");
|
||||
expect(stub.writes.join("\n")).not.toContain("a-client-core");
|
||||
// And the namespace that made prod reachable at all was never even asked
|
||||
// for: the target is resolved through the project, like every other verb's.
|
||||
expect(stub.hits).not.toContain("GET /applications");
|
||||
expect(stub.hits).toContain("GET /projects/p-inc/staging");
|
||||
});
|
||||
|
||||
it("follows --env to the other environment of the same project", async () => {
|
||||
const stub = await stubCoolify();
|
||||
const r = await run([
|
||||
"heavy-duty/incubator",
|
||||
"--state",
|
||||
state(stub.url),
|
||||
"--env",
|
||||
"prod",
|
||||
]);
|
||||
expect(r.code).toBe(0);
|
||||
for (const w of stub.writes) expect(w).toContain("a-prod-core");
|
||||
expect(stub.writes.join("\n")).not.toContain("a-staging-core");
|
||||
});
|
||||
|
||||
// The other half of the coordinate: same environment, same instance, same app
|
||||
// name — a different project, and therefore a different application.
|
||||
it("follows the repo to the other project's app of the same name", async () => {
|
||||
const stub = await stubCoolify();
|
||||
const r = await run([
|
||||
"acme/client-site",
|
||||
"--state",
|
||||
state(stub.url),
|
||||
"--env",
|
||||
"staging",
|
||||
]);
|
||||
expect(r.code).toBe(0);
|
||||
for (const w of stub.writes) expect(w).toContain("a-client-core");
|
||||
expect(stub.writes.join("\n")).not.toContain("a-staging-core");
|
||||
});
|
||||
|
||||
it("reads the box's project and environment names when they are not ours", async () => {
|
||||
const stub = await stubCoolify();
|
||||
const r = await run([
|
||||
"heavy-duty/incubator",
|
||||
"--state",
|
||||
state(stub.url),
|
||||
"--env",
|
||||
"staging",
|
||||
// `staging` is OURS: it selects the binding and the team to assert. The
|
||||
// box calls this project's environment `prod`, and only the box's name
|
||||
// goes on the wire.
|
||||
"--project",
|
||||
"incubator",
|
||||
"--environment",
|
||||
"prod",
|
||||
]);
|
||||
expect(r.code).toBe(0);
|
||||
for (const w of stub.writes) expect(w).toContain("a-prod-core");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cast smoke — refusing rather than guessing (#29)", () => {
|
||||
it("refuses when this project + environment holds no app of that name, and says what it does hold", async () => {
|
||||
const stub = await stubCoolify();
|
||||
const r = await run([
|
||||
"heavy-duty/incubator",
|
||||
"--state",
|
||||
state(stub.url, { "heavy-duty/incubator": "web" }),
|
||||
"--env",
|
||||
"staging",
|
||||
]);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.output).toContain('holds no application named "web"');
|
||||
// What IS there — the finding, and the whole reason this is not a 404.
|
||||
expect(r.output).toMatch(/exists here:\s+core/);
|
||||
expect(r.output).toContain("smoke_target");
|
||||
// Not "…so I looked on the rest of the instance and found one". An app in
|
||||
// another project is a different app, and this verb writes.
|
||||
expect(stub.hits).not.toContain("GET /applications");
|
||||
expect(stub.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a target that exists here but is not an application", async () => {
|
||||
const stub = await stubCoolify();
|
||||
const r = await run([
|
||||
"heavy-duty/incubator",
|
||||
"--state",
|
||||
state(stub.url, { "heavy-duty/incubator": "db" }),
|
||||
"--env",
|
||||
"staging",
|
||||
]);
|
||||
expect(r.code).toBe(2);
|
||||
// smoke POSTs to /applications/<uuid>/envs. Pointed at the postgres, it
|
||||
// would 404 on an endpoint that does not exist for a database, and the
|
||||
// operator would debug the status code instead of the name.
|
||||
expect(r.output).toMatch(/"db" DOES exist here — as a database/);
|
||||
expect(r.output).toMatch(/not an\s+application/);
|
||||
expect(r.output).toContain("/envs endpoint");
|
||||
expect(stub.writes).toEqual([]);
|
||||
});
|
||||
|
||||
// The project/environment refusal, reached through the same fetchLive every
|
||||
// read-side verb uses — so smoke inherits it verbatim (see renderAbsentTarget).
|
||||
it("refuses an absent environment as absent, naming --environment", async () => {
|
||||
const stub = await stubCoolify();
|
||||
const r = await run([
|
||||
"heavy-duty/incubator",
|
||||
"--state",
|
||||
state(stub.url),
|
||||
"--env",
|
||||
"staging",
|
||||
"--environment",
|
||||
"production",
|
||||
]);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.output).toContain("refusing to smoke");
|
||||
expect(r.output).toContain('has no environment "production"');
|
||||
expect(stub.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses when the project declares no smoke_target at all", async () => {
|
||||
const stub = await stubCoolify();
|
||||
const r = await run([
|
||||
"heavy-duty/incubator",
|
||||
"--state",
|
||||
state(stub.url, { "acme/client-site": "core" }),
|
||||
"--env",
|
||||
"staging",
|
||||
]);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.output).toContain("no smoke_target for heavy-duty/incubator");
|
||||
expect(r.output).toContain("smoke_target: <the application's name>");
|
||||
expect(stub.writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue