cast/test/bindings.test.ts

465 lines
15 KiB
TypeScript
Raw Normal View History

fix: diff refuses an absent target instead of reporting it as empty (#11, #6) `diff` could not tell "this project does not exist" from "this project is empty" — both came back as [] from fetchLive. That is right for `apply` (a first apply legitimately creates the project and its environment) and quietly wrong for `diff`: computeDiff(desired, []) means "every desired resource is missing", rendered as a confident full-create plan. So a diff aimed at a name that does not exist reported a CLEAN-LOOKING plan that verified nothing. Same shape of lie as the wrong-team token #10 closed — an unverifiable read that answers "absent" and invites a create — reached through the project name instead of the team. It matters more now: with a single Root Team the team assert can never fire, so it is no longer guarding this class of bug at all. There are two roads to it, not one. The project name may be wrong, and so may the environment name: cast names environments after --env, but a project built by hand in the Coolify UI uses whatever someone typed (Coolify's own default is `production`, not `prod`). Both are gated. - fetchLive returns a LiveLookup union, so absence is its own answer rather than a value that happens to equal "empty". diff refuses (exit 2) and names what it looked for, where that name came from, and what exists instead; apply keeps today's tolerant behaviour, which is the whole point of the split. - --project <name> overrides the repo-derived project name, for an instance that names it differently. It overrides ONLY that: secrets stay keyed by the repo, a state-repo convention we own. #6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so existing state files keep working. A short name is unique only *within* an org, so two orgs' same-named repos collapsed onto one entry and whichever App was bound there would clone both — silently, because a wrong-but-existing App still resolves to a real uuid and the create succeeds. Verified end-to-end against a fake Coolify, driving the real binary: an absent project refuses (exit 2), --project recovers it (exit 0), an absent environment refuses (exit 2). 12 new tests; 98 pass; check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
import { describe, expect, it } from "vitest";
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
import {
type Bindings,
type ProjectBinding,
githubAppNameFor,
loadBindings,
projectBindingFor,
feat: a project registry — the list of what exists (#25) environments.yaml could say where things deploy to, and how a project you have already named is placed once it is there. It could not say which projects exist. "Every project" was a thing the operator remembered — so fleet operations (#26) had nothing to iterate, and rebuild-from-state (#27) was an assumption, since you cannot restore what you cannot enumerate. A new optional top-level block, keyed by the full <org>/<repo> slug: projects: heavy-duty/incubator: environments: [prod, staging] The key IS the repo — no `repo:` field, because a second place to write the same string is a second place for it to be wrong. No bare-<repo> fallback, unlike github_apps and environments.<env>.projects: those carry one because state files in the wild are keyed that way, and this block has none to support. A bare <repo> is unique only within an org, which is why it is not a key (#12, twice learned). Validated in loadBindings, so every verb refuses a registry that lies: - an environment no `environments:` block defines is an error — the project would be registered into an environment no command can visit - every environments.<env>.projects.<slug> binding must be registered for that env, or the two blocks describe two different fleets: a destination or smoke_target real enough for a direct apply, invisible to every fleet run. Only enforced when `projects:` is present, so pre-registry state files keep loading unchanged. Both defend one failure: a silently skipped project reads exactly like a clean one. Errors render multi-line now — zod's own .message is the issue array as JSON, which flattened the refusals into a line of \n escapes. projectsIn(bindings, env) gives an environment's slugs, sorted; [] with no registry. The --all flag that consumes it is #26's, not here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:03:35 +00:00
projectsIn,
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
smokeTargetFor,
} from "../src/bindings.js";
fix: diff refuses an absent target instead of reporting it as empty (#11, #6) `diff` could not tell "this project does not exist" from "this project is empty" — both came back as [] from fetchLive. That is right for `apply` (a first apply legitimately creates the project and its environment) and quietly wrong for `diff`: computeDiff(desired, []) means "every desired resource is missing", rendered as a confident full-create plan. So a diff aimed at a name that does not exist reported a CLEAN-LOOKING plan that verified nothing. Same shape of lie as the wrong-team token #10 closed — an unverifiable read that answers "absent" and invites a create — reached through the project name instead of the team. It matters more now: with a single Root Team the team assert can never fire, so it is no longer guarding this class of bug at all. There are two roads to it, not one. The project name may be wrong, and so may the environment name: cast names environments after --env, but a project built by hand in the Coolify UI uses whatever someone typed (Coolify's own default is `production`, not `prod`). Both are gated. - fetchLive returns a LiveLookup union, so absence is its own answer rather than a value that happens to equal "empty". diff refuses (exit 2) and names what it looked for, where that name came from, and what exists instead; apply keeps today's tolerant behaviour, which is the whole point of the split. - --project <name> overrides the repo-derived project name, for an instance that names it differently. It overrides ONLY that: secrets stay keyed by the repo, a state-repo convention we own. #6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so existing state files keep working. A short name is unique only *within* an org, so two orgs' same-named repos collapsed onto one entry and whichever App was bound there would clone both — silently, because a wrong-but-existing App still resolves to a real uuid and the create succeeds. Verified end-to-end against a fake Coolify, driving the real binary: an absent project refuses (exit 2), --project recovers it (exit 0), an absent environment refuses (exit 2). 12 new tests; 98 pass; check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
function bindings(github_apps: Record<string, string>): Bindings {
return {
environments: {
prod: { server: "prod-box", team: { id: 0, name: "Root Team" } },
},
github_apps,
} as Bindings;
}
fix: smoke resolves its target inside the project it was declared under (#29) `smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
function withProjects(projects: Record<string, ProjectBinding>): Bindings {
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
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: {},
} as Bindings;
}
fix: diff refuses an absent target instead of reporting it as empty (#11, #6) `diff` could not tell "this project does not exist" from "this project is empty" — both came back as [] from fetchLive. That is right for `apply` (a first apply legitimately creates the project and its environment) and quietly wrong for `diff`: computeDiff(desired, []) means "every desired resource is missing", rendered as a confident full-create plan. So a diff aimed at a name that does not exist reported a CLEAN-LOOKING plan that verified nothing. Same shape of lie as the wrong-team token #10 closed — an unverifiable read that answers "absent" and invites a create — reached through the project name instead of the team. It matters more now: with a single Root Team the team assert can never fire, so it is no longer guarding this class of bug at all. There are two roads to it, not one. The project name may be wrong, and so may the environment name: cast names environments after --env, but a project built by hand in the Coolify UI uses whatever someone typed (Coolify's own default is `production`, not `prod`). Both are gated. - fetchLive returns a LiveLookup union, so absence is its own answer rather than a value that happens to equal "empty". diff refuses (exit 2) and names what it looked for, where that name came from, and what exists instead; apply keeps today's tolerant behaviour, which is the whole point of the split. - --project <name> overrides the repo-derived project name, for an instance that names it differently. It overrides ONLY that: secrets stay keyed by the repo, a state-repo convention we own. #6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so existing state files keep working. A short name is unique only *within* an org, so two orgs' same-named repos collapsed onto one entry and whichever App was bound there would clone both — silently, because a wrong-but-existing App still resolves to a real uuid and the create succeeds. Verified end-to-end against a fake Coolify, driving the real binary: an absent project refuses (exit 2), --project recovers it (exit 0), an absent environment refuses (exit 2). 12 new tests; 98 pass; check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
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/);
});
});
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
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", () => {
fix: smoke resolves its target inside the project it was declared under (#29) `smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
it("resolves the project-scoped target", () => {
const b = withProjects({
"heavy-duty/incubator": { smoke_target: "core" },
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
});
fix: smoke resolves its target inside the project it was declared under (#29) `smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toBe("core");
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
});
fix: smoke resolves its target inside the project it was declared under (#29) `smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
it("is undefined when the project declares no target", () => {
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
expect(
smokeTargetFor(withProjects({}), "prod", "heavy-duty/incubator"),
).toBe(undefined);
fix: smoke resolves its target inside the project it was declared under (#29) `smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
expect(
smokeTargetFor(
withProjects({ "heavy-duty/incubator": { smoke_target: "core" } }),
"staging",
"heavy-duty/incubator",
),
).toBe(undefined);
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
});
fix: smoke resolves its target inside the project it was declared under (#29) `smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
// Two projects, each with its own smoke target: the case the removed
// state-file-scoped key could not express at all, since it named one app for
// the whole file.
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
it("keeps two projects' smoke targets apart", () => {
const b = withProjects({
"heavy-duty/incubator": { smoke_target: "core" },
"acme/client-site": { smoke_target: "web" },
});
fix: smoke resolves its target inside the project it was declared under (#29) `smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toBe("core");
expect(smokeTargetFor(b, "prod", "acme/client-site")).toBe("web");
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
});
});
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",
});
});
fix: smoke resolves its target inside the project it was declared under (#29) `smoke` found the application it WRITES to by name against GET /applications — every app the token can see, across every project and every environment on the instance — and took the first name match. So `smoke_target: core` did not name an application; it named whichever `core` Coolify happened to list first. One instance carrying prod and staging is enough for `cast smoke --env staging` to POST its canary vars onto prod's `core`, and on the failure path leave them there. It now resolves the target through fetchLive(project, environment) — the same lookup every read-side verb makes — and takes the coordinates that lookup needs: --project and --environment, with diff/capture/inventory's semantics and defaults. An application that is not in that project + environment is not an empty result, it is the absence of anything to write to, so smoke refuses: naming what it looked for, where the name came from, and what is actually there (including when the name belongs to a service or a database, which would 404 on the /envs endpoint smoke writes to). The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped `smoke_target` is dropped: it named an app from a key with no project to scope to, so it could not be fixed, only carried. It is still declared in the schema — refused with a migration message rather than a strict-mode "unrecognized key", because loadBindings runs for every verb and an unmigrated state file must not take `diff` and `apply` down with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
// The key is gone (#29): it named one project's application from a scope that
// could not tell two projects — or prod from staging — apart, and `smoke` now
// resolves the name INSIDE the project it was declared under, which this key
// does not have. It is still declared in the schema purely so its removal
// reads as a migration instead of as a zod "unrecognized key" — loadBindings
// runs for every verb, so an unmigrated file would otherwise take `diff` and
// `apply` down with it, over a key neither of them reads.
it("refuses a state-file-scoped smoke_target, and says where to move it", () => {
const load = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
github_apps: {}
smoke_target: core
`,
});
expect(load).toThrow(/top-level `smoke_target` key is no longer read/);
expect(load).toThrow(/projects:/);
expect(load).toThrow(/smoke_target: core/);
expect(load).toThrow(/cast smoke <org>\/<repo> --env <env>/);
});
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
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/);
});
});
feat: a project registry — the list of what exists (#25) environments.yaml could say where things deploy to, and how a project you have already named is placed once it is there. It could not say which projects exist. "Every project" was a thing the operator remembered — so fleet operations (#26) had nothing to iterate, and rebuild-from-state (#27) was an assumption, since you cannot restore what you cannot enumerate. A new optional top-level block, keyed by the full <org>/<repo> slug: projects: heavy-duty/incubator: environments: [prod, staging] The key IS the repo — no `repo:` field, because a second place to write the same string is a second place for it to be wrong. No bare-<repo> fallback, unlike github_apps and environments.<env>.projects: those carry one because state files in the wild are keyed that way, and this block has none to support. A bare <repo> is unique only within an org, which is why it is not a key (#12, twice learned). Validated in loadBindings, so every verb refuses a registry that lies: - an environment no `environments:` block defines is an error — the project would be registered into an environment no command can visit - every environments.<env>.projects.<slug> binding must be registered for that env, or the two blocks describe two different fleets: a destination or smoke_target real enough for a direct apply, invisible to every fleet run. Only enforced when `projects:` is present, so pre-registry state files keep loading unchanged. Both defend one failure: a silently skipped project reads exactly like a clean one. Errors render multi-line now — zod's own .message is the issue array as JSON, which flattened the refusals into a line of \n escapes. projectsIn(bindings, env) gives an environment's slugs, sorted; [] with no registry. The --all flag that consumes it is #26's, not here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:03:35 +00:00
// The registry: the list of which projects exist at all. Everything it is FOR
// (fleet iteration, rebuild-from-state) depends on it being true, and the way it
// stops being true is silent — see the refusals below.
describe("the project registry", () => {
const twoEnvs = `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
staging:
server: staging-box
team: { id: 0, name: Root Team }
`;
it("registers projects per environment, keyed by the full slug", () => {
const b = loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod, staging]
acme/client-site:
environments: [prod]
github_apps: {}
`,
});
expect(b.projects).toEqual({
"heavy-duty/incubator": { environments: ["prod", "staging"] },
"acme/client-site": { environments: ["prod"] },
});
});
describe("projectsIn", () => {
const b = loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod, staging]
acme/client-site:
environments: [prod]
github_apps: {}
`,
});
// Sorted, not file-order: a fleet run's output is read by a human and diffed
// by CI, and must not reshuffle because someone appended a project.
it("gives an environment's projects, sorted", () => {
expect(projectsIn(b, "prod")).toEqual([
"acme/client-site",
"heavy-duty/incubator",
]);
});
it("gives only the projects registered for that environment", () => {
expect(projectsIn(b, "staging")).toEqual(["heavy-duty/incubator"]);
});
it("is empty for an environment no project is registered in", () => {
expect(projectsIn(b, "nowhere")).toEqual([]);
});
});
// The refusal the issue is actually about. A typo'd environment name makes the
// project real and its environment imaginary: `cast diff --all` visits nothing
// for it, reports nothing, and exits clean — and a silently skipped project
// reads exactly like a clean one.
it("refuses an environment that does not exist, naming the ones that do", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod, stagng]
github_apps: {}
`,
});
expect(err).toThrow(/environment "stagng", which does not exist/);
expect(err).toThrow(/known envs:\s+prod, staging/);
});
// The other direction, and the one that rots quietly: per-environment state
// (#21) sitting in an environment the registry does not register the project
// for. The two blocks then describe two different fleets.
it("refuses a binding in an environment the registry does not register", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
staging:
server: staging-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination_uuid: dest-abc
projects:
heavy-duty/incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(
/environments\.staging\.projects\["heavy-duty\/incubator"\]/,
);
expect(err).toThrow(/registered for:\s+prod/);
});
it("refuses a binding for a project the registry does not carry at all", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
acme/client-site:
destination_uuid: dest-client
projects:
heavy-duty/incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(/environments\.prod\.projects\["acme\/client-site"\]/);
expect(err).toThrow(/registry has:\s+heavy-duty\/incubator/);
});
// A legacy bare-<repo> binding key (projectBindingFor still resolves one) under
// a slug-keyed registry is drift with an obvious fix — say which fix.
it("tells a legacy bare-<repo> binding key which slug to rename to", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
incubator:
smoke_target: core
projects:
heavy-duty/incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(/registry has:\s+projects\["heavy-duty\/incubator"\]/);
expect(err).toThrow(/legacy bare-<repo> key/);
});
// No fallback here, unlike github_apps: this block is new, so it has no state
// files in the wild to keep working, and a bare <repo> is unique only within an
// org — which is exactly why it is not a key.
it("refuses a bare <repo> registry key — the org is not optional", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(/projects\["incubator"\] is not a repo/);
expect(err).toThrow(/full <org>\/<repo> slug/);
});
// A project registered into nothing is a line of YAML that reads like a
// registration and is skipped by every fleet run.
it("refuses a project registered into no environment", () => {
expect(() =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: []
github_apps: {}
`,
}),
).toThrow(/invalid bindings/);
});
it("rejects an unknown key inside a registry entry", () => {
expect(() =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod]
repo: heavy-duty/incubator
github_apps: {}
`,
}),
).toThrow(/invalid bindings/);
});
// Back-compat: the registry is optional, and every state file written before it
// existed has no `projects:` block. Such a file loads unchanged — including its
// per-environment bindings, which are NOT checked against a registry that is
// not there.
describe("with no registry at all", () => {
const b = loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination_uuid: dest-abc
smoke_target: core
github_apps: {}
`,
});
it("loads, and keeps its per-environment bindings working", () => {
expect(b.projects).toBe(undefined);
expect(
projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid,
).toBe("dest-abc");
});
it("has no projects registered in any environment", () => {
expect(projectsIn(b, "prod")).toEqual([]);
});
});
});