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([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|