feat(service): set and diff per-container service hostnames via urls (#72 item 1) #81

Merged
dan-claude-bot merged 1 commit from feat/service-domains into main 2026-07-16 15:43:41 +00:00
dan-claude-bot commented 2026-07-16 15:14:51 +00:00 (Migrated from github.com)

Implements #72 item 1 — "adopt service urls on 4.1.2 now (biggest UI-step deletion available today)".

The claim this refutes

desiredFromManifest dropped a service's domains and warned that hostnames were a manual Coolify UI act, citing a re-checked "no flat domains on a 4.1.2 service, on any route." The audit disproved it — the same failure mode #51 corrected for backups. The flat shape has no route; the per-container capability was there at 4.1.2 all along, verified at the source:

  • POST /services / PATCH /services/{uuid} allowlist urls ([{name,url}]); applyServiceUrls matches urls[].name to a ServiceApplication and sets its fqdn (ServicesController.php:296,320,505,962 + :45-135).
  • GET /services/{uuid} does $service->load(['applications']) and removeSensitiveData does not hide fqdn (:736).

What changed

Services now speak the same per-container vocabulary a dockercompose app does:

before after
manifest flat domains (dropped + warned) service_domains: { <container>: [url] }
write — (manual UI) urls on create and update
read/diff not read supplementary GET /services/{uuid}applications[].fqdn, compared every run
pre-flight services excluded service service_domains joins desiredDomainsOfCreate
  • A flat domains: string[] is removed from ServiceSpec — it can't name which container a hostname belongs to, which is exactly what urls requires.
  • service_domains is canonicalized (keys + each URL array sorted) on both the desired and live sides, so container ordering never causes false drift.
  • The read-back is gated to diff/apply (one extra GET per service), like backups.

Two limits, stated out loud

  1. Fail-closed read. An unreachable/unrecognized GET /services/{uuid} aborts rather than projecting empty and re-PATCHing the hostname forever (#12/#14/#17 discipline). Not a silent "not compared".
  2. Create-conflict is destructive. A service create whose domain conflicts is deleted server-side before the 409 (applyServiceUrls rollback), so service_domains on a create is pre-flighted. (Cross-service conflict detection remains the documented subset the preflight always was.)
  3. Draft not taught yet. inventory --emit-draft doesn't make the per-service GET, so a drafted service's hostnames are still declared by hand — draft's UNCAPTURED note and semantics.md say so (same shape as backups).

Migration (operator)

A manifest using the old flat service domains must move to service_domains, naming the container (discoverable from a cast diff read-back or the Coolify UI). For the incubator's umami:

# before
umami: { type: umami, domains: ["https://analytics.heavyduty.builders"] }
# after
umami:
  type: umami
  service_domains:
    umami: ["https://analytics.heavyduty.builders"]

Tests

serviceApiFieldsurls; attachServiceDomains projection + fail-closed on unreachable/unrecognized; desiredFromManifest emits canonicalized service_domains + diffs clean/drift; desiredDomainsOfCreate claims a service create's domains; manifest/fixture updated.

npm run check clean · 514 tests pass.

🤖 Generated with Claude Code

Implements **#72 item 1** — "adopt service `urls` on 4.1.2 now (biggest UI-step deletion available today)". ## The claim this refutes `desiredFromManifest` dropped a service's `domains` and warned that hostnames were a manual Coolify UI act, citing a re-checked *"no flat `domains` on a 4.1.2 service, on any route."* The audit disproved it — the same failure mode #51 corrected for backups. The **flat** shape has no route; the **per-container** capability was there at 4.1.2 all along, verified at the source: - `POST /services` / `PATCH /services/{uuid}` allowlist `urls` (`[{name,url}]`); `applyServiceUrls` matches `urls[].name` to a `ServiceApplication` and sets its `fqdn` (`ServicesController.php:296,320,505,962` + `:45-135`). - `GET /services/{uuid}` does `$service->load(['applications'])` and `removeSensitiveData` does not hide `fqdn` (`:736`). ## What changed Services now speak the **same per-container vocabulary a dockercompose app does**: | | before | after | |---|---|---| | manifest | flat `domains` (dropped + warned) | `service_domains: { <container>: [url] }` | | write | — (manual UI) | `urls` on **create and update** | | read/diff | not read | supplementary `GET /services/{uuid}` → `applications[].fqdn`, compared every run | | pre-flight | services excluded | service `service_domains` joins `desiredDomainsOfCreate` | - A flat `domains: string[]` is **removed** from `ServiceSpec` — it can't name which container a hostname belongs to, which is exactly what `urls` requires. - `service_domains` is **canonicalized** (keys + each URL array sorted) on both the desired and live sides, so container ordering never causes false drift. - The read-back is gated to `diff`/`apply` (one extra GET per service), like backups. ## Two limits, stated out loud 1. **Fail-closed read.** An unreachable/unrecognized `GET /services/{uuid}` **aborts** rather than projecting empty and re-PATCHing the hostname forever (#12/#14/#17 discipline). Not a silent "not compared". 2. **Create-conflict is destructive.** A service create whose domain conflicts is **deleted server-side before the 409** (`applyServiceUrls` rollback), so `service_domains` on a create is pre-flighted. (Cross-*service* conflict detection remains the documented subset the preflight always was.) 3. **Draft not taught yet.** `inventory --emit-draft` doesn't make the per-service GET, so a drafted service's hostnames are still declared by hand — draft's UNCAPTURED note and semantics.md say so (same shape as backups). ## Migration (operator) A manifest using the old flat service `domains` must move to `service_domains`, naming the container (discoverable from a `cast diff` read-back or the Coolify UI). For the incubator's umami: ```yaml # before umami: { type: umami, domains: ["https://analytics.heavyduty.builders"] } # after umami: type: umami service_domains: umami: ["https://analytics.heavyduty.builders"] ``` ## Tests `serviceApiFields` → `urls`; `attachServiceDomains` projection + **fail-closed** on unreachable/unrecognized; `desiredFromManifest` emits canonicalized `service_domains` + diffs clean/drift; `desiredDomainsOfCreate` claims a service create's domains; manifest/fixture updated. `npm run check` clean · **514 tests pass**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#81
No description provided.