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>
225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
type Bindings,
|
|
type ProjectBinding,
|
|
githubAppNameFor,
|
|
loadBindings,
|
|
projectBindingFor,
|
|
smokeTargetFor,
|
|
} from "../src/bindings.js";
|
|
|
|
function bindings(github_apps: Record<string, string>): Bindings {
|
|
return {
|
|
environments: {
|
|
prod: { server: "prod-box", team: { id: 0, name: "Root Team" } },
|
|
},
|
|
github_apps,
|
|
} 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" });
|
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
|
});
|
|
|
|
// Every state file written before full-slug keying uses the bare repo name.
|
|
// Dropping that would break them for no gain, so it stays as a fallback.
|
|
it("still resolves a legacy bare <repo> key", () => {
|
|
const b = bindings({ incubator: "hdb-coolify" });
|
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
|
});
|
|
|
|
// The whole point of the issue. A short name is unique only *within* an org,
|
|
// so two orgs' same-named repos collapse onto one key — and the loser gets
|
|
// cloned by the winner's App, silently, because a wrong-but-existing App
|
|
// still resolves to a real uuid and the create succeeds.
|
|
it("keeps two orgs' same-named repos on separate Apps", () => {
|
|
const b = bindings({
|
|
"heavy-duty/incubator": "hdb-coolify",
|
|
"acme/incubator": "acme-coolify",
|
|
});
|
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
|
expect(githubAppNameFor(b, "acme/incubator")).toBe("acme-coolify");
|
|
});
|
|
|
|
// Precedence matters in exactly the case that motivated the fix: a state file
|
|
// mid-migration carries both a legacy short key and a new full-slug one. The
|
|
// slug is the thing that actually identifies a repo, so it must win.
|
|
it("prefers the full slug over a colliding bare key", () => {
|
|
const b = bindings({
|
|
incubator: "legacy-app",
|
|
"heavy-duty/incubator": "hdb-coolify",
|
|
});
|
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
|
});
|
|
|
|
it("refuses an unbound repo, naming both keys it tried", () => {
|
|
const b = bindings({ "heavy-duty/other": "other-app" });
|
|
const err = githubAppNameFor.bind(
|
|
null,
|
|
b,
|
|
"heavy-duty/incubator",
|
|
) as () => string;
|
|
expect(err).toThrow(/no GitHub App bound for heavy-duty\/incubator/);
|
|
expect(err).toThrow(/github_apps\["heavy-duty\/incubator"\]/);
|
|
expect(err).toThrow(/github_apps\["incubator"\]/);
|
|
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/);
|
|
});
|
|
});
|