fix(apply): pre-flight domain uniqueness, and translate Coolify's 409 (#44) #57
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#57
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/domain-preflight"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The problem
cast plans inside one project + one environment. Coolify enforces domain uniqueness across the entire instance. So
applycan produce a plan that is internally consistent, correct against everything cast can observe, and still be refused — for a reason invisible from cast's own scope, arriving as a raw 409 mid-apply, after the project and the environment have already been created:Same family as the multi-destination 400 (#41) — an instance-wide constraint surfacing untranslated at the worst moment. Unlike that one, this one can be pre-flighted.
What changed
1. The create plan is pre-flighted (
preflightDomainConflicts,src/cli.ts). Beforeapplywrites anything — the project and environment are created lazily, by the first create, so this is the last moment a refusal is still free — cast readsGET /applicationsand checks the domains the plan is about to claim against every one already held. A conflict is a refusal that costs nothing, not a half-applied run. It fires only on a plan that creates an application with a domain: a first apply, and nothing else (databases have no domains, and cast's service creates dropdomainson the wire). Both live shapes are covered:fqdnon non-compose apps, per-servicedocker_compose_domainson dockercompose apps.2. The 409 is translated (
domainConflictRemedy, modelled onmultiDestinationRemedy). The pre-flight is a strict subset of Coolify's check — Coolify also compares against servicefqdns and the instancefqdn, neither of which appears in any list cast can read — so the translation is not dead code. Both paths say the same three things, the last being the one the operator cannot get from Coolify's own message: the domain, the resource holding it (name + uuid + compose service), and whether that resource is outside the project cast is applying, with the usual cause named — residue from a run cleaned up by deleting a Coolify project, since project deletion does not delete resources.The scope claim is checked, not assumed: the executor and the pre-flight are both handed the uuids of the live resources cast actually read, so a conflict with something in the plan's own project (a renamed resource) gets a different sentence rather than a lie.
3.
force_domain_override=trueis never sent. Not on retry, not anywhere — the string does not exist insrc/outside comments and the refusal text that explains why cast declines it. It cannot enter from a manifest either: every manifest object is.strict()(src/manifest.ts), so an unknown key is a parse error, andapplicationApiFields's...resthas nothing to leak. Two resources on one domain is a routing coin-flip, and Coolify says so in the same response it suggests the flag in.Coolify source evidence
Read from
coollabsio/coolify@v4.1.2(the vendored OpenAPI is provably incomplete here — it does not documentfqdnonGET /applicationsat all):bootstrap/helpers/domains.php@checkIfDomainIsAlreadyUsedViaAPI(L142) — the constraint itself. It walksApplication::ownedByCurrentTeamAPI($teamId)(every application of the team, any project, any environment) checkingfqdn, and — gated onbuild_pack === 'dockercompose'(L189) —docker_compose_domains; thenServiceApplicationfqdns; then the instance fqdn. Comparison strips one trailing slash from both sides and then compares the strings literally, scheme included (L153–177):http://xandhttps://xare different domains to Coolify. The pre-flight mirrors this exactly, including thebuild_packgate — a check stricter than the server would refuse applies Coolify would have allowed.ApplicationsController.phpL1112–1127 (and identically at L1353, L1566) — the 409 body:message+conflicts[]+warning, and theforce_domain_overridebypass.ApplicationsController.phpL38 (removeSensitiveData), called at L130 (applications()) and L1980 (application_by_uuid()) — this is where the issue's suggestion turned out to be half-wrong, and I did the right thing instead. The issue proposes "GET /applicationslists every application; a per-appGETexposes its domains… N+1 calls". But the list is serialized by the sameremoveSensitiveData()as the per-appGET, and neither hidesfqdn,docker_compose_domainsorbuild_pack. A per-app GET would return byte-for-byte the same fields. So the pre-flight is one call, not N+1 — and the N+1 would have bought literally nothing.applications()listsownedByCurrentTeamAPI($teamId)— the same population the conflict check walks for applications. That is what makes the pre-flight sound rather than a guess.Tests
test/domain-preflight.test.ts, 16 new tests, all against fakes (no live instance, no credentials). The refusal and the translated error are what is tested — not the happy path:fqdn; per-service compose domains), and both desired shapes claimedapiservice holding the domain, in a project cast cannot seehttp://≠https://— i.e. cast agrees with Coolify's comparison, in both directionsbuild_packgate — a false refusal blocks a correct apply)NOT in <project> / <env>, says deleting a project does not delete its resources, and saysNothing was createdarrived mid-apply, keeps Coolify's words verbatim, and no request body in the exchange carriesforce_domain_overrideconflicts(Coolify's duplicate-environment answer) passes through untranslated — the narrowing is on the conflicts, not on the statusnpm run check && npm run build && npm test→ 310/310 green (294 baseline + 16).Noticed, deliberately not fixed here
PATCH /applications/{uuid}can 409 too (ApplicationsController.phpL2512 runs the same check, excluding self) — an update that moves a domain onto a claimed one still surfaces raw. #44 is about the create path, and the pre-flight is create-only by design; the update-side 409 wants its own issue (the translation function is already reusable for it).Closes #44.
🤖 Generated with Claude Code