feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f7670938ee
commit
8deaeac07b
11 changed files with 1062 additions and 18 deletions
76
README.md
76
README.md
|
|
@ -49,7 +49,8 @@ infrastructure can be re-pointed at a new Coolify without touching a product.
|
|||
```
|
||||
environments.yaml # bindings: the team each env's token must belong to,
|
||||
# which server it deploys onto, the S3 destination,
|
||||
# GitHub App name, smoke target, guards
|
||||
# GitHub App name, guards — and, per project,
|
||||
# the destination it deploys onto + its smoke target
|
||||
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)
|
||||
|
|
@ -65,7 +66,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 --env <env>
|
||||
cast smoke [<org>/<repo>] --env <env>
|
||||
cast team [--env <env>]
|
||||
```
|
||||
|
||||
|
|
@ -86,10 +87,12 @@ cast team [--env <env>]
|
|||
writes the environment's age store from it. See *Adopting a hand-built
|
||||
instance* below.
|
||||
- **`server add`** — uploads a server's private key and registers it with Coolify.
|
||||
- **`smoke`** — contract test against `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.
|
||||
- **`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.
|
||||
- **`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
|
||||
|
|
@ -322,6 +325,67 @@ 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.
|
||||
|
||||
## Two projects, one box: destinations
|
||||
|
||||
A **destination** is the Docker network a resource is created on. A server has a
|
||||
default one, and while a server hosts a single project that default is the right
|
||||
answer — which is why cast went so long without naming it.
|
||||
|
||||
The moment a server hosts *two* projects, it stops being: they share one network,
|
||||
and "isolated" becomes a thing you believe rather than a thing that is true. So
|
||||
the destination is declared per **project**, inside the environment — an
|
||||
environment-scoped key could not express it, because `server:` is precisely the
|
||||
thing two projects share:
|
||||
|
||||
```yaml
|
||||
environments:
|
||||
prod:
|
||||
server: shared-box
|
||||
team: { id: 1, name: heavy-duty }
|
||||
projects:
|
||||
heavy-duty/incubator:
|
||||
destination_uuid: <uuid> # the network THIS project's resources go on
|
||||
smoke_target: core # the app `cast smoke` writes its canary to
|
||||
acme/client-site:
|
||||
destination_uuid: <other>
|
||||
```
|
||||
|
||||
Keyed by repo, full `<org>/<repo>` slug first, exactly like `github_apps` — a
|
||||
bare `<repo>` key still resolves, so existing state files keep working. Both
|
||||
fields are optional, and an environment whose server hosts one project needs
|
||||
neither.
|
||||
|
||||
A **UUID and not a name**, unlike `server:` right above it. Coolify 4.1.2 has no
|
||||
destinations API whatsoever — no list, no read, nothing — so there is no name for
|
||||
cast to resolve. You read the UUID out of the Coolify UI, the same way you do for
|
||||
`s3_destination`.
|
||||
|
||||
**What cast can and cannot promise here.** It sends `destination_uuid` on create,
|
||||
for applications, databases and services alike. It can never check it afterwards:
|
||||
Coolify takes a UUID on write and hands back an integer `destination_id` on read,
|
||||
and nothing maps between them. So `diff` does the one honest thing left — it
|
||||
groups the live resources by the id Coolify *does* report, and a project whose
|
||||
resources do not all share one network is **drift**:
|
||||
|
||||
```
|
||||
split placement: these resources sit on 2 different destinations
|
||||
destination 1: application landing, database postgres
|
||||
destination 4: application core
|
||||
a project's resources must share one destination — that is what the isolation IS.
|
||||
apply never moves a live resource between networks: resolve manually (runbook act).
|
||||
```
|
||||
|
||||
…and when you declare a destination, every `diff` says, out loud, that it did not
|
||||
verify it. That is deliberate. A setting that reads back as *absent* rather than
|
||||
*wrong* is the failure this whole file keeps trying not to be.
|
||||
|
||||
One sharp edge worth knowing: on a server with exactly **one** destination,
|
||||
Coolify ignores the `destination_uuid` you send and never validates it — a typo
|
||||
there is invisible until a second destination exists. On a server with more than
|
||||
one, a create that omits it is a hard `400`, which is why cast could not deploy
|
||||
onto a shared box at all until it could send this. Details, with citations:
|
||||
[reference/README.md](reference/README.md).
|
||||
|
||||
## Guarding an environment
|
||||
|
||||
An environment may refuse variables by name pattern:
|
||||
|
|
|
|||
|
|
@ -158,6 +158,46 @@ 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.
|
||||
|
||||
## Placement (destinations)
|
||||
|
||||
A **destination** is the Docker network a resource is created on. It is declared
|
||||
per project — `environments.<env>.projects.<repo>.destination_uuid` — because a
|
||||
destination is scoped project × environment, and the environment block above it
|
||||
says `server:`, which is exactly what two projects share.
|
||||
|
||||
**Enforced once, at create.** `apply` sends `destination_uuid` on every create
|
||||
(applications, databases, services — Coolify runs identical destination logic in
|
||||
all three controllers). It is never sent on update, and `apply` never moves a
|
||||
live resource between networks.
|
||||
|
||||
**Not comparable, and therefore reported rather than compared.** Coolify 4.1.2
|
||||
accepts a `destination_uuid` on write and returns a `destination_id` (an integer
|
||||
primary key) on read, exposes no endpoint mapping one to the other, and in fact
|
||||
has no destinations API at all (zero routes at `v4.1.2`). So the declared UUID
|
||||
**cannot be verified against the live resource it was sent for** — by cast or by
|
||||
anything else. Two consequences, both deliberate:
|
||||
|
||||
- `diff` never diffs the destination as a field. Doing so would compare a UUID
|
||||
against an int and report drift that could never be resolved — a phantom
|
||||
"update" on every run.
|
||||
- `diff` instead groups live resources by the `destination_id` Coolify *does*
|
||||
report. That int is opaque, but it is comparable **to itself**, which catches
|
||||
the thing worth catching: a project whose resources do not all sit on one
|
||||
network is a project whose isolation is broken. That is `split placement`, and
|
||||
it is drift — non-clean, reported, and **not repaired** (same disposition as an
|
||||
orphan).
|
||||
- Whenever a destination is declared, `diff` says explicitly that it was *not*
|
||||
compared. Silence would make an unverified setting read as a verified one —
|
||||
the failure shape this document exists to avoid.
|
||||
|
||||
**Coolify's create-time behavior** (`ApplicationsController` ~L1003,
|
||||
`DatabasesController` ~L1700, `ServicesController` ~L378 @ v4.1.2): a server with
|
||||
**one** destination uses it and *ignores* any `destination_uuid` sent, never
|
||||
validating it — so a typo is invisible there. A server with **more than one**
|
||||
rejects a create that omits it (`400`), and rejects a UUID that belongs to
|
||||
another server (`422`). The second case is why cast could not apply to a shared
|
||||
box at all before this field existed. Citations: `reference/README.md`.
|
||||
|
||||
## Instance selection
|
||||
|
||||
**The Coolify a command talks to is an explicit, named value** — not a property
|
||||
|
|
|
|||
|
|
@ -28,3 +28,47 @@ Storages). No S3 storage API exists in 4.1.2 — the destination UUID is
|
|||
recorded in environments.yaml by the bootstrap runbook; the client
|
||||
deliberately has no storage resolver. Re-check on any Coolify upgrade
|
||||
re-vendor.
|
||||
|
||||
## Known gap: a resource's destination is write-only (verified 2026-07-13)
|
||||
|
||||
The **destination** (the Docker network a resource is created on — a
|
||||
`StandaloneDocker`, not the S3 thing above) can be *set* by the API and never
|
||||
*read back*. Three separate facts, all verified at tag `v4.1.2`:
|
||||
|
||||
- **No destinations API at all.** `routes/api.php` has zero routes matching
|
||||
`destination` — not list, not read, not create. So a destination name cannot
|
||||
be resolved to anything; only a raw UUID, read out of the UI, identifies one.
|
||||
(Same shape as the S3 gap above, same consequence: `environments.yaml` records
|
||||
the UUID, and cast has no resolver.)
|
||||
- **Write accepts `destination_uuid`** on create for applications
|
||||
(`/applications/*`), all eight database types, and `/services`; and on PATCH
|
||||
for applications and services.
|
||||
- **Read returns `destination_id` + `destination_type`** — the integer primary
|
||||
key and the morph class (`App\Models\StandaloneDocker`), never the UUID. They
|
||||
survive serialization (none of the three controllers' `removeSensitiveData()`
|
||||
hides them), but `ProjectController@environment_details` does not eager-load
|
||||
the `destination` relation, and nothing else exposes it.
|
||||
|
||||
**Nothing maps an int to a UUID**, so a declared `destination_uuid` cannot be
|
||||
compared against the live resource it was sent for. `diff` therefore *reports*
|
||||
placement rather than comparing it (see `Placement` in `src/diff.ts`): it groups
|
||||
live resources by `destination_id` — which is comparable to itself — so a
|
||||
project whose resources do not all share one network is still caught, and it
|
||||
says plainly that the declared UUID was not verified.
|
||||
|
||||
Coolify's own create-time behavior, identical in `ApplicationsController`
|
||||
(~L1003), `DatabasesController` (~L1700) and `ServicesController` (~L378):
|
||||
|
||||
| server has | `destination_uuid` sent | result |
|
||||
| --- | --- | --- |
|
||||
| 0 destinations | anything | `400 Server has no destinations.` |
|
||||
| exactly 1 | omitted | `$destinations->first()` — the default network |
|
||||
| exactly 1 | **any value** | **ignored, never validated** — `first()` again |
|
||||
| >1 | omitted | `400 Server has multiple destinations and you do not set destination_uuid.` |
|
||||
| >1 | not on that server | `422 Provided destination_uuid does not belong to the specified server.` |
|
||||
|
||||
Two consequences worth keeping in mind. A **single-destination server silently
|
||||
accepts a wrong UUID** — neither Coolify nor cast can catch that, and it is why
|
||||
the declared value is never trusted as verified. And a **multi-destination
|
||||
server rejects a create that omits it**, which is why cast could not deploy onto
|
||||
a shared box at all until it could send this field.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,42 @@ const TeamSchema = z
|
|||
message: "team must give at least one of `id` or `name`",
|
||||
});
|
||||
|
||||
// State that belongs to ONE project inside ONE environment — not to the
|
||||
// environment as a whole.
|
||||
//
|
||||
// The environment block above it is the wrong scope for any of this, and was
|
||||
// always going to be: it says `server:`, and a server is exactly the thing two
|
||||
// projects can share. Everything here is keyed the way `github_apps` is (see
|
||||
// githubAppNameFor) — by the repo, full `<org>/<repo>` slug first — because the
|
||||
// repo is what identifies a project to US. The Coolify project NAME is theirs,
|
||||
// it is what someone typed into a UI, and `--project` exists precisely because
|
||||
// it does not have to match.
|
||||
const ProjectBindingSchema = z
|
||||
.object({
|
||||
// The Docker network this project's resources are created on — a raw UUID,
|
||||
// read out of the Coolify UI, exactly like `s3_destination`.
|
||||
//
|
||||
// A UUID and not a name, deliberately, even though `server:` right above is
|
||||
// a name: Coolify 4.1.2 has NO destinations API at all (zero routes in
|
||||
// routes/api.php @ v4.1.2), so unlike a server name, cast cannot resolve a
|
||||
// destination name to anything. A key called `destination` would invite one.
|
||||
// See reference/README.md.
|
||||
//
|
||||
// Optional, and absent means "whatever the server's only destination is" —
|
||||
// which is correct until the server has two, and is why this went unnoticed:
|
||||
// Coolify picks `$destinations->first()` when a server has exactly one.
|
||||
destination_uuid: z.string().optional(),
|
||||
// The app `cast smoke` writes its canary env vars to. Project-scoped
|
||||
// because it names one project's application: `core` is incubator's compose
|
||||
// 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.
|
||||
smoke_target: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ProjectBinding = z.infer<typeof ProjectBindingSchema>;
|
||||
|
||||
const BindingsSchema = z
|
||||
.object({
|
||||
environments: z.record(
|
||||
|
|
@ -58,6 +94,9 @@ const BindingsSchema = z
|
|||
// assertEnvVarPolicy). Operator-owned guard: prod typically bans
|
||||
// whatever family of flags enables destructive tooling.
|
||||
forbidden_var_patterns: z.array(z.string()).optional(),
|
||||
// Per-project state, keyed by repo. Optional: an environment whose
|
||||
// server hosts one project needs none of it.
|
||||
projects: z.record(ProjectBindingSchema).optional(),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
|
|
@ -65,6 +104,12 @@ const BindingsSchema = z
|
|||
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
|
||||
// existing state files keep working.
|
||||
github_apps: z.record(z.string()),
|
||||
// DEPRECATED — moved to environments.<env>.projects.<repo>.smoke_target.
|
||||
// Still read (see smokeTargetFor) so state files written before the move
|
||||
// keep working, on the same reasoning as the bare-`<repo>` github_apps key.
|
||||
// It is wrong at TWO levels: it names one project's app (`core`) from a key
|
||||
// scoped to the whole state file, so it cannot distinguish two projects and
|
||||
// cannot distinguish prod's app from staging's either.
|
||||
smoke_target: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -104,6 +149,45 @@ export function githubAppNameFor(bindings: Bindings, orgRepo: string): string {
|
|||
return name;
|
||||
}
|
||||
|
||||
// The project-scoped bindings for one repo in one environment, or undefined if
|
||||
// the environment declares none. Same full-slug-then-bare-repo lookup as
|
||||
// githubAppNameFor, for the same reason — see the note there.
|
||||
//
|
||||
// Absence is NOT an error: `projects:` is optional, and an environment with a
|
||||
// single project on a single-destination server has nothing to say here. The
|
||||
// callers that genuinely need a value (smoke) say so themselves.
|
||||
export function projectBindingFor(
|
||||
bindings: Bindings,
|
||||
envName: string,
|
||||
orgRepo: string,
|
||||
): ProjectBinding | undefined {
|
||||
const projects = bindings.environments[envName]?.projects;
|
||||
if (!projects) return undefined;
|
||||
const repoShort = orgRepo.split("/")[1] ?? orgRepo;
|
||||
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.
|
||||
//
|
||||
// `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.
|
||||
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;
|
||||
}
|
||||
|
||||
export function loadBindings(
|
||||
path: string,
|
||||
opts: { overrideText?: string } = {},
|
||||
|
|
|
|||
107
src/cli.ts
107
src/cli.ts
|
|
@ -5,7 +5,12 @@ import { createInterface } from "node:readline/promises";
|
|||
import { parseArgs } from "node:util";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
|
||||
import { githubAppNameFor, loadBindings } from "./bindings.js";
|
||||
import {
|
||||
githubAppNameFor,
|
||||
loadBindings,
|
||||
projectBindingFor,
|
||||
smokeTargetFor,
|
||||
} from "./bindings.js";
|
||||
import {
|
||||
type LiveEnvs,
|
||||
absentResources,
|
||||
|
|
@ -50,7 +55,7 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
|
|||
cast capture <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--generated <NAME>] [--override <NAME>] [--force]
|
||||
cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>]
|
||||
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
|
||||
cast smoke --env <env>
|
||||
cast smoke [<org>/<repo>] --env <env>
|
||||
cast team [--env <env>]
|
||||
|
||||
--state <dir> the state checkout holding environments.yaml, secrets/ and
|
||||
|
|
@ -316,6 +321,14 @@ export async function fetchLive(
|
|||
uuid: String(i.uuid),
|
||||
fields: projectLiveFields(kind, i),
|
||||
env: undefined, // populated per-resource below only in full mode by caller
|
||||
// The one thing Coolify will tell us about placement. `destination_id` is
|
||||
// a plain column on all three resource tables and none of the three
|
||||
// controllers' removeSensitiveData() hides it (v4.1.2), so it survives
|
||||
// into this response — whereas the destination's UUID never appears in
|
||||
// any response at all, because environment_details does not eager-load
|
||||
// the `destination` relation and no endpoint exposes it. See Placement.
|
||||
destinationId:
|
||||
typeof i.destination_id === "number" ? i.destination_id : undefined,
|
||||
}));
|
||||
return {
|
||||
found: true,
|
||||
|
|
@ -603,6 +616,10 @@ async function main(): Promise<number> {
|
|||
return 2;
|
||||
}
|
||||
assertEnvVarPolicy(envName, resolvedEnvs, binding.forbidden_var_patterns);
|
||||
// Keyed by the REPO, not by --project: --project is the name Coolify's own
|
||||
// UI happens to use for this project, and cast's state is keyed by the name
|
||||
// WE own (same split as the secrets file — see the --project note above).
|
||||
const projectBinding = projectBindingFor(bindings, envName, orgRepo);
|
||||
if (values["hostname-overlay"]) {
|
||||
desired = applyHostnameOverlay(
|
||||
desired,
|
||||
|
|
@ -655,7 +672,9 @@ async function main(): Promise<number> {
|
|||
l.env = await fetchEnv(client, l);
|
||||
}
|
||||
}
|
||||
const report = computeDiff(desired, live, mode);
|
||||
const report = computeDiff(desired, live, mode, {
|
||||
declaredDestination: projectBinding?.destination_uuid,
|
||||
});
|
||||
console.log(renderDiff(report));
|
||||
if (command === "diff") return report.clean ? 0 : 1;
|
||||
const serverUuid = await client.serverUuid(binding.server);
|
||||
|
|
@ -670,6 +689,7 @@ async function main(): Promise<number> {
|
|||
envName: coolifyEnv,
|
||||
serverUuid,
|
||||
githubAppUuid,
|
||||
destinationUuid: projectBinding?.destination_uuid,
|
||||
s3DestinationUuid: binding.s3_destination,
|
||||
backupSchedules,
|
||||
});
|
||||
|
|
@ -986,7 +1006,7 @@ async function main(): Promise<number> {
|
|||
return 0;
|
||||
}
|
||||
if (command === "smoke") {
|
||||
const { values } = parseArgs({
|
||||
const { values, positionals } = parseArgs({
|
||||
args: rest,
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
|
|
@ -995,6 +1015,11 @@ async function main(): Promise<number> {
|
|||
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
|
||||
|
|
@ -1018,20 +1043,53 @@ async function main(): Promise<number> {
|
|||
assertWritable(instance, "smoke");
|
||||
const team = await assertTeam(client, binding.team, values.env);
|
||||
console.log(`team ${formatTeam(team)} ✓`);
|
||||
if (!bindings.smoke_target) {
|
||||
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",
|
||||
];
|
||||
console.error(
|
||||
"environments.yaml: smoke_target (app name) required for smoke",
|
||||
[
|
||||
`no smoke_target for ${values.env}`,
|
||||
"",
|
||||
...lookedFor,
|
||||
"",
|
||||
"`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:",
|
||||
"",
|
||||
" environments:",
|
||||
` ${values.env}:`,
|
||||
" projects:",
|
||||
` ${smokeRepo ?? "<org>/<repo>"}:`,
|
||||
" smoke_target: <the application's name>",
|
||||
].join("\n"),
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
// resolve app uuid by name across the project list
|
||||
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\`.`,
|
||||
);
|
||||
}
|
||||
// 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 two projects with an app of
|
||||
// the same name are a coin flip — filed separately.
|
||||
const apps = (await client.get("/applications")) as Array<{
|
||||
uuid: string;
|
||||
name: string;
|
||||
}>;
|
||||
const target = apps.find((a) => a.name === bindings.smoke_target);
|
||||
const target = apps.find((a) => a.name === resolved.target);
|
||||
if (!target) {
|
||||
console.error(`smoke_target ${bindings.smoke_target} not found`);
|
||||
console.error(`smoke_target ${resolved.target} not found`);
|
||||
return 2;
|
||||
}
|
||||
await smoke(client, target.uuid);
|
||||
|
|
@ -1191,10 +1249,38 @@ export function buildExecutor(
|
|||
envName: string;
|
||||
serverUuid: string;
|
||||
githubAppUuid: string;
|
||||
// The Docker network to create resources on. A raw UUID from
|
||||
// environments.yaml for the same reason s3DestinationUuid is one: Coolify
|
||||
// 4.1.2 has no destinations API, so there is no name for cast to resolve.
|
||||
//
|
||||
// Create-time ONLY, and every kind gets it (Coolify's three controllers run
|
||||
// identical destination logic). Undefined means "the server's only
|
||||
// destination", which is what Coolify picks anyway — and, until a server
|
||||
// hosts two projects, is the right answer.
|
||||
destinationUuid?: string;
|
||||
s3DestinationUuid?: string; // raw UUID from environments.yaml — no storage API exists to resolve names
|
||||
backupSchedules: Record<string, { frequency: string; retention: number }>;
|
||||
},
|
||||
): Executor {
|
||||
// Coolify resolves this identically for applications, databases and services
|
||||
// (ApplicationsController ~1003, DatabasesController ~1700,
|
||||
// ServicesController ~378 @ v4.1.2):
|
||||
//
|
||||
// 0 destinations -> 400, whatever we send
|
||||
// >1 and no destination_uuid -> 400 "Server has multiple destinations and
|
||||
// you do not set destination_uuid"
|
||||
// >1 and a foreign uuid -> 422 "does not belong to the specified server"
|
||||
// exactly 1 -> $destinations->first(), and anything we
|
||||
// send here is IGNORED, not validated
|
||||
//
|
||||
// So this field is what makes cast able to create resources on a server that
|
||||
// has more than one destination AT ALL — without it, apply simply 400s there,
|
||||
// which is the state of things before this change. On a single-destination
|
||||
// server it is inert (and so, note, a WRONG uuid is silently accepted there —
|
||||
// nothing on either side can catch that; see renderDiff's placement note).
|
||||
const destination = ctx.destinationUuid
|
||||
? { destination_uuid: ctx.destinationUuid }
|
||||
: {};
|
||||
return {
|
||||
async createResource(change) {
|
||||
// Field payloads assembled from change.fieldDiffs (desired values):
|
||||
|
|
@ -1207,6 +1293,7 @@ export function buildExecutor(
|
|||
project_uuid: projectUuid,
|
||||
environment_name: ctx.envName,
|
||||
server_uuid: ctx.serverUuid,
|
||||
...destination,
|
||||
github_app_uuid: ctx.githubAppUuid,
|
||||
name: change.name,
|
||||
instant_deploy: false,
|
||||
|
|
@ -1228,6 +1315,7 @@ export function buildExecutor(
|
|||
project_uuid: projectUuid,
|
||||
environment_name: ctx.envName,
|
||||
server_uuid: ctx.serverUuid,
|
||||
...destination,
|
||||
name: change.name,
|
||||
...databaseApiFields(fields),
|
||||
},
|
||||
|
|
@ -1252,6 +1340,7 @@ export function buildExecutor(
|
|||
project_uuid: projectUuid,
|
||||
environment_name: ctx.envName,
|
||||
server_uuid: ctx.serverUuid,
|
||||
...destination,
|
||||
name: change.name,
|
||||
...serviceApiFields(fields),
|
||||
})) as { uuid: string };
|
||||
|
|
|
|||
92
src/diff.ts
92
src/diff.ts
|
|
@ -13,6 +13,15 @@ export type Live = {
|
|||
uuid: string;
|
||||
fields: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
// The destination (Docker network) Coolify reports this resource on.
|
||||
//
|
||||
// NOT in `fields`, because `fields` is the desired-vs-live comparison
|
||||
// vocabulary and this can never take part in it: Coolify 4.1.2 accepts
|
||||
// `destination_uuid` on write and returns `destination_id` (an integer
|
||||
// primary key) on read, and exposes no endpoint that maps one to the other.
|
||||
// Putting it in `fields` would diff a UUID against an int and report drift
|
||||
// that can never be resolved. See Placement.
|
||||
destinationId?: number;
|
||||
};
|
||||
export type FieldDiff = {
|
||||
field: string;
|
||||
|
|
@ -33,10 +42,32 @@ export type Change = {
|
|||
fieldDiffs: FieldDiff[];
|
||||
envDiffs: EnvDiff[];
|
||||
};
|
||||
// Where this project's resources actually sit, as far as Coolify will say.
|
||||
//
|
||||
// The destination cannot be diffed the way every other field is (see Live), so
|
||||
// the alternative was to leave it out of the report entirely — and a setting
|
||||
// that reads back as ABSENT rather than WRONG is the exact failure shape cast
|
||||
// keeps legislating against (#12, #14, #17, #18). So it is reported instead of
|
||||
// compared, and reported with the limit stated:
|
||||
//
|
||||
// - `declared` is what the state file asks for. cast sends it on create and
|
||||
// CANNOT check it afterwards. Never silently — renderDiff says so.
|
||||
// - `groups` is what Coolify answers, by `destination_id`. It is an opaque
|
||||
// int, but it is comparable to ITSELF, and that is enough to catch the
|
||||
// thing actually worth catching: a project whose resources do not all share
|
||||
// one network is a project whose isolation is broken, whatever the numbers
|
||||
// happen to be.
|
||||
export type Placement = {
|
||||
declared?: string;
|
||||
groups: { destinationId: number; resources: string[] }[];
|
||||
split: boolean;
|
||||
};
|
||||
|
||||
export type DiffReport = {
|
||||
mode: "structural" | "full";
|
||||
changes: Change[];
|
||||
orphans: { kind: ResourceKind; name: string; uuid: string }[];
|
||||
placement: Placement;
|
||||
clean: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -67,10 +98,33 @@ function diffEnv(
|
|||
return diffs;
|
||||
}
|
||||
|
||||
function computePlacement(live: Live[], declared?: string): Placement {
|
||||
const byDestination = new Map<number, string[]>();
|
||||
for (const l of live) {
|
||||
// Coolify returns destination_id on applications, databases and services
|
||||
// alike (none of the three controllers' removeSensitiveData hides it,
|
||||
// v4.1.2). A resource that reports none is not evidence of a split — it is
|
||||
// no evidence at all, so it is left out rather than grouped under a
|
||||
// fabricated id.
|
||||
if (typeof l.destinationId !== "number") continue;
|
||||
const at = byDestination.get(l.destinationId) ?? [];
|
||||
at.push(`${l.kind} ${l.name}`);
|
||||
byDestination.set(l.destinationId, at);
|
||||
}
|
||||
const groups = [...byDestination.entries()]
|
||||
.map(([destinationId, resources]) => ({
|
||||
destinationId,
|
||||
resources: resources.sort(),
|
||||
}))
|
||||
.sort((a, b) => a.destinationId - b.destinationId);
|
||||
return { declared, groups, split: groups.length > 1 };
|
||||
}
|
||||
|
||||
export function computeDiff(
|
||||
desired: Desired[],
|
||||
live: Live[],
|
||||
mode: "structural" | "full",
|
||||
opts: { declaredDestination?: string } = {},
|
||||
): DiffReport {
|
||||
const changes: Change[] = [];
|
||||
for (const d of desired) {
|
||||
|
|
@ -120,11 +174,16 @@ export function computeDiff(
|
|||
const orphans = live
|
||||
.filter((l) => !desired.some((d) => d.kind === l.kind && d.name === l.name))
|
||||
.map((l) => ({ kind: l.kind, name: l.name, uuid: l.uuid }));
|
||||
const placement = computePlacement(live, opts.declaredDestination);
|
||||
return {
|
||||
mode,
|
||||
changes,
|
||||
orphans,
|
||||
clean: changes.length === 0 && orphans.length === 0,
|
||||
placement,
|
||||
// A split project is drift, and drift is not clean — the same disposition
|
||||
// as an orphan: reported, counted, and NOT repaired (apply moves nothing
|
||||
// between networks; see renderDiff).
|
||||
clean: changes.length === 0 && orphans.length === 0 && !placement.split,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -156,10 +215,39 @@ export function renderDiff(report: DiffReport): string {
|
|||
`orphan ${o.kind} ${o.name} (live, not in manifest — removal is a manual runbook act)`,
|
||||
);
|
||||
}
|
||||
const { placement } = report;
|
||||
if (placement.split) {
|
||||
lines.push(
|
||||
`split placement: these resources sit on ${placement.groups.length} different destinations`,
|
||||
);
|
||||
for (const g of placement.groups)
|
||||
lines.push(` destination ${g.destinationId}: ${g.resources.join(", ")}`);
|
||||
lines.push(
|
||||
" a project's resources must share one destination — that is what the isolation IS.",
|
||||
" apply never moves a live resource between networks: resolve manually (runbook act).",
|
||||
);
|
||||
} else if (placement.declared && placement.groups.length === 1) {
|
||||
lines.push(
|
||||
`placement: all resources on destination ${placement.groups[0].destinationId}`,
|
||||
);
|
||||
}
|
||||
if (placement.declared) {
|
||||
// Said out loud on every run that declares one, rather than left to be
|
||||
// inferred from its absence. cast enforces this UUID exactly once — at
|
||||
// create — and can never check it again; an operator who thinks `diff`
|
||||
// covers it is an operator who thinks the isolation is verified.
|
||||
lines.push(
|
||||
`destination ${placement.declared} declared, NOT compared — Coolify 4.1.2 takes`,
|
||||
" destination_uuid on write and returns destination_id on read, and has no endpoint",
|
||||
" mapping one to the other. cast sends it on create; nothing can verify it after.",
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
report.clean
|
||||
? "clean"
|
||||
: `${report.changes.length} change(s), ${report.orphans.length} orphan(s)`,
|
||||
: `${report.changes.length} change(s), ${report.orphans.length} orphan(s)${
|
||||
placement.split ? ", split placement" : ""
|
||||
}`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { type Bindings, githubAppNameFor } from "../src/bindings.js";
|
||||
import {
|
||||
type Bindings,
|
||||
type ProjectBinding,
|
||||
githubAppNameFor,
|
||||
loadBindings,
|
||||
projectBindingFor,
|
||||
smokeTargetFor,
|
||||
} from "../src/bindings.js";
|
||||
|
||||
function bindings(github_apps: Record<string, string>): Bindings {
|
||||
return {
|
||||
|
|
@ -10,6 +17,24 @@ function bindings(github_apps: Record<string, string>): Bindings {
|
|||
} as Bindings;
|
||||
}
|
||||
|
||||
function withProjects(
|
||||
projects: Record<string, ProjectBinding>,
|
||||
smoke_target?: string,
|
||||
): Bindings {
|
||||
return {
|
||||
environments: {
|
||||
prod: {
|
||||
server: "shared-box",
|
||||
team: { id: 0, name: "Root Team" },
|
||||
projects,
|
||||
},
|
||||
staging: { server: "staging-box", team: { id: 0, name: "Root Team" } },
|
||||
},
|
||||
github_apps: {},
|
||||
...(smoke_target ? { smoke_target } : {}),
|
||||
} as Bindings;
|
||||
}
|
||||
|
||||
describe("githubAppNameFor", () => {
|
||||
it("resolves the full <org>/<repo> slug", () => {
|
||||
const b = bindings({ "heavy-duty/incubator": "hdb-coolify" });
|
||||
|
|
@ -60,3 +85,141 @@ describe("githubAppNameFor", () => {
|
|||
expect(err).toThrow(/heavy-duty\/other/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectBindingFor", () => {
|
||||
it("resolves the full slug, and a legacy bare <repo> key", () => {
|
||||
const bySlug = withProjects({
|
||||
"heavy-duty/incubator": { destination_uuid: "dest-a" },
|
||||
});
|
||||
const byShort = withProjects({ incubator: { destination_uuid: "dest-a" } });
|
||||
expect(
|
||||
projectBindingFor(bySlug, "prod", "heavy-duty/incubator")
|
||||
?.destination_uuid,
|
||||
).toBe("dest-a");
|
||||
expect(
|
||||
projectBindingFor(byShort, "prod", "heavy-duty/incubator")
|
||||
?.destination_uuid,
|
||||
).toBe("dest-a");
|
||||
});
|
||||
|
||||
// The reason the destination has to be project-scoped at all: one server, two
|
||||
// projects, two networks. An environment-scoped key could not say this.
|
||||
it("gives two projects on one server two different destinations", () => {
|
||||
const b = withProjects({
|
||||
"heavy-duty/incubator": { destination_uuid: "dest-incubator" },
|
||||
"acme/client-site": { destination_uuid: "dest-client" },
|
||||
});
|
||||
expect(
|
||||
projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid,
|
||||
).toBe("dest-incubator");
|
||||
expect(
|
||||
projectBindingFor(b, "prod", "acme/client-site")?.destination_uuid,
|
||||
).toBe("dest-client");
|
||||
});
|
||||
|
||||
it("prefers the full slug over a colliding bare key", () => {
|
||||
const b = withProjects({
|
||||
incubator: { destination_uuid: "legacy" },
|
||||
"heavy-duty/incubator": { destination_uuid: "dest-a" },
|
||||
});
|
||||
expect(
|
||||
projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid,
|
||||
).toBe("dest-a");
|
||||
});
|
||||
|
||||
// Absence is not an error: an environment whose server hosts one project has
|
||||
// nothing to declare, and that is the state of every box today.
|
||||
it("is undefined for an environment with no projects block", () => {
|
||||
const b = withProjects({ "heavy-duty/incubator": {} });
|
||||
expect(projectBindingFor(b, "staging", "heavy-duty/incubator")).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(projectBindingFor(b, "prod", "heavy-duty/other")).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
expect(
|
||||
smokeTargetFor(withProjects({}), "prod", "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.
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BindingsSchema (projects)", () => {
|
||||
it("parses a project-scoped destination and smoke_target", () => {
|
||||
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: {}
|
||||
`,
|
||||
});
|
||||
expect(projectBindingFor(b, "prod", "heavy-duty/incubator")).toEqual({
|
||||
destination_uuid: "dest-abc",
|
||||
smoke_target: "core",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown key under a project (a typo is not a placement)", () => {
|
||||
expect(() =>
|
||||
loadBindings("environments.yaml", {
|
||||
overrideText: `
|
||||
environments:
|
||||
prod:
|
||||
server: shared-box
|
||||
team: { id: 0, name: Root Team }
|
||||
projects:
|
||||
heavy-duty/incubator:
|
||||
destination: dest-abc
|
||||
github_apps: {}
|
||||
`,
|
||||
}),
|
||||
).toThrow(/invalid bindings/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -90,6 +90,127 @@ describe("computeDiff", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// The destination can never be diffed the way a field is: Coolify 4.1.2 takes
|
||||
// destination_uuid on write and returns destination_id on read, with nothing
|
||||
// mapping between them. So it is REPORTED rather than compared — and the one
|
||||
// thing that IS comparable (a project's live resources against each other)
|
||||
// carries the check that matters.
|
||||
//
|
||||
// Placement is measured against the live side ALONE, so these fixtures pair each
|
||||
// live resource with a matching desired one: otherwise every resource is an
|
||||
// orphan, and `clean` would be false for reasons that have nothing to do with
|
||||
// the destination.
|
||||
const want = (name: string) => ({
|
||||
kind: "application" as const,
|
||||
name,
|
||||
fields: {},
|
||||
});
|
||||
const got = (name: string, destinationId?: number) => ({
|
||||
kind: "application" as const,
|
||||
name,
|
||||
uuid: `u-${name}`,
|
||||
fields: {},
|
||||
destinationId,
|
||||
});
|
||||
|
||||
describe("computeDiff placement", () => {
|
||||
it("is not split when every resource shares one destination", () => {
|
||||
const r = computeDiff(
|
||||
[want("a"), want("b")],
|
||||
[got("a", 3), got("b", 3)],
|
||||
"structural",
|
||||
);
|
||||
expect(r.placement.split).toBe(false);
|
||||
expect(r.placement.groups).toEqual([
|
||||
{ destinationId: 3, resources: ["application a", "application b"] },
|
||||
]);
|
||||
expect(r.clean).toBe(true);
|
||||
});
|
||||
|
||||
// A project whose resources straddle two networks is a project whose
|
||||
// isolation is broken — drift, not silence. Same disposition as an orphan:
|
||||
// reported, counted, never repaired.
|
||||
it("reports a split project as drift, and is not clean", () => {
|
||||
const r = computeDiff(
|
||||
[want("a"), want("b")],
|
||||
[got("a", 3), got("b", 7)],
|
||||
"structural",
|
||||
);
|
||||
expect(r.placement.split).toBe(true);
|
||||
expect(r.placement.groups).toEqual([
|
||||
{ destinationId: 3, resources: ["application a"] },
|
||||
{ destinationId: 7, resources: ["application b"] },
|
||||
]);
|
||||
expect(r.clean).toBe(false);
|
||||
// ...and apply is not offered a way to "fix" it.
|
||||
expect(r.changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A resource Coolify reports no destination for is no evidence of a split.
|
||||
it("ignores resources with no destination rather than grouping them", () => {
|
||||
const r = computeDiff(
|
||||
[want("a"), want("b")],
|
||||
[got("a", 3), got("b")],
|
||||
"structural",
|
||||
);
|
||||
expect(r.placement.split).toBe(false);
|
||||
expect(r.placement.groups).toEqual([
|
||||
{ destinationId: 3, resources: ["application a"] },
|
||||
]);
|
||||
expect(r.clean).toBe(true);
|
||||
});
|
||||
|
||||
it("carries the declared destination through without comparing it", () => {
|
||||
const r = computeDiff([want("a")], [got("a", 3)], "structural", {
|
||||
declaredDestination: "dest-abc",
|
||||
});
|
||||
expect(r.placement.declared).toBe("dest-abc");
|
||||
// Declaring one does not make the project dirty — there is nothing to
|
||||
// compare it against, and a phantom "update" would never clear.
|
||||
expect(r.clean).toBe(true);
|
||||
expect(r.changes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDiff placement", () => {
|
||||
it("says out loud that a declared destination was NOT compared", () => {
|
||||
const out = renderDiff(
|
||||
computeDiff([want("a")], [got("a", 3)], "structural", {
|
||||
declaredDestination: "dest-abc",
|
||||
}),
|
||||
);
|
||||
expect(out).toContain("dest-abc");
|
||||
expect(out).toMatch(/NOT compared/);
|
||||
expect(out).toMatch(/placement: all resources on destination 3/);
|
||||
});
|
||||
|
||||
// The whole reason placement is in the report at all: a destination that read
|
||||
// back as absent rather than wrong is the failure shape #12/#14/#17/#18 are
|
||||
// about. Silence is the bug — but so is noise on the happy path.
|
||||
it("stays silent about placement when nothing is declared and nothing is split", () => {
|
||||
const out = renderDiff(
|
||||
computeDiff([want("a")], [got("a", 3)], "structural"),
|
||||
);
|
||||
expect(out).not.toMatch(/placement/);
|
||||
expect(out).toContain("clean");
|
||||
});
|
||||
|
||||
it("names every resource on each side of a split", () => {
|
||||
const out = renderDiff(
|
||||
computeDiff(
|
||||
[want("core"), want("landing")],
|
||||
[got("core", 3), got("landing", 7)],
|
||||
"structural",
|
||||
),
|
||||
);
|
||||
expect(out).toMatch(/split placement: these resources sit on 2 different/);
|
||||
expect(out).toContain("destination 3: application core");
|
||||
expect(out).toContain("destination 7: application landing");
|
||||
expect(out).toMatch(/apply never moves a live resource between networks/);
|
||||
expect(out).toMatch(/split placement$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDiff", () => {
|
||||
it("never prints secret values", () => {
|
||||
const live = [
|
||||
|
|
|
|||
4
test/fixtures/environments.yaml
vendored
4
test/fixtures/environments.yaml
vendored
|
|
@ -4,6 +4,10 @@ environments:
|
|||
team: { id: 1, name: heavy-duty }
|
||||
s3_destination: s3-backups
|
||||
forbidden_var_patterns: ["^ALLOW_"]
|
||||
projects:
|
||||
heavy-duty/widget:
|
||||
destination_uuid: dest-widget
|
||||
smoke_target: core
|
||||
staging:
|
||||
server: staging-vm
|
||||
team: { id: 1, name: heavy-duty }
|
||||
|
|
|
|||
232
test/placement-cli.test.ts
Normal file
232
test/placement-cli.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
// Placement, end to end: `environments.yaml` -> `cast diff` -> what it prints
|
||||
// and what it exits with. The unit tests prove each half (bindings resolve a
|
||||
// project's destination; computeDiff groups the live side by it) — this proves
|
||||
// they are actually wired to each other, which is the half a type checker
|
||||
// cannot see.
|
||||
//
|
||||
// The box here is the shape #21 is about: ONE server carrying more than one
|
||||
// project, so the server's default network is no longer the right answer for
|
||||
// anything on it.
|
||||
|
||||
let recipient: string;
|
||||
let keyFile: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||
keyFile = join(dir, "age.key");
|
||||
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
||||
recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
});
|
||||
|
||||
type Stub = { url: string; close: () => Promise<void> };
|
||||
const stubs: Stub[] = [];
|
||||
|
||||
// destinationIds is the knob: which network Coolify says each app is on. The
|
||||
// live payload is otherwise a byte-for-byte match for the manifest below, so
|
||||
// anything the diff reports is placement and nothing else.
|
||||
async function stubCoolify(destinationIds: {
|
||||
core: number | null;
|
||||
landing: number | null;
|
||||
}): Promise<Stub> {
|
||||
const app = (name: string, uuid: string, destination_id: number | null) => ({
|
||||
name,
|
||||
uuid,
|
||||
git_repository: "heavy-duty/incubator",
|
||||
git_branch: "main",
|
||||
build_pack: "nixpacks",
|
||||
base_directory: "/",
|
||||
fqdn: `http://${name}.example.com`,
|
||||
// Coolify returns this on every resource; `null` stands for the box that
|
||||
// somehow reports none.
|
||||
destination_id,
|
||||
});
|
||||
const server = createServer((req, res) => {
|
||||
const path = (req.url ?? "").replace("/api/v1", "");
|
||||
const json = (body: unknown) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
|
||||
if (path === "/projects") return json([{ uuid: "p1", name: "incubator" }]);
|
||||
if (path === "/projects/p1/staging")
|
||||
return json({
|
||||
applications: [
|
||||
app("core", "a1", destinationIds.core),
|
||||
app("landing", "a2", destinationIds.landing),
|
||||
],
|
||||
});
|
||||
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}`,
|
||||
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()));
|
||||
});
|
||||
|
||||
const MANIFEST = `project: incubator
|
||||
environments:
|
||||
staging:
|
||||
applications:
|
||||
core:
|
||||
source: { repo: heavy-duty/incubator, branch: main }
|
||||
build: { pack: nixpacks, base_directory: / }
|
||||
domains: ["http://core.example.com"]
|
||||
landing:
|
||||
source: { repo: heavy-duty/incubator, branch: main }
|
||||
build: { pack: nixpacks, base_directory: / }
|
||||
domains: ["http://landing.example.com"]
|
||||
`;
|
||||
|
||||
// `declared` is the destination_uuid the state file names for this project —
|
||||
// undefined means the state file says nothing, which is every state file today.
|
||||
function fixture(url: string, declared?: string) {
|
||||
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
||||
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
mkdirSync(join(state, "secrets"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
||||
);
|
||||
// No template refs any secret, but the store still has to exist and open —
|
||||
// diff resolves the environment's secrets before it reads anything live.
|
||||
execFileSync("age", ["-r", recipient, "-o", "incubator.staging.env.age"], {
|
||||
input: "\n",
|
||||
cwd: join(state, "secrets"),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
writeFileSync(
|
||||
join(state, "environments.yaml"),
|
||||
[
|
||||
"environments:",
|
||||
" staging:",
|
||||
" server: shared-box",
|
||||
" team: { id: 0, name: Root Team }",
|
||||
// Keyed by the full slug, and nested under the environment — the shape
|
||||
// that can say "this project goes HERE and that one goes THERE".
|
||||
...(declared
|
||||
? [
|
||||
" projects:",
|
||||
" heavy-duty/incubator:",
|
||||
` destination_uuid: ${declared}`,
|
||||
]
|
||||
: []),
|
||||
"github_apps:",
|
||||
" incubator: hdb-coolify",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return { checkout, state };
|
||||
}
|
||||
|
||||
function run(args: string[]): Promise<{ code: number; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("node", ["dist/cli.js", "diff", ...args], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, CAST_AGE_KEY_FILE_STAGING: keyFile },
|
||||
});
|
||||
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 }));
|
||||
});
|
||||
}
|
||||
|
||||
const base = (f: { checkout: string; state: string }) => [
|
||||
"heavy-duty/incubator",
|
||||
"--env",
|
||||
"staging",
|
||||
"--path",
|
||||
f.checkout,
|
||||
"--state",
|
||||
f.state,
|
||||
];
|
||||
|
||||
describe("cast diff — placement (#21)", () => {
|
||||
it("reports the destination it declared, and says it could not verify it", async () => {
|
||||
const f = fixture(
|
||||
(await stubCoolify({ core: 5, landing: 5 })).url,
|
||||
"d-abc",
|
||||
);
|
||||
const r = await run(base(f));
|
||||
// Clean: a declared destination is never itself drift. There is nothing on
|
||||
// the wire to compare it to, and a phantom "update" would never clear.
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.output).toContain("placement: all resources on destination 5");
|
||||
// The load-bearing sentence. cast enforces this UUID once, at create, and
|
||||
// can never check it again — an operator who reads a clean diff as "the
|
||||
// isolation is verified" is exactly the person #21 is written for.
|
||||
expect(r.output).toContain("d-abc");
|
||||
expect(r.output).toMatch(/NOT compared/);
|
||||
expect(r.output).toContain("clean");
|
||||
});
|
||||
|
||||
// The failure the issue is actually about: the isolation looks configured and
|
||||
// isn't. Two of this project's apps, two different networks.
|
||||
it("fails a split project, naming both sides of the split", async () => {
|
||||
const f = fixture(
|
||||
(await stubCoolify({ core: 5, landing: 9 })).url,
|
||||
"d-abc",
|
||||
);
|
||||
const r = await run(base(f));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toMatch(/split placement: these resources sit on 2/);
|
||||
expect(r.output).toContain("destination 5: application core");
|
||||
expect(r.output).toContain("destination 9: application landing");
|
||||
// Reported, never repaired — the orphan disposition.
|
||||
expect(r.output).toMatch(/apply never moves a live resource between/);
|
||||
expect(r.output).not.toContain("clean");
|
||||
});
|
||||
|
||||
// A split is a split whether or not the state file has caught up: it is read
|
||||
// off the live side alone. This is what catches a box where someone made the
|
||||
// destinations by hand and cast has never been told.
|
||||
it("catches a split even when no destination is declared", async () => {
|
||||
const f = fixture((await stubCoolify({ core: 5, landing: 9 })).url);
|
||||
const r = await run(base(f));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toMatch(/split placement/);
|
||||
// ...and with nothing declared there is no unverifiable claim to warn about.
|
||||
expect(r.output).not.toMatch(/NOT compared/);
|
||||
});
|
||||
|
||||
// The state of every box today: one project, one server, one network, nothing
|
||||
// declared. Placement must be silent — a line on every diff that says nothing
|
||||
// is how a report stops being read.
|
||||
it("says nothing at all about placement on an undeclared, unsplit box", async () => {
|
||||
const f = fixture((await stubCoolify({ core: 5, landing: 5 })).url);
|
||||
const r = await run(base(f));
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.output).not.toMatch(/placement/);
|
||||
expect(r.output).toContain("clean");
|
||||
});
|
||||
});
|
||||
|
|
@ -257,6 +257,121 @@ describe("buildExecutor createResource (application, dockercompose)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Placement is create-time only, and every kind needs it: Coolify runs the same
|
||||
// destination logic in ApplicationsController, DatabasesController and
|
||||
// ServicesController, and 400s on a multi-destination server for whichever one
|
||||
// omits it. Missing it on the database create alone would be enough to leave a
|
||||
// project's Postgres on the shared default network.
|
||||
describe("buildExecutor createResource (destination placement)", () => {
|
||||
function captureCreates() {
|
||||
const bodies: Record<string, Record<string, unknown>> = {};
|
||||
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const path = new URL(String(url)).pathname;
|
||||
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
|
||||
return new Response(
|
||||
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
||||
{ status: 200 },
|
||||
);
|
||||
if (
|
||||
path === "/api/v1/applications/private-github-app" ||
|
||||
path === "/api/v1/databases/postgresql" ||
|
||||
path === "/api/v1/services"
|
||||
) {
|
||||
bodies[path] = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({ uuid: "new-1" }), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
return { bodies, fetchImpl };
|
||||
}
|
||||
|
||||
const creates = [
|
||||
{
|
||||
path: "/api/v1/applications/private-github-app",
|
||||
change: {
|
||||
kind: "application" as const,
|
||||
name: "core",
|
||||
op: "create" as const,
|
||||
fieldDiffs: [
|
||||
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
||||
{ field: "domains", desired: ["https://a"], updatable: true },
|
||||
],
|
||||
envDiffs: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/api/v1/databases/postgresql",
|
||||
change: {
|
||||
kind: "database" as const,
|
||||
name: "postgres",
|
||||
op: "create" as const,
|
||||
fieldDiffs: [
|
||||
{ field: "type", desired: "postgresql", updatable: false },
|
||||
],
|
||||
envDiffs: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/api/v1/services",
|
||||
change: {
|
||||
kind: "service" as const,
|
||||
name: "umami",
|
||||
op: "create" as const,
|
||||
fieldDiffs: [{ field: "type", desired: "umami", updatable: false }],
|
||||
envDiffs: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(creates)(
|
||||
"sends destination_uuid on the $path create",
|
||||
async ({ path, change }) => {
|
||||
const { bodies, fetchImpl } = captureCreates();
|
||||
const client = new CoolifyClient(
|
||||
"https://coolify.test",
|
||||
"tok",
|
||||
fetchImpl,
|
||||
);
|
||||
const exec = buildExecutor(client, {
|
||||
projectName: "widget",
|
||||
envName: "prod",
|
||||
serverUuid: "srv-1",
|
||||
githubAppUuid: "gh-1",
|
||||
destinationUuid: "dest-abc",
|
||||
backupSchedules: {},
|
||||
});
|
||||
await exec.createResource(change);
|
||||
expect(bodies[path]?.destination_uuid).toBe("dest-abc");
|
||||
// The server still has to be named — a destination belongs to one.
|
||||
expect(bodies[path]?.server_uuid).toBe("srv-1");
|
||||
},
|
||||
);
|
||||
|
||||
// Undeclared must mean ABSENT, not empty-string: Coolify branches on
|
||||
// `$request->has('destination_uuid')`, so sending "" would take the
|
||||
// "you gave me one" path and then fail to match any destination.
|
||||
it.each(creates)(
|
||||
"omits destination_uuid entirely when none is declared ($path)",
|
||||
async ({ path, change }) => {
|
||||
const { bodies, fetchImpl } = captureCreates();
|
||||
const client = new CoolifyClient(
|
||||
"https://coolify.test",
|
||||
"tok",
|
||||
fetchImpl,
|
||||
);
|
||||
const exec = buildExecutor(client, {
|
||||
projectName: "widget",
|
||||
envName: "prod",
|
||||
serverUuid: "srv-1",
|
||||
githubAppUuid: "gh-1",
|
||||
backupSchedules: {},
|
||||
});
|
||||
await exec.createResource(change);
|
||||
expect(bodies[path]).not.toHaveProperty("destination_uuid");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("databaseVersionFromImage / defaultDatabaseImage", () => {
|
||||
it("round-trips through defaultDatabaseImage for postgres", () => {
|
||||
const image = defaultDatabaseImage("postgresql", "17");
|
||||
|
|
|
|||
Loading…
Reference in a new issue