smoke resolves its target inside the project it was declared under (#29) #31
7 changed files with 1166 additions and 121 deletions
81
README.md
81
README.md
|
|
@ -51,6 +51,8 @@ environments.yaml # bindings: the team each env's token must belon
|
|||
# which server it deploys onto, the S3 destination,
|
||||
# GitHub App name, guards — and, per project,
|
||||
# the destination it deploys onto + its smoke target
|
||||
# …plus `projects:`, the registry: which projects
|
||||
# exist, and in which environments
|
||||
secrets/<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
293
src/bindings.ts
293
src/bindings.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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") {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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