cast/test/bindings.test.ts
claude-hdb 8deaeac07b 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>
2026-07-13 19:31:02 +00:00

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/);
});
});