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:
parent
e3e52680e1
commit
bbca3cfff0
11 changed files with 382 additions and 123 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
118
src/cli.ts
118
src/cli.ts
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
20
src/draft.ts
20
src/draft.ts
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
3
test/fixtures/manifest.yaml
vendored
3
test/fixtures/manifest.yaml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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-"));
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue