smoke resolves its target inside the project it was declared under (#29) #31

Merged
dan-claude-bot merged 2 commits from fix/smoke-project-scoped into main 2026-07-13 20:45:40 +00:00
7 changed files with 1166 additions and 121 deletions

View file

@ -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/<repo>.<env>.env.age # age-encrypted values for the ${…} placeholders
.coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit)
.coolify/<name>.env # …the same, for a NAMED instance (see below)
@ -66,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>]
```
@ -91,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
@ -258,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,
@ -357,6 +368,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 `<org>/<repo>` 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 `<repo>` 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 `<repo>` 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.<env>.projects.<repo>` 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

View file

@ -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.<env>.projects.<repo>` 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 `<org>/<repo>` slug, with no bare-`<repo>` fallback.** The
key is the repo; there is no `repo:` field. `github_apps` and
`environments.<env>.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 `<repo>` 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.<slug>.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.<env>.projects.<slug>` 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 <repo> --env <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

View file

@ -52,12 +52,43 @@ 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();
export type ProjectBinding = z.infer<typeof ProjectBindingSchema>;
// 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.<env>.projects.<repo>` 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<typeof RegisteredProjectSchema>;
const BindingsSchema = z
.object({
environments: z.record(
@ -100,19 +131,203 @@ const BindingsSchema = z
})
.strict(),
),
// THE REGISTRY: which projects exist at all, keyed by the full `<org>/<repo>`
// 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-`<repo>` fallback — the one place in this
// file where that fallback does not exist. `github_apps` and
// `environments.<env>.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
// `<repo>` 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 `<org>/<repo>`
// 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();
.strict()
// 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;
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 <org>/<repo> slug",
"",
"A bare <repo> 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-<repo> fallback.",
"",
" projects:",
` <org>/${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: <server>",
" team: { id: <id>, name: <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 <repo> --env <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-<repo> 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-<repo> 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<typeof BindingsSchema>;
@ -167,25 +382,42 @@ 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
// 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(
@ -195,7 +427,22 @@ 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. 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
// 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;
}

View file

@ -63,7 +63,7 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--resource <m>=<l>]
cast inventory --env <env> [--instance <name>] # no repo: SWEEP the whole instance
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke [<org>/<repo>] --env <env>
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>]
--state <dir> the state checkout holding environments.yaml, secrets/ and
@ -406,6 +406,65 @@ export function renderAbsentTarget(
].join("\n");
}
// The same disposition as renderAbsentTarget, one level deeper, and for the one
// verb that WRITES: the project and the environment are both there, and hold no
// application of the name `smoke` was told to write to.
//
// Until #29, `smoke` never got here. It resolved its target against
// GET /applications — every application the token can see, across every project
// and every environment of the instance — and wrote to the first name match. So
// `smoke_target: core` did not name an application; it named whichever `core`
// Coolify happened to list first, and one instance carrying prod and staging is
// enough for that to be prod's. The canary vars land on an app nobody named, and
// on the failure path they stay there.
//
// This message therefore does NOT offer to look elsewhere, and the code behind it
// does not either. An application in another project is not the same application
// seen from a different angle — it is a different application, and this verb
// writes. The only thing worth saying is: here is where I looked, here is what is
// actually in there, and here is which coordinate to correct.
export function renderAbsentSmokeTarget(
target: string,
live: Array<{ kind: ResourceKind; name: string }>,
ctx: { orgRepo: string; env: string; project: string; environment: string },
): string {
const apps = live.filter((l) => l.kind === "application").map((l) => l.name);
// A service or a database of that name is not a near-miss to be accommodating
// about — smoke POSTs to /applications/<uuid>/envs, so being pointed at one
// would 404 on an endpoint that does not exist for that kind, and the operator
// would spend the afternoon on an HTTP status instead of on the name.
const sameName = live.find((l) => l.name === target);
const wrongKind =
sameName && sameName.kind !== "application"
? [
"",
` but note: "${target}" DOES exist here — as a ${sameName.kind}, not an`,
" application. `smoke` writes to an application's /envs endpoint;",
` a ${sameName.kind} of the same name is a different resource behind a`,
" different endpoint, not this one seen sideways.",
]
: [];
return [
`refusing to smoke: project "${ctx.project}" / environment "${ctx.environment}" holds no application named "${target}"`,
"",
` looked for: application "${target}"`,
` (environments.${ctx.env}.projects["${ctx.orgRepo}"].smoke_target)`,
` in: project "${ctx.project}", environment "${ctx.environment}"`,
` exists here: ${apps.join(", ") || "(no applications at all)"}`,
...wrongKind,
"",
"cast will not go looking for that name anywhere else on this instance. A bare",
"application name is unique only INSIDE a project and an environment, so the first",
`\`${target}\` the API lists may belong to another project — or to prod, while you are`,
"smoking staging (#29). `smoke` POSTs two canary env vars to the application it",
"resolves, and deletes them again; on the failure path it leaves them behind. An",
"app it was not pointed at is not a fallback.",
"",
"Name the application as it exists here, or pass --project / --environment if this",
"instance names the project or the environment differently.",
].join("\n");
}
// The third name a hand-built box does not share with you: the RESOURCE.
//
// `--project` and `--environment` are coordinates for finding the target;
@ -1064,27 +1123,37 @@ async function main(): Promise<number> {
options: {
state: { type: "string" },
env: { type: "string" },
project: { type: "string" },
environment: { type: "string" },
instance: { type: "string" },
},
});
// Optional, unlike every other verb's — and only because the target used to
// be state-file-scoped, so `cast smoke --env prod` with no repo at all is
// what the runbook says today. Without it, only the deprecated key can
// answer; with it, the project-scoped one can.
const smokeRepo = positionals[0];
// smoke writes: it POSTs two env vars onto the live smoke_target app and
// deletes them again. That is a mutation, so it takes the assert like any
// other. Without it, a wrong-team token that happened to own an app of
// the same name would have that app written to instead.
if (!values.env) {
// REQUIRED, like every other verb's — because the repo IS the project, and
// the project is half of the only scope in which the target's name means
// anything (#29). `cast smoke --env prod` with no repo used to work by
// reading the state-file-scoped `smoke_target`, which named an application
// from a key that could not say which project or which environment it was
// in; that key is gone (see BindingsSchema), and so is the invocation.
const orgRepo = positionals[0];
const envName = values.env;
if (!orgRepo || !envName) {
console.error(USAGE);
return 2;
}
const stateDir = stateDirFrom(values.state);
const repoShort = orgRepo.split("/")[1];
// The same two read-side coordinates diff/capture/inventory take, for the
// same two reasons: a project built by hand in the UI is called whatever
// someone typed, and an environment built by hand is called whatever Coolify
// defaulted to (`production`, not `prod`). --env still selects the manifest
// block, the environments.yaml binding and the team to assert; --project and
// --environment change ONLY the names cast looks the target up under.
const projectName = values.project ?? repoShort;
const coolifyEnv = values.environment ?? envName;
const bindings = loadBindings(join(stateDir, "environments.yaml"));
const binding = bindings.environments[values.env];
const binding = bindings.environments[envName];
if (!binding) {
console.error(`environment ${values.env} not in environments.yaml`);
console.error(`environment ${envName} not in environments.yaml`);
return 2;
}
const { instance, client } = openCoolify(
@ -1092,61 +1161,69 @@ async function main(): Promise<number> {
values.instance,
binding,
);
// smoke writes: it POSTs two env vars onto the live smoke_target app and
// deletes them again. That is a mutation, so it takes both gates — the
// read-only instance refusal and the team assert — before the first call.
// Without the assert, a wrong-team token that happened to own an app of the
// same name would have that app written to instead.
assertWritable(instance, "smoke");
const team = await assertTeam(client, binding.team, values.env);
const team = await assertTeam(client, binding.team, envName);
console.log(`team ${formatTeam(team)}`);
const resolved = smokeTargetFor(bindings, values.env, smokeRepo);
if (!resolved) {
const lookedFor = smokeRepo
? [
` looked for: environments.${values.env}.projects["${smokeRepo}"].smoke_target`,
" then the deprecated state-file-scoped smoke_target",
]
: [
" looked for: the deprecated state-file-scoped smoke_target — and only",
" that one, because no <org>/<repo> was given and so no",
" project's binding could be consulted",
];
const target = smokeTargetFor(bindings, envName, orgRepo);
if (!target) {
console.error(
[
`no smoke_target for ${values.env}`,
`no smoke_target for ${orgRepo} in ${envName}`,
"",
...lookedFor,
` looked for: environments.${envName}.projects["${orgRepo}"].smoke_target`,
` (a bare "${repoShort}" key resolves too)`,
"",
"`smoke` writes two canary env vars to one application and deletes them",
"again — it has to be told which one. Name it under the project it belongs",
"to, and pass that repo:",
"again — it has to be told which one, under the project that owns it:",
"",
" environments:",
` ${values.env}:`,
` ${envName}:`,
" projects:",
` ${smokeRepo ?? "<org>/<repo>"}:`,
` ${orgRepo}:`,
" smoke_target: <the application's name>",
].join("\n"),
);
return 2;
}
if (resolved.source === "deprecated") {
console.warn(
`warning: smoke_target read from the deprecated state-file-scoped key. It names ONE project's application from a key that cannot tell two projects — or even prod from staging — apart. Move it to environments.${values.env}.projects.<org>/<repo>.smoke_target and pass the repo to \`cast smoke\`.`,
// The fix for #29, and the whole of it: the target is resolved in the project
// and the environment it was DECLARED under — the same lookup every read-side
// verb makes — instead of by name against GET /applications, which is every
// application on the instance and answers with whichever one it lists first.
const lookup = await fetchLive(client, projectName, coolifyEnv);
if (!lookup.found) {
console.error(
renderAbsentTarget(lookup, {
orgRepo,
overridden: values.project !== undefined,
envOverridden: values.environment !== undefined,
verb: "smoke",
}),
);
}
// Resolved against the instance-wide application list, not the project's:
// that is what it did before this change and it is not this change's job to
// alter which app gets written to. It does mean the name is not actually a
// coordinate — one project's `core` and another's, or prod's and staging's
// on the same instance, are a coin flip. See #29; fixing it needs the
// read-side coordinates (--project/--environment) smoke does not yet have.
const apps = (await client.get("/applications")) as Array<{
uuid: string;
name: string;
}>;
const target = apps.find((a) => a.name === resolved.target);
if (!target) {
console.error(`smoke_target ${resolved.target} not found`);
return 2;
}
await smoke(client, target.uuid);
// Applications only. fetchLive returns every kind in the environment, and a
// service or database called `core` is not a smoke target — it is a 404 on an
// endpoint that does not exist for it (see renderAbsentSmokeTarget).
const app = lookup.live.find(
(l) => l.kind === "application" && l.name === target,
);
if (!app) {
console.error(
renderAbsentSmokeTarget(target, lookup.live, {
orgRepo,
env: envName,
project: projectName,
environment: coolifyEnv,
}),
);
return 2;
}
await smoke(client, app.uuid);
return 0;
}
if (command === "team") {

View file

@ -5,6 +5,7 @@ import {
githubAppNameFor,
loadBindings,
projectBindingFor,
projectsIn,
smokeTargetFor,
} from "../src/bindings.js";
@ -17,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: {
@ -31,7 +29,6 @@ function withProjects(
staging: { server: "staging-box", team: { id: 0, name: "Root Team" } },
},
github_apps: {},
...(smoke_target ? { smoke_target } : {}),
} as Bindings;
}
@ -139,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");
});
});
@ -206,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", {
@ -223,3 +232,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-<repo> 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-<repo> 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-<repo> 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 <repo> is unique only within an
// org — which is exactly why it is not a key.
it("refuses a bare <repo> 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 <org>\/<repo> 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([]);
});
});
});

View file

@ -90,9 +90,11 @@ function stateWith(opts: {
" server: prod-box",
" team: { id: 0, name: Root Team }",
...(opts.boundInstance ? [` instance: ${opts.boundInstance}`] : []),
" projects:",
" heavy-duty/incubator:",
" smoke_target: core",
"github_apps:",
" incubator: hdb-coolify",
"smoke_target: core",
"",
].join("\n"),
);
@ -135,10 +137,20 @@ describe("infra cli", () => {
expect(r.output).toMatch(/--env/);
});
it("refuses smoke without --env, exit non-zero", async () => {
const r = await runCli(["smoke"]);
const r = await runCli(["smoke", "heavy-duty/incubator"]);
expect(r.code).not.toBe(0);
expect(r.output).toMatch(/--env/);
});
// The repo is the PROJECT, and the project is half of the only scope in which
// `smoke_target: core` names anything (#29). `cast smoke --env prod` used to
// run — resolving the name against every application on the instance — which
// is precisely the invocation that could write prod's `core` while smoking
// staging. It is now a usage error, before any state is even read.
it("refuses smoke without the <org>/<repo> positional, exit non-zero", async () => {
const r = await runCli(["smoke", "--env", "prod"]);
expect(r.code).toBe(2);
expect(r.output).toMatch(/cast smoke\s+<org>\/<repo>/);
});
});
describe("--instance (multiple Coolify instances)", () => {
@ -235,6 +247,7 @@ describe("--instance (multiple Coolify instances)", () => {
});
const r = await runCli([
"smoke",
"heavy-duty/incubator",
"--state",
dir,
"--env",

365
test/smoke-cli.test.ts Normal file
View file

@ -0,0 +1,365 @@
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
// `cast smoke`, end to end, against the instance shape #29 is actually about.
//
// ONE Coolify, carrying:
//
// project incubator / environment prod → application `core` (a-prod-core)
// project incubator / environment staging → application `core` (a-staging-core)
// → database `db`
// project client-site / environment staging → application `core` (a-client-core)
//
// Three applications called `core`. That is not a contrived box — `instance:` is
// a per-environment binding, so with none set, prod and staging read the same
// .coolify.env and live on the same control plane. Until this fix, smoke resolved
// its target against GET /applications (every app the token can see, all projects,
// all environments) and wrote to the FIRST name match — so this stub lists prod's
// `core` first, which is what `cast smoke --env staging` would have written its
// canary vars onto, and (on the failure path) left them on.
//
// The wire is the witness in every test below: which uuid was written to, and —
// just as load-bearing — that the instance-wide list was never asked for at all.
type Stub = {
url: string;
hits: string[];
writes: string[];
close: () => Promise<void>;
};
const stubs: Stub[] = [];
type EnvVar = { key: string; value: string; is_buildtime: boolean };
async function stubCoolify(): Promise<Stub> {
const hits: string[] = [];
const writes: string[] = [];
// One env store per application, so a write to the wrong `core` is visible as
// a write to the wrong uuid rather than as nothing at all.
const envs: Record<string, Array<EnvVar & { uuid: string }>> = {
"a-prod-core": [],
"a-staging-core": [],
"a-client-core": [],
};
let nextUuid = 1;
const server = createServer((req, res) => {
const method = req.method ?? "GET";
const path = (req.url ?? "").replace("/api/v1", "");
hits.push(`${method} ${path}`);
if (method !== "GET") writes.push(`${method} ${path}`);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
const app = (name: string, uuid: string) => ({ name, uuid });
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/version") {
res.writeHead(200, { "content-type": "text/plain" });
return res.end("4.1.2");
}
if (path === "/projects")
return json([
{ uuid: "p-inc", name: "incubator" },
{ uuid: "p-cli", name: "client-site" },
]);
// The lookup this fix REPLACES. Answered anyway (prod's `core` first, as
// Coolify would list the older resource) so the tests can assert cast never
// asks for it — a stub that 404'd here would prove only that cast survives
// the 404.
if (path === "/applications")
return json([
app("core", "a-prod-core"),
app("core", "a-client-core"),
app("core", "a-staging-core"),
]);
if (path === "/projects/p-inc/prod")
return json({ applications: [app("core", "a-prod-core")] });
if (path === "/projects/p-inc/staging")
return json({
applications: [app("core", "a-staging-core")],
postgresqls: [app("db", "d-staging")],
});
if (path === "/projects/p-cli/staging")
return json({ applications: [app("core", "a-client-core")] });
const env = path.match(/^\/applications\/([^/]+)\/envs(\/(.+))?$/);
if (env) {
const store = envs[env[1]];
if (!store) {
res.writeHead(404);
return res.end("{}");
}
const rest = env[3];
if (method === "GET" && !rest) return json(store);
if (method === "POST" && !rest) {
let body = "";
req.on("data", (d) => {
body += String(d);
});
return req.on("end", () => {
const v = JSON.parse(body) as EnvVar;
const created = { ...v, uuid: `e-${nextUuid++}` };
store.push(created);
json(created);
});
}
// Upsert, mirroring verified Coolify 4.1.2 behavior — the property `smoke`
// exists to keep checking (see src/smoke.ts).
if (method === "PATCH" && rest === "bulk") {
let body = "";
req.on("data", (d) => {
body += String(d);
});
return req.on("end", () => {
const { data } = JSON.parse(body) as { data: EnvVar[] };
for (const v of data) {
const existing = store.find((e) => e.key === v.key);
if (existing) Object.assign(existing, v);
else store.push({ ...v, uuid: `e-${nextUuid++}` });
}
json({ ok: true });
});
}
if (method === "DELETE" && rest) {
envs[env[1]] = store.filter((e) => e.uuid !== rest);
res.writeHead(204);
return res.end();
}
}
res.writeHead(404);
res.end("{}");
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
writes,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
// `targets` is the knob: what each project's binding says `smoke` should write
// to. smoke needs no manifest, no checkout, no secret store and no age key — it
// reads the state file and the live box, and nothing else.
function state(
url: string,
targets: Record<string, string> = {
"heavy-duty/incubator": "core",
"acme/client-site": "core",
},
): string {
const dir = mkdtempSync(join(tmpdir(), "cast-smoke-"));
writeFileSync(
join(dir, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
const projects = Object.entries(targets).flatMap(([repo, target]) => [
` ${repo}:`,
` smoke_target: ${target}`,
]);
writeFileSync(
join(dir, "environments.yaml"),
[
"environments:",
" staging:",
" server: shared-box",
" team: { id: 0, name: Root Team }",
" projects:",
...projects,
" prod:",
" server: shared-box",
" team: { id: 0, name: Root Team }",
" projects:",
...projects,
"github_apps:",
" incubator: hdb-coolify",
"",
].join("\n"),
);
return dir;
}
function run(args: string[]): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", "smoke", ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
describe("cast smoke — resolved inside its project + environment (#29)", () => {
it("writes to THIS environment's app, not the first `core` the instance lists", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"staging",
]);
expect(r.code).toBe(0);
expect(r.output).toMatch(/smoke OK/);
// THE POINT. Every mutation landed on staging's `core` — and prod's, which
// the instance-wide lookup would have picked first, was never touched.
expect(stub.writes.length).toBeGreaterThan(0);
for (const w of stub.writes) expect(w).toContain("a-staging-core");
expect(stub.writes.join("\n")).not.toContain("a-prod-core");
expect(stub.writes.join("\n")).not.toContain("a-client-core");
// And the namespace that made prod reachable at all was never even asked
// for: the target is resolved through the project, like every other verb's.
expect(stub.hits).not.toContain("GET /applications");
expect(stub.hits).toContain("GET /projects/p-inc/staging");
});
it("follows --env to the other environment of the same project", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"prod",
]);
expect(r.code).toBe(0);
for (const w of stub.writes) expect(w).toContain("a-prod-core");
expect(stub.writes.join("\n")).not.toContain("a-staging-core");
});
// The other half of the coordinate: same environment, same instance, same app
// name — a different project, and therefore a different application.
it("follows the repo to the other project's app of the same name", async () => {
const stub = await stubCoolify();
const r = await run([
"acme/client-site",
"--state",
state(stub.url),
"--env",
"staging",
]);
expect(r.code).toBe(0);
for (const w of stub.writes) expect(w).toContain("a-client-core");
expect(stub.writes.join("\n")).not.toContain("a-staging-core");
});
it("reads the box's project and environment names when they are not ours", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"staging",
// `staging` is OURS: it selects the binding and the team to assert. The
// box calls this project's environment `prod`, and only the box's name
// goes on the wire.
"--project",
"incubator",
"--environment",
"prod",
]);
expect(r.code).toBe(0);
for (const w of stub.writes) expect(w).toContain("a-prod-core");
});
});
describe("cast smoke — refusing rather than guessing (#29)", () => {
it("refuses when this project + environment holds no app of that name, and says what it does hold", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url, { "heavy-duty/incubator": "web" }),
"--env",
"staging",
]);
expect(r.code).toBe(2);
expect(r.output).toContain('holds no application named "web"');
// What IS there — the finding, and the whole reason this is not a 404.
expect(r.output).toMatch(/exists here:\s+core/);
expect(r.output).toContain("smoke_target");
// Not "…so I looked on the rest of the instance and found one". An app in
// another project is a different app, and this verb writes.
expect(stub.hits).not.toContain("GET /applications");
expect(stub.writes).toEqual([]);
});
it("refuses a target that exists here but is not an application", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url, { "heavy-duty/incubator": "db" }),
"--env",
"staging",
]);
expect(r.code).toBe(2);
// smoke POSTs to /applications/<uuid>/envs. Pointed at the postgres, it
// would 404 on an endpoint that does not exist for a database, and the
// operator would debug the status code instead of the name.
expect(r.output).toMatch(/"db" DOES exist here — as a database/);
expect(r.output).toMatch(/not an\s+application/);
expect(r.output).toContain("/envs endpoint");
expect(stub.writes).toEqual([]);
});
// The project/environment refusal, reached through the same fetchLive every
// read-side verb uses — so smoke inherits it verbatim (see renderAbsentTarget).
it("refuses an absent environment as absent, naming --environment", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"staging",
"--environment",
"production",
]);
expect(r.code).toBe(2);
expect(r.output).toContain("refusing to smoke");
expect(r.output).toContain('has no environment "production"');
expect(stub.writes).toEqual([]);
});
it("refuses when the project declares no smoke_target at all", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url, { "acme/client-site": "core" }),
"--env",
"staging",
]);
expect(r.code).toBe(2);
expect(r.output).toContain("no smoke_target for heavy-duty/incubator");
expect(r.output).toContain("smoke_target: <the application's name>");
expect(stub.writes).toEqual([]);
});
});