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>
232 lines
8.4 KiB
TypeScript
232 lines
8.4 KiB
TypeScript
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");
|
|
});
|
|
});
|