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

Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.

`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:

- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
  unhonorable `domains` on a service (a flat list cannot name which container
  a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
  and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
  (`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
  `applications[].fqdn` back into `service_domains`, so a declared hostname is
  compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
  `desiredDomainsOfCreate`, the more important because a service create whose
  domain conflicts is DELETED server-side before the 409 (rollback).

Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.

`npm run check` clean · 514 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-16 15:14:29 +00:00
parent e3e52680e1
commit bbca3cfff0
11 changed files with 382 additions and 123 deletions

View file

@ -1056,8 +1056,11 @@ express everything a Coolify holds, and a blueprint that omits those things
without saying so is worse than no blueprint — in a disaster you would trust it without saying so is worse than no blueprint — in a disaster you would trust it
and rebuild a *different box*. Per resource, it names what was seen and could not and rebuild a *different box*. Per resource, it names what was seen and could not
be written: `destination_id` (which Docker network — no destinations API in 4.1.2 be written: `destination_id` (which Docker network — no destinations API in 4.1.2
to resolve it to the UUID `destination_uuid:` wants, #21), service hostnames (no to resolve it to the UUID `destination_uuid:` wants, #21), service hostnames
flat `domains` on a Coolify 4.1.2 service), Basic Auth / custom Traefik labels, (settable/readable via the API and carried by `diff`/`apply` as `service_domains`
since #72, but the same as backups below — `inventory --emit-draft` does not yet
make the per-service `GET /services/{uuid}`, so a drafted service has none until
you declare them), Basic Auth / custom Traefik labels,
build and deploy command overrides, backup schedules (**a rebuild has no backups build and deploy command overrides, backup schedules (**a rebuild has no backups
until you declare them** — *not* because they cannot be read, which is what this until you declare them** — *not* because they cannot be read, which is what this
line used to say and #51 disproved, but because `inventory --emit-draft` has not line used to say and #51 disproved, but because `inventory --emit-draft` has not
@ -1228,24 +1231,28 @@ one used — verified against a live private clone.
target) and how each says so out loud. Kept here, struck through, because target) and how each says so out loud. Kept here, struck through, because
this entry is *why nobody looked*: a limitation filed as a defect gets fixed, this entry is *why nobody looked*: a limitation filed as a defect gets fixed,
and a defect filed as a limitation does not. and a defect filed as a limitation does not.
- **A service's `domains` cannot be applied via the API in Coolify 4.1.2, - **~~A service's hostnames cannot be applied via the API in Coolify 4.1.2.~~**
and is deliberately kept out of the diffed `fields` for idempotency.** (This **Corrected (#72).** This entry used to claim, re-checked, that "Coolify 4.1.2
used to cite backup schedules as its precedent; it can't any more — that exposes no flat `domains` on a service, on any route" — and drop a service's
reasoning was disproved above. This one was re-checked and holds: Coolify domains from `fields`, warning that hostnames were a manual Coolify UI act. The
4.1.2 exposes no flat `domains` on a service, on any route. If that is ever claim was true of the FLAT shape and false of the CAPABILITY, the same failure
disproved the same way, `domains` belongs in `fields` too.) The `/services` mode #51 corrected for backups: the per-container route was there at 4.1.2 all
create/update payload takes a structured per-container `urls` list, not along. `POST /services` and `PATCH /services/{uuid}` both take a structured
the manifest's flat `domains: string[]`, and the manifest has no `urls` list (`[{name, url}]`, `url` comma-joined) that `applyServiceUrls`
per-container name to build that list correctly from — so cast matches to a `ServiceApplication` by name and stores as its `fqdn`; `GET
drops it rather than send a malformed payload. Live Coolify service state /services/{uuid}` loads `applications` and returns each `fqdn`
doesn't expose a flat `domains` back either, so if it stayed in `fields` (`ServicesController` v4.1.2). So services now speak the SAME per-container
every domain-bearing service would diff as a perpetual update and every vocabulary a dockercompose app does — a `service_domains: { <container>: [url] }`
`apply` would needlessly restart it — `desiredFromManifest` drops map — carried in `fields`, written on create *and* update, and compared on
`domains` from the service's `fields` and **warns** every run off the read-back (one supplementary `GET /services/{uuid}` per
(`service <name> declares domains (...), but apply cannot set them on service, gated to `diff`/`apply` like backups). A flat `domains: string[]` is
Coolify 4.1.2 services — configure hostnames manually in the Coolify UI`) gone: it could never name which container a hostname belongs to, which is
once per run for every service that declared any. Set service hostnames exactly what `urls` requires. Two limits remain, stated out loud: the read is
in the Coolify UI by hand. **fail-closed** (an unreachable/unrecognized `GET /services/{uuid}` aborts
rather than projecting empty and re-PATCHing forever), and a service create
whose domain conflicts is **deleted server-side before the 409**
(`applyServiceUrls` rollback) — which is why `service_domains` on a create is
pre-flighted (`desiredDomainsOfCreate`) alongside application domains.
- **"Include Source Commit in Build" cannot be enabled via the API in Coolify - **"Include Source Commit in Build" cannot be enabled via the API in Coolify
4.1.2 — `apply` warns instead.** A dockercompose application whose build 4.1.2 — `apply` warns instead.** A dockercompose application whose build
consumes `SOURCE_COMMIT` as a **build arg** only receives it if the consumes `SOURCE_COMMIT` as a **build arg** only receives it if the

View file

@ -93,6 +93,7 @@ import {
import { assertNoReservedEnvNames, reservedHits } from "./reserved.js"; import { assertNoReservedEnvNames, reservedHits } from "./reserved.js";
import { import {
PATH_IN_PROD_REFUSAL, PATH_IN_PROD_REFUSAL,
canonicalizeServiceDomains,
desiredFromManifest, desiredFromManifest,
fillDesiredDerived, fillDesiredDerived,
manifestResources, manifestResources,
@ -493,15 +494,55 @@ export async function attachBackup(
}; };
} }
// Read a service's per-container hostnames off GET /services/{uuid} and project
// them into `service_domains` on the Live's fields, so a declared hostname diffs
// like any other field (cast#72). The environment-list GET fetchLive reads does
// not eager-load `service.applications`, so this is a supplementary per-service
// read (see serviceByUuid).
//
// Unlike backup's not-compared escape, this FAILS CLOSED: a service whose domains
// cannot be read is NOT projected empty — that would diff a declared hostname as
// "will set" and let apply re-PATCH it every run — the read throws and aborts.
// GET /services/{uuid} for a service the environment list just named is not
// expected to fail; when it does, refusing beats a confident-but-blind plan
// (#12/#14/#17). A service with genuinely NO hostnames leaves service_domains
// absent, so a manifest declaring none stays clean and one declaring some drifts.
export async function attachServiceDomains(
client: CoolifyClient,
svc: Live,
): Promise<void> {
const raw = (await client.serviceByUuid(svc.uuid)) as {
applications?: Array<{ name?: unknown; fqdn?: unknown }>;
} | null;
if (!raw || !Array.isArray(raw.applications)) {
throw new Error(
`GET /services/${svc.uuid} returned no applications array — cannot read service ${svc.name}'s hostnames to diff them`,
);
}
const map: Record<string, string[]> = {};
for (const app of raw.applications) {
const name = typeof app.name === "string" ? app.name : undefined;
const fqdn = typeof app.fqdn === "string" ? app.fqdn : "";
const urls = fqdn
.split(",")
.map((u) => u.trim())
.filter(Boolean);
if (name && urls.length > 0) map[name] = urls;
}
if (Object.keys(map).length > 0) {
svc.fields.service_domains = canonicalizeServiceDomains(map);
}
}
export async function fetchLive( export async function fetchLive(
client: CoolifyClient, client: CoolifyClient,
projectName: string, projectName: string,
envName: string, envName: string,
// Backups cost one extra GET per database, so only the callers that actually // Backups and service hostnames each cost one extra GET per resource, so only
// compare them ask for them: `diff` and `apply`. The read-side sweeps // the callers that actually compare them ask: `diff` and `apply`. The read-side
// (inventory, capture, smoke) walk every project on a box and would pay it on // sweeps (inventory, capture, smoke) walk every project on a box and would pay
// every database for an answer they never look at. // it on every resource for an answer they never look at.
opts: { backups?: boolean } = {}, opts: { backups?: boolean; serviceDomains?: boolean } = {},
): Promise<LiveLookup> { ): Promise<LiveLookup> {
const projects = (await client.get("/projects")) as Array<{ const projects = (await client.get("/projects")) as Array<{
uuid: string; uuid: string;
@ -599,6 +640,11 @@ export async function fetchLive(
await attachBackup(client, db); await attachBackup(client, db);
} }
} }
if (opts.serviceDomains) {
for (const svc of live.filter((l) => l.kind === "service")) {
await attachServiceDomains(client, svc);
}
}
return { found: true, live }; return { found: true, live };
} }
@ -1067,9 +1113,11 @@ async function runProject(
parseYaml(readFileSync(ctx.hostnameOverlay, "utf8")), parseYaml(readFileSync(ctx.hostnameOverlay, "utf8")),
); );
} }
// `backups: true` — this is the one path that compares them (see fetchLive). // `backups`/`serviceDomains` — diff and apply are the one path that compares
// each, and each costs a supplementary GET per resource (see fetchLive).
const lookup = await fetchLive(ctx.client, projectName, coolifyEnv, { const lookup = await fetchLive(ctx.client, projectName, coolifyEnv, {
backups: true, backups: true,
serviceDomains: true,
}); });
// apply and diff take opposite (and both correct) positions on absence: // apply and diff take opposite (and both correct) positions on absence:
// apply is *allowed* to be the thing that brings a project into existence, // apply is *allowed* to be the thing that brings a project into existence,
@ -2621,14 +2669,29 @@ export function databaseApiFields(
export function serviceApiFields( export function serviceApiFields(
fields: Record<string, unknown>, fields: Record<string, unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
// /services accepts `urls`, a structured per-container list // /services accepts `urls`, a per-container list ({name, url}[]) — the create
// ({name, url}[]), not the flat `domains` string list the manifest // (POST /services) and update (PATCH /services/{uuid}) allowlists both carry
// speaks. manifest.ts's ServiceSpecSchema has no notion of per-container // it, and applyServiceUrls matches `urls[].name` to a ServiceApplication and
// name, so we can't build a correct `urls` payload from `domains` alone — // sets its `fqdn`, `url` being that container's URLs comma-joined (verified
// dropped rather than sent malformed. Known limitation: service hostnames // against ServicesController v4.1.2, cast#72). `service_domains` speaks the
// need manual Coolify UI configuration (see README, Task 10). // internal map vocabulary (container -> string[]); this is the exact shape
const { domains: _domains, ...rest } = fields; // dockercompose apps' `docker_compose_domains` is written with, one route over.
return rest; //
// A `url` whose `name` matches no container is a 422 on update and, on CREATE,
// deletes the just-made service before answering 422 (applyServiceUrls's
// rollback) — so the name must be a real container. buildExecutor surfaces
// either as-is; the operator reads the right name off a `cast diff` read-back.
const { service_domains, ...rest } = fields;
return {
...rest,
...(service_domains !== undefined
? {
urls: Object.entries(service_domains as Record<string, string[]>).map(
([name, urls]) => ({ name, url: urls.join(",") }),
),
}
: {}),
};
} }
// --- Domain uniqueness: instance-wide, while cast plans project-scoped (#44) --- // --- Domain uniqueness: instance-wide, while cast plans project-scoped (#44) ---
@ -2684,14 +2747,18 @@ function nakedDomain(raw: string): string {
return d.endsWith("/") ? d.slice(0, -1) : d; return d.endsWith("/") ? d.slice(0, -1) : d;
} }
// The domains a planned CREATE would claim. Applications only, and that is not an // The domains a planned CREATE would claim: an application's flat `domains` or
// oversight: databases have no domains, and cast's service creates drop `domains` // compose `docker_compose_domains`, and now a service's `service_domains`
// on the wire entirely (serviceApiFields above), so an application create is the // (cast#72 — service creates send `urls`; databases have no domains). Worth
// only way an apply can claim one. // pre-flighting for services in particular, because a service create whose
// domain conflicts does not just 409 — applyServiceUrls DELETES the half-made
// service first (v4.1.2 rollback), so catching it here costs nothing where the
// server-side failure costs a resurrection.
export function desiredDomainsOfCreate( export function desiredDomainsOfCreate(
change: Change, change: Change,
): Array<{ domain: string; service?: string }> { ): Array<{ domain: string; service?: string }> {
if (change.kind !== "application" || change.op !== "create") return []; if (change.op !== "create") return [];
if (change.kind !== "application" && change.kind !== "service") return [];
const fields = Object.fromEntries( const fields = Object.fromEntries(
change.fieldDiffs.map((f) => [f.field, f.desired]), change.fieldDiffs.map((f) => [f.field, f.desired]),
); );
@ -2702,11 +2769,14 @@ export function desiredDomainsOfCreate(
if (typeof d === "string" && d.length > 0) if (typeof d === "string" && d.length > 0)
out.push({ domain: nakedDomain(d) }); out.push({ domain: nakedDomain(d) });
} }
const compose = fields.docker_compose_domains as // dockercompose apps and services share the per-container map shape
| Record<string, string[]> // (docker_compose_domains / service_domains); the two never coexist on one
| undefined; // resource. A container's name rides out as `service` so a conflict can say
if (compose) { // which container wanted the domain.
for (const [service, urls] of Object.entries(compose)) const perContainer = (fields.docker_compose_domains ??
fields.service_domains) as Record<string, string[]> | undefined;
if (perContainer) {
for (const [service, urls] of Object.entries(perContainer))
for (const d of urls ?? []) for (const d of urls ?? [])
if (typeof d === "string" && d.length > 0) if (typeof d === "string" && d.length > 0)
out.push({ domain: nakedDomain(d), service }); out.push({ domain: nakedDomain(d), service });

View file

@ -330,6 +330,17 @@ export class CoolifyClient {
return this.get(`/databases/${encodeURIComponent(uuid)}/backups`); return this.get(`/databases/${encodeURIComponent(uuid)}/backups`);
} }
// A service's per-container hostnames live on `service.applications[].fqdn`,
// and only GET /services/{uuid} loads that relation ($service->load(['appli-
// cations','databases']), v4.1.2) — the environment-list GET that fetchLive
// reads does NOT. So reading a service's domains back to diff them (cast#72)
// costs this one extra GET per service. Returns the raw body; attachService-
// Domains projects `applications[].fqdn` into the manifest's service_domains
// shape and fails closed on any unrecognized answer.
async serviceByUuid(uuid: string): Promise<unknown> {
return this.get(`/services/${encodeURIComponent(uuid)}`);
}
// What a Coolify DELETE removes, made explicit rather than inherited. // What a Coolify DELETE removes, made explicit rather than inherited.
// //
// All four are query parameters on DELETE /applications|databases|services/{uuid}, // All four are query parameters on DELETE /applications|databases|services/{uuid},

View file

@ -603,17 +603,21 @@ function serviceSpec(
hasEnv: boolean, hasEnv: boolean,
uncaptured: UncapturedItem[], uncaptured: UncapturedItem[],
): Spec { ): Spec {
// Coolify 4.1.2's Service model carries no flat `domains` — hostnames live // A service's per-container hostnames (`service.applications[].fqdn`) ARE
// per-container on `service.applications[].fqdn`, which no endpoint cast uses // settable and readable via the API — `urls` on create/PATCH, and
// returns (see projectLiveFields / serviceApiFields in cli.ts, and // GET /services/{uuid} on read — so `diff`/`apply` now carry them as
// desiredFromManifest's warning). Not readable, not writable, not in the // `service_domains` (cast#72). What the DRAFT path cannot yet do is CAPTURE
// draft: a service that serves a hostname today would come back serving none. // them: the inventory sweep reads the environment list, which does not
// eager-load `service.applications`, and does not make the supplementary
// per-service GET. So a service that serves a hostname today comes back with
// none in this draft — until it is declared by hand. Same shape as the backup
// schedule (#51): the API answers, the draft path has not been taught to ask.
uncaptured.push({ uncaptured.push({
project, project,
resource: r.name, resource: r.name,
setting: "domains (hostnames)", setting: "service_domains (hostnames)",
detail: detail:
"Coolify 4.1.2 exposes no flat `domains` on a service — hostnames live per-container on service.applications[].fqdn, which cast can neither read nor write. Whatever hostnames this service answers on are NOT in this draft. Read them off the Coolify UI and set them there after a rebuild.", "a service's per-container hostnames ARE settable/readable via the API (`urls` on create/PATCH, `service.applications[].fqdn` on GET /services/{uuid}) — `diff`/`apply` carry them as `service_domains` (cast#72) — but `inventory --emit-draft` does not yet make that per-service GET, so they are NOT captured here. Read them off a `cast diff` or the Coolify UI and declare `service_domains: { <container>: [url] }` yourself.",
}); });
return { return {
type: String(r.raw.service_type ?? r.raw.type ?? ""), type: String(r.raw.service_type ?? r.raw.type ?? ""),
@ -786,7 +790,7 @@ const NO_API_COVERAGE: Array<[string, string]> = [
], ],
[ [
"service hostnames", "service hostnames",
"no flat `domains` on a service — they live per-container on `service.applications[].fqdn`, which cast can neither read nor write (Coolify 4.1.2).", "settable/readable via the API (`urls` on create/PATCH, `service.applications[].fqdn` on GET /services/{uuid}) — `diff`/`apply` carry them as `service_domains` (cast#72) — but `inventory --emit-draft` does not yet make the per-service GET, so a drafted service has none until you declare them.",
], ],
[ [
"Basic Auth / custom Traefik labels", "Basic Auth / custom Traefik labels",

View file

@ -147,7 +147,16 @@ const DatabaseSpecSchema = z
const ServiceSpecSchema = z const ServiceSpecSchema = z
.object({ .object({
type: z.string(), type: z.string(),
domains: z.array(z.string()).optional(), // Per-container hostnames, exactly the vocabulary a dockercompose app uses
// (a map of container name -> URLs). A Coolify service is a bundle of
// containers (`ServiceApplication`s), and a hostname is set on ONE of them —
// so a flat `domains: string[]` cannot say which, and cannot build the
// `urls: [{name, url}]` payload the API matches to a container by name
// (cast#72, verified against ServicesController@applyServiceUrls v4.1.2).
// The name is the container's, discoverable from a `cast diff` read-back or
// the Coolify UI. Written on create/PATCH, read back off
// `service.applications[].fqdn`, and diffed like any other field.
service_domains: z.record(z.array(z.string())).optional(),
env_template: z.string().optional(), env_template: z.string().optional(),
}) })
.strict(); .strict();

View file

@ -509,6 +509,24 @@ export function manifestResources(
return resources; return resources;
} }
// Sort the keys and each URL array of a service's `service_domains` map, so the
// same set of per-container hostnames compares equal whatever order the manifest
// authored them in or Coolify returns them in. Both the desired side
// (desiredFromManifest) and the live read-back (attachServiceDomains in cli.ts)
// run this before computeDiff compares by JSON.stringify — without it, a service
// whose containers Coolify lists in a different order than the manifest would
// diff forever. Order is meaningless for hostnames (Coolify matches `urls[].name`
// to a container and treats the URLs as a set), so canonicalizing loses nothing.
export function canonicalizeServiceDomains(
map: Record<string, string[]>,
): Record<string, string[]> {
return Object.fromEntries(
Object.keys(map)
.sort()
.map((k) => [k, [...map[k]].sort()]),
);
}
export function desiredFromManifest( export function desiredFromManifest(
checkoutDir: string, checkoutDir: string,
envName: string, envName: string,
@ -663,33 +681,30 @@ export function desiredFromManifest(
}); });
} }
for (const [name, svc] of Object.entries(envSpec.services ?? {})) { for (const [name, svc] of Object.entries(envSpec.services ?? {})) {
if (svc.domains && svc.domains.length > 0) {
// Coolify 4.1.2's service executor has no flat `domains` concept —
// hostnames live per-container on `urls` (see serviceApiFields in
// cli.ts) — so a manifest-declared service `domains` list is silently
// unhonorable by apply. Warn at build time, once per run, while the
// service name is still in scope.
console.warn(
`service ${name} declares domains (${svc.domains.join(", ")}), but apply cannot set them on Coolify 4.1.2 services — configure hostnames manually in the Coolify UI`,
);
}
desired.push({ desired.push({
kind: "service", kind: "service",
name, name,
// domains dropped from fields: the live side (projectLiveFields in // service_domains is a DIFFED FIELD (cast#72). Coolify 4.1.2 sets a
// cli.ts) can't read service domains and the write side // service's per-container hostnames via `urls` on create/PATCH and returns
// (serviceApiFields) drops them, so keeping domains in fields makes // them on `service.applications[].fqdn` — so a declared hostname is now
// every domain-bearing service diff as a perpetual update. Hostnames // WRITTEN by apply and VERIFIED by diff, not a manual Coolify UI act.
// stay a manual Coolify UI act (warned above).
// //
// This USED to cite database `backup` as its precedent. It no longer // Canonicalized (see canonicalizeServiceDomains) so container order in the
// can: `backup` was dropped on the same reasoning and the reasoning // manifest never diffs against Coolify's own ordering on read-back. The
// turned out to be false there (a read route existed, unlooked-for — // live side (attachServiceDomains in cli.ts) canonicalizes identically.
// see the databases loop above). The difference is that this one was //
// re-checked: Coolify 4.1.2 genuinely exposes no flat `domains` on a // This block USED to warn that a service's domains were unhonorable and
// service, on any route. If that is ever disproved the same way, this // drop them, citing a re-checked "no flat `domains` on a 4.1.2 service, on
// belongs in `fields` too. // any route". That was true of the FLAT shape and false of the capability:
fields: { type: svc.type }, // the per-container `urls` route was there at 4.1.2 all along — the same
// arc as `backup` (#51), which was dropped on the same "can't read it back"
// reasoning that also turned out false.
fields: {
type: svc.type,
...(svc.service_domains
? { service_domains: canonicalizeServiceDomains(svc.service_domains) }
: {}),
},
env: resolveEnvFile(name, svc.env_template), env: resolveEnvFile(name, svc.env_template),
}); });
} }

View file

@ -82,9 +82,10 @@ describe("reading the domains of a plan and of an instance", () => {
]); ]);
}); });
// Databases have none, and cast's service creates drop `domains` on the wire // Databases have no domains, and an update never claims (apply diffs the field
// (serviceApiFields) — an application create is the only way an apply claims one. // and PATCHes it, it does not create). A SERVICE create does now claim — see
it("claims nothing for a database or service create, or for an update", () => { // the next test.
it("claims nothing for a database create or an update", () => {
const db: Change = { const db: Change = {
kind: "database", kind: "database",
name: "postgres", name: "postgres",
@ -101,6 +102,29 @@ describe("reading the domains of a plan and of an instance", () => {
expect(desiredDomainsOfCreate(update)).toEqual([]); expect(desiredDomainsOfCreate(update)).toEqual([]);
}); });
// cast#72: a service create sends `urls`, so its per-container service_domains
// must be pre-flighted — the more so because a service create whose domain
// conflicts is DELETED server-side before the 409 (applyServiceUrls rollback).
it("claims a service create's per-container service_domains", () => {
const svc: Change = {
kind: "service",
name: "umami",
op: "create",
fieldDiffs: [
{ field: "type", desired: "umami", updatable: false },
{
field: "service_domains",
desired: { umami: ["https://analytics.example.com"] },
updatable: true,
},
],
envDiffs: [],
};
expect(desiredDomainsOfCreate(svc)).toEqual([
{ domain: "https://analytics.example.com", service: "umami" },
]);
});
it("reads both live shapes: fqdn, and per-service compose domains", () => { it("reads both live shapes: fqdn, and per-service compose domains", () => {
expect( expect(
liveApplicationDomains( liveApplicationDomains(

View file

@ -19,7 +19,8 @@ environments:
services: services:
metabase: metabase:
type: metabase type: metabase
domains: ["https://metabase.example.com"] service_domains:
metabase: ["https://metabase.example.com"]
staging: staging:
applications: applications:
core-api: core-api:

View file

@ -1,5 +1,10 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { attachBackup, fetchLive, renderAbsentTarget } from "../src/cli.js"; import {
attachBackup,
attachServiceDomains,
fetchLive,
renderAbsentTarget,
} from "../src/cli.js";
import { CoolifyClient } from "../src/coolify.js"; import { CoolifyClient } from "../src/coolify.js";
// A Coolify that answers GET /projects with `projects`, and // A Coolify that answers GET /projects with `projects`, and
@ -270,3 +275,74 @@ describe("attachBackup", () => {
expect("backup" in l.fields).toBe(false); expect("backup" in l.fields).toBe(false);
}); });
}); });
describe("attachServiceDomains (cast#72)", () => {
const svc = () => ({
kind: "service" as const,
name: "umami",
uuid: "svc-1",
fields: { type: "umami" },
});
// A Coolify whose GET /services/svc-1 answers with `body`.
const client = (body: unknown, status = 200) =>
new CoolifyClient(
"https://coolify.test",
"tok",
vi.fn(
async () =>
new Response(status === 200 ? JSON.stringify(body) : "boom", {
status,
}),
) as unknown as typeof fetch,
);
it("projects applications[].fqdn into canonicalized service_domains", async () => {
const l = svc();
await attachServiceDomains(
client({
applications: [
// Out of order, comma-joined, one container with two URLs — all
// canonicalized so the read matches the desired side regardless.
{ name: "web", fqdn: "https://b.example.com,https://a.example.com" },
{ name: "collector", fqdn: "https://collect.example.com" },
],
}),
l,
);
expect(l.fields.service_domains).toEqual({
collector: ["https://collect.example.com"],
web: ["https://a.example.com", "https://b.example.com"],
});
});
it("leaves service_domains absent when no container has a hostname", async () => {
const l = svc();
await attachServiceDomains(
client({
applications: [
{ name: "web", fqdn: "" },
{ name: "collector", fqdn: null },
],
}),
l,
);
// A service with no hostnames stays clean against a manifest that declares
// none, and drifts against one that declares some.
expect("service_domains" in l.fields).toBe(false);
});
it("FAILS CLOSED — throws rather than projecting empty — when the read is unreachable", async () => {
const l = svc();
// A blind empty projection would diff a declared hostname as "will set" and
// re-PATCH it forever; refusing is the safe answer (#12/#14/#17).
await expect(attachServiceDomains(client(null, 500), l)).rejects.toThrow();
expect("service_domains" in l.fields).toBe(false);
});
it("FAILS CLOSED when the answer has no applications array", async () => {
const l = svc();
await expect(
attachServiceDomains(client({ uuid: "svc-1" }), l),
).rejects.toThrow(/applications array/);
});
});

View file

@ -270,30 +270,7 @@ environments:
// alone, like every other thing apply never removes. // alone, like every other thing apply never removes.
expect("backup" in desired[0].fields).toBe(false); expect("backup" in desired[0].fields).toBe(false);
}); });
it("warns when a service declares domains (unhonorable by apply on Coolify 4.1.2)", () => { it("emits a service's service_domains into fields, canonicalized (cast#72)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
services:
plausible:
type: plausible
domains: ["https://stats.staging.example.com"]
`,
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0]).toMatchObject({ kind: "service", name: "plausible" });
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toMatch(/plausible/);
expect(warn.mock.calls[0][0]).toMatch(/domains/);
warn.mockRestore();
});
it("drops domains from a domain-bearing service's fields (mirrors database backup handling)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
@ -305,15 +282,21 @@ environments:
services: services:
umami: umami:
type: umami type: umami
domains: ["https://analytics.example.com"] service_domains:
umami: ["https://b.example.com", "https://a.example.com"]
`, `,
); );
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired } = desiredFromManifest(dir, "prod", {}); const { desired } = desiredFromManifest(dir, "prod", {});
expect(desired[0].fields).toEqual({ type: "umami" }); // Keys and each URL array are sorted so container order never false-drifts
warn.mockRestore(); // against Coolify's read-back ordering.
expect(desired[0].fields).toEqual({
type: "umami",
service_domains: {
umami: ["https://a.example.com", "https://b.example.com"],
},
});
}); });
it("computeDiff is clean for a domain-bearing service against a matching live service (no perpetual update)", () => { it("is clean for a service whose live per-container hostnames match (cast#72, no perpetual update)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
@ -325,12 +308,45 @@ environments:
services: services:
umami: umami:
type: umami type: umami
domains: ["https://analytics.example.com"] service_domains:
umami: ["https://analytics.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "prod", {});
const report = computeDiff(
desired,
[
{
kind: "service",
name: "umami",
uuid: "svc-uuid",
fields: {
type: "umami",
service_domains: { umami: ["https://analytics.example.com"] },
},
},
],
"full",
);
expect(report.clean).toBe(true);
});
it("diffs a service whose declared hostname is missing live (apply will set it)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications: {}
services:
umami:
type: umami
service_domains:
umami: ["https://analytics.example.com"]
`, `,
); );
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired } = desiredFromManifest(dir, "prod", {}); const { desired } = desiredFromManifest(dir, "prod", {});
warn.mockRestore();
const report = computeDiff( const report = computeDiff(
desired, desired,
[ [
@ -341,11 +357,19 @@ environments:
fields: { type: "umami" }, fields: { type: "umami" },
}, },
], ],
"structural", "full",
); );
expect(report.clean).toBe(true); expect(report.clean).toBe(false);
expect(report.changes[0].fieldDiffs).toEqual([
{
field: "service_domains",
desired: { umami: ["https://analytics.example.com"] },
live: undefined,
updatable: true,
},
]);
}); });
it("does not warn for a service with no domains", () => { it("a service with no service_domains carries only its type", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
@ -359,10 +383,8 @@ environments:
type: plausible type: plausible
`, `,
); );
const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const { desired } = desiredFromManifest(dir, "staging", {});
desiredFromManifest(dir, "staging", {}); expect(desired[0].fields).toEqual({ type: "plausible" });
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
}); });
it("resolves a dockercompose app to docker_compose_location/docker_compose_domains and no port/healthcheck/domains keys", () => { it("resolves a dockercompose app to docker_compose_location/docker_compose_domains and no port/healthcheck/domains keys", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = mkdtempSync(join(tmpdir(), "infra-co-"));

View file

@ -56,13 +56,33 @@ describe("databaseApiFields", () => {
}); });
describe("serviceApiFields", () => { describe("serviceApiFields", () => {
it("drops domains (services have no flat-domains create/update field)", () => { // cast#72: a service's per-container hostnames go on the wire as `urls`
// ({name, url}[], url comma-joined) — matched to a ServiceApplication by name
// on create/PATCH. `service_domains` is the internal map that becomes it.
it("builds `urls` from service_domains", () => {
const out = serviceApiFields({ const out = serviceApiFields({
type: "plausible", type: "plausible",
domains: ["https://stats.example.com"], service_domains: {
web: ["https://stats.example.com", "https://alt.example.com"],
collector: ["https://collect.example.com"],
},
}); });
expect(out).toEqual({
type: "plausible",
urls: [
{
name: "web",
url: "https://stats.example.com,https://alt.example.com",
},
{ name: "collector", url: "https://collect.example.com" },
],
});
expect(out).not.toHaveProperty("service_domains");
});
it("sends no `urls` for a service with no service_domains", () => {
const out = serviceApiFields({ type: "plausible" });
expect(out).toEqual({ type: "plausible" }); expect(out).toEqual({ type: "plausible" });
expect(out).not.toHaveProperty("domains"); expect(out).not.toHaveProperty("urls");
}); });
}); });