feat(draft): capture service hostnames via per-service GET (#83)
#73/#81 made a service's per-container hostnames settable (urls) and
readable (GET /services/{uuid} -> applications[].fqdn), and diff/apply
carry them as service_domains — but the draft path was never brought
along: the inventory sweep's environment-list GET does not eager-load
service.applications, so --emit-draft emitted every service with no
hostnames and an UNCAPTURED hand-wave.
Now the draft loop makes the same supplementary per-service GET that
diff/apply make (sibling of #75's per-database backups read — one
design, both reads: ungated for DRAFTED resources only, sequential,
per-resource failure degrades to an UNCAPTURED entry instead of
aborting the whole-instance sweep).
The projection is SHARED, not duplicated: projectServiceDomains is
extracted out of attachServiceDomains and exported, so the draft emits
applications[].fqdn through the exact projection + canonicalization
(canonicalizeServiceDomains) the diff's read-back uses — a drafted
manifest diffs clean the moment it is applied. Its two absences stay
distinct: {} is an answer (no hostnames; nothing emitted, nothing
reported), undefined is "not read" — attachServiceDomains still fails
a one-project diff closed on it, while serviceSpec reports it per
resource and keeps sweeping.
The stale "service hostnames" NO_API_COVERAGE row and the
service_domains (hostnames) always-uncaptured entry are gone, and
semantics.md's "does not yet make the per-service GET" line now tells
the truth.
Closes #83
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1210ae4445
commit
4d8a326b96
6 changed files with 262 additions and 44 deletions
|
|
@ -1056,11 +1056,14 @@ 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
|
||||
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
|
||||
to resolve it to the UUID `destination_uuid:` wants, #21), service hostnames
|
||||
(settable/readable via the API and carried by `diff`/`apply` as `service_domains`
|
||||
since #72, but `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,
|
||||
to resolve it to the UUID `destination_uuid:` wants, #21), a service whose
|
||||
hostnames could not be read (they **are captured** since #83 — the draft makes
|
||||
the per-service `GET /services/{uuid}` that `diff`/`apply` have made since
|
||||
#72/#81 and emits `service_domains` from `applications[].fqdn`, through the same
|
||||
projection the diff's read-back uses, so a drafted service diffs clean once
|
||||
applied; a service whose GET is unreachable or unrecognized is reported per
|
||||
resource instead — where a one-project diff fails closed, a whole-instance sweep
|
||||
reports and keeps going), Basic Auth / custom Traefik labels,
|
||||
build and deploy command overrides, what a backup schedule cannot fully say
|
||||
(the schedule itself **is captured** since #75 — the draft reads
|
||||
`GET /databases/{uuid}/backups`, the route `diff`/`apply` have used since #51,
|
||||
|
|
|
|||
70
src/cli.ts
70
src/cli.ts
|
|
@ -495,6 +495,40 @@ export async function attachBackup(
|
|||
};
|
||||
}
|
||||
|
||||
// Project GET /services/{uuid}'s body into the manifest's `service_domains`
|
||||
// shape: `applications[].fqdn` → a canonicalized map of container name → URLs.
|
||||
// THE one projection, shared by the diff/apply read (attachServiceDomains,
|
||||
// below) and the draft (#83) — two projections of the same wire shape would
|
||||
// drift, and a drafted manifest that disagrees with the diff's read-back by so
|
||||
// much as URL order would diff dirty the moment it is applied.
|
||||
//
|
||||
// The two answers stay distinct, because they mean opposite things to every
|
||||
// caller (the BackupRead lesson, on a different route):
|
||||
//
|
||||
// undefined -> NOT READ: no body, or no applications array. Says nothing.
|
||||
// a map -> read cleanly. `{}` is an ANSWER — this service serves no
|
||||
// hostnames — not a failure; a caller must not flatten it into
|
||||
// the unreadable case.
|
||||
export function projectServiceDomains(
|
||||
raw: unknown,
|
||||
): Record<string, string[]> | undefined {
|
||||
const body = raw as {
|
||||
applications?: Array<{ name?: unknown; fqdn?: unknown }>;
|
||||
} | null;
|
||||
if (!body || !Array.isArray(body.applications)) return undefined;
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const app of body.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;
|
||||
}
|
||||
return canonicalizeServiceDomains(map);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -508,30 +542,21 @@ export async function attachBackup(
|
|||
// 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.
|
||||
// (The draft takes the opposite position on the same unreadable answer — it
|
||||
// REPORTS in UNCAPTURED.md rather than aborting a whole-instance sweep — which
|
||||
// is exactly why the projection above is a separate function.)
|
||||
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)) {
|
||||
const map = projectServiceDomains(await client.serviceByUuid(svc.uuid));
|
||||
if (map === undefined) {
|
||||
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);
|
||||
svc.fields.service_domains = map;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1968,6 +1993,21 @@ async function main(): Promise<number> {
|
|||
r.env = flattenEnv(
|
||||
await fetchEnv(client, { kind: r.kind, uuid: r.uuid }),
|
||||
);
|
||||
// A service's hostnames live behind the same kind of supplementary
|
||||
// GET as a database's backups (#83, sibling of #75 — one design, both
|
||||
// reads): GET /services/{uuid} is the only route that eager-loads
|
||||
// service.applications, so the draft pays it per DRAFTED service,
|
||||
// ungated and sequential, and projects the answer through THE
|
||||
// projection diff/apply use (projectServiceDomains) so the drafted
|
||||
// manifest diffs clean the moment it is applied. A failed read lands
|
||||
// on `undefined` and becomes an UNCAPTURED entry (see serviceSpec) —
|
||||
// where attachServiceDomains fails a one-project diff closed, a
|
||||
// whole-instance sweep reports and keeps going.
|
||||
if (r.kind === "service") {
|
||||
r.serviceDomains = projectServiceDomains(
|
||||
await client.serviceByUuid(r.uuid).catch(() => undefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
draftProjects.push({
|
||||
name: p.name,
|
||||
|
|
|
|||
61
src/draft.ts
61
src/draft.ts
|
|
@ -43,8 +43,8 @@ import { encryptSecrets } from "./secrets.js";
|
|||
// wrong in four entries out of seventeen is worse than one that is obviously
|
||||
// incomplete.
|
||||
//
|
||||
// 2. SILENT LOSSES. cast cannot express everything a Coolify holds — service
|
||||
// hostnames, destinations (#21), Basic Auth, build toggles, whole database
|
||||
// 2. SILENT LOSSES. cast cannot express everything a Coolify holds —
|
||||
// destinations (#21), Basic Auth, build toggles, whole database
|
||||
// kinds. A blueprint that omits them WITHOUT SAYING SO is worse than no
|
||||
// blueprint, because in a disaster you would trust it and rebuild a
|
||||
// *different box*. Hence UNCAPTURED.md, which is emitted on every run, even
|
||||
|
|
@ -158,6 +158,16 @@ export type DraftResource = {
|
|||
// not read" — which the draft REPORTS in UNCAPTURED.md rather than aborting a
|
||||
// whole-instance sweep the way diff/apply refuse a single-project plan.
|
||||
backups?: BackupRead;
|
||||
// Services only: the per-container hostnames read off GET /services/{uuid} —
|
||||
// the same supplementary per-service GET diff/apply have made since #72/#81,
|
||||
// made here by the CLI's draft loop for every DRAFTED service and projected
|
||||
// through the SAME projection (projectServiceDomains in cli.ts), so a drafted
|
||||
// manifest and a diff read-back agree on the shape to the byte. `{}` is an
|
||||
// ANSWER (this service serves no hostnames — nothing to draft, nothing to
|
||||
// report); `undefined` means the GET was unreadable, which the draft REPORTS
|
||||
// in UNCAPTURED.md rather than aborting the sweep the way
|
||||
// attachServiceDomains fails a one-project diff closed.
|
||||
serviceDomains?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type DraftProject = {
|
||||
|
|
@ -649,24 +659,35 @@ function serviceSpec(
|
|||
hasEnv: boolean,
|
||||
uncaptured: UncapturedItem[],
|
||||
): Spec {
|
||||
// A service's per-container hostnames (`service.applications[].fqdn`) ARE
|
||||
// settable and readable via the API — `urls` on create/PATCH, and
|
||||
// GET /services/{uuid} on read — so `diff`/`apply` now carry them as
|
||||
// `service_domains` (cast#72). What the DRAFT path cannot yet do is CAPTURE
|
||||
// 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({
|
||||
project,
|
||||
resource: r.name,
|
||||
setting: "service_domains (hostnames)",
|
||||
detail:
|
||||
"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.",
|
||||
});
|
||||
// A service's per-container hostnames ARE captured (#83): the CLI's draft
|
||||
// loop makes the per-service GET diff/apply have made since #72/#81, and
|
||||
// hands the map in already projected through THE projection the diff's
|
||||
// read-back uses (projectServiceDomains + canonicalizeServiceDomains in
|
||||
// cli.ts) — so what is drafted here diffs clean the moment it is applied.
|
||||
//
|
||||
// Only the read that FAILED stays a report. attachServiceDomains fails a
|
||||
// one-project diff closed on the same answer, because its output feeds an
|
||||
// apply; a draft's reader is a human adopting a box, and trading a
|
||||
// whole-instance blueprint for one unreadable service would be the worse
|
||||
// artifact — so the loss is named, per resource, and the sweep keeps going.
|
||||
const domains = r.serviceDomains;
|
||||
if (domains === undefined) {
|
||||
uncaptured.push({
|
||||
project,
|
||||
resource: r.name,
|
||||
setting: "service_domains (hostnames)",
|
||||
detail:
|
||||
"`GET /services/{uuid}` was unreachable or returned no applications array, so this service's per-container hostnames are NOT in this draft — if it serves one today, a rebuild from here would serve nothing until you declare it. Read them off a `cast diff` or the Coolify UI and set `service_domains: { <container>: [url] }` yourself.",
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: String(r.raw.service_type ?? r.raw.type ?? ""),
|
||||
// `{}` (read cleanly, no hostnames) emits NOTHING, exactly as the live side
|
||||
// leaves `service_domains` absent for a domainless service — a manifest
|
||||
// declaring none stays clean, and one declaring some drifts.
|
||||
...(domains && Object.keys(domains).length > 0
|
||||
? { service_domains: domains }
|
||||
: {}),
|
||||
...(hasEnv
|
||||
? { env_template: `env/${slug(r.name)}.${ctx.env}.env.template` }
|
||||
: {}),
|
||||
|
|
@ -834,10 +855,6 @@ const NO_API_COVERAGE: Array<[string, string]> = [
|
|||
"destinations",
|
||||
"Coolify 4.1.2 serves no destinations endpoint. A resource's `destination_id` comes back; the UUID that names it never does. Placement must be read from the UI (#21).",
|
||||
],
|
||||
[
|
||||
"service hostnames",
|
||||
"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",
|
||||
"carried as raw container labels. cast's manifest has no field for them, so a rebuilt resource is UNPROTECTED where the original was not.",
|
||||
|
|
|
|||
|
|
@ -174,6 +174,20 @@ async function stubCoolify(opts: { ambiguous?: boolean } = {}): Promise<Stub> {
|
|||
},
|
||||
]);
|
||||
|
||||
// The draft's supplementary per-service GET (#83) — the same route the
|
||||
// diff's read-back uses. Only the umami answers; the barber shop's s9
|
||||
// deliberately 404s below, so the draft's report-not-abort path is what a
|
||||
// whole-instance run actually exercises.
|
||||
if (path === "/services/s1")
|
||||
return json({
|
||||
uuid: "s1",
|
||||
name: "Incubator Umami",
|
||||
applications: [
|
||||
{ name: "umami", fqdn: "https://umami.box-b.example.com" },
|
||||
{ name: "db", fqdn: null },
|
||||
],
|
||||
});
|
||||
|
||||
const envs = path.match(/^\/[a-z]+\/([a-z0-9]+)\/envs$/);
|
||||
if (envs) return json(ENVS[envs[1]] ?? []);
|
||||
res.writeHead(404);
|
||||
|
|
@ -369,8 +383,12 @@ describe("cast inventory --emit-draft (#27)", () => {
|
|||
const md = readFileSync(join(f.out, "UNCAPTURED.md"), "utf8");
|
||||
|
||||
// Seen on the box, and inexpressible:
|
||||
expect(md).toContain("Incubator Umami"); // service hostnames (#21 / 4.1.2)
|
||||
expect(md).toContain("domains (hostnames)");
|
||||
// The umami's hostnames are CAPTURED now (#83) — it has nothing left to
|
||||
// report. The service whose per-service GET failed (barber-site) is the
|
||||
// one that must be listed, or a rebuild would silently serve nothing.
|
||||
expect(md).not.toContain("Incubator Umami");
|
||||
expect(md).toContain("barber-site");
|
||||
expect(md).toContain("service_domains (hostnames)");
|
||||
expect(md).toContain("destination"); // which Docker network (#21)
|
||||
expect(md).toContain("destination_id 3");
|
||||
expect(md).toContain("legacy-analytics"); // a MySQL cast cannot model
|
||||
|
|
@ -415,6 +433,45 @@ describe("cast inventory --emit-draft (#27)", () => {
|
|||
).toEqual({ frequency: "0 3 * * *", retention: 7 });
|
||||
});
|
||||
|
||||
it("captures a service's hostnames via the per-service GET (#83)", async () => {
|
||||
const f = fixture((await stubCoolify()).url);
|
||||
const r = await run([
|
||||
"--env",
|
||||
"prod",
|
||||
"--state",
|
||||
f.state,
|
||||
"--emit-draft",
|
||||
f.out,
|
||||
"--recipient",
|
||||
RECIPIENT,
|
||||
]);
|
||||
expect(r.code).toBe(0);
|
||||
// Projected through the SAME projection the diff's read-back uses, so a
|
||||
// drafted service diffs clean the moment it is applied. The container with
|
||||
// no fqdn is simply absent, exactly as attachServiceDomains reads it.
|
||||
const manifest = loadManifest(
|
||||
join(f.out, "incubator", ".infra", "manifest.yaml"),
|
||||
);
|
||||
expect(
|
||||
manifest.environments.prod.services?.["Incubator Umami"]?.service_domains,
|
||||
).toEqual({ umami: ["https://umami.box-b.example.com"] });
|
||||
|
||||
// The barber shop's GET /services/s9 404s. The sweep does not abort: its
|
||||
// manifest is still drafted, carries no hostnames, and the loss is NAMED.
|
||||
const barber = loadManifest(
|
||||
join(f.out, "martin-reyes-barber-shop", ".infra", "manifest.yaml"),
|
||||
);
|
||||
expect(
|
||||
barber.environments.prod.services?.["barber-site"],
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
barber.environments.prod.services?.["barber-site"],
|
||||
).not.toHaveProperty("service_domains");
|
||||
const md = readFileSync(join(f.out, "UNCAPTURED.md"), "utf8");
|
||||
expect(md).toContain("barber-site");
|
||||
expect(md).toContain("unreachable");
|
||||
});
|
||||
|
||||
it("writes the projects: registry — the list of what exists", async () => {
|
||||
const f = fixture((await stubCoolify()).url);
|
||||
await run([
|
||||
|
|
|
|||
|
|
@ -552,6 +552,72 @@ describe("backup schedules — read and drafted, not hand-waved (#75)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("service hostnames — read and drafted via the per-service GET (#83)", () => {
|
||||
const withService = (
|
||||
serviceDomains?: Record<string, string[]>,
|
||||
): DraftProject =>
|
||||
project({
|
||||
resources: [
|
||||
...project().resources,
|
||||
{
|
||||
kind: "service",
|
||||
name: "Incubator Umami",
|
||||
uuid: "s1",
|
||||
raw: { service_type: "umami" },
|
||||
env: {},
|
||||
serviceDomains,
|
||||
},
|
||||
],
|
||||
});
|
||||
const loadedSvc = (plan: ReturnType<typeof planDraft>) => {
|
||||
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
const path = join(dir, "manifest.yaml");
|
||||
writeFileSync(path, manifest?.content ?? "");
|
||||
return loadManifest(path).environments.prod.services?.["Incubator Umami"];
|
||||
};
|
||||
const hostnameItems = (plan: ReturnType<typeof planDraft>) =>
|
||||
plan.uncaptured.filter((u) => u.setting === "service_domains (hostnames)");
|
||||
|
||||
it("emits service_domains as the diff's own projection reads them — and nothing uncaptured", () => {
|
||||
const plan = planDraft(
|
||||
[
|
||||
withService({
|
||||
umami: ["https://umami.example.com"],
|
||||
web: ["https://a.example.com", "https://b.example.com"],
|
||||
}),
|
||||
],
|
||||
ctx,
|
||||
);
|
||||
expect(loadedSvc(plan)?.service_domains).toEqual({
|
||||
umami: ["https://umami.example.com"],
|
||||
web: ["https://a.example.com", "https://b.example.com"],
|
||||
});
|
||||
expect(hostnameItems(plan)).toEqual([]);
|
||||
// The stale "does not yet make the per-service GET" claim is gone from
|
||||
// every artifact — UNCAPTURED's standing table included.
|
||||
expect(JSON.stringify(plan.files)).not.toContain("does not yet make");
|
||||
});
|
||||
|
||||
it("emits nothing for a clean 'no hostnames' read — an answer, not a failure", () => {
|
||||
const plan = planDraft([withService({})], ctx);
|
||||
expect(loadedSvc(plan)).not.toHaveProperty("service_domains");
|
||||
expect(hostnameItems(plan)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an unreadable per-service GET rather than drafting a blank", () => {
|
||||
const plan = planDraft([withService(undefined)], ctx);
|
||||
expect(loadedSvc(plan)).not.toHaveProperty("service_domains");
|
||||
const items = hostnameItems(plan);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].resource).toBe("Incubator Umami");
|
||||
expect(items[0].detail).toContain("unreachable");
|
||||
// The rest of the draft survives: a whole-instance sweep reports one
|
||||
// unreadable service, it does not abort on it.
|
||||
expect(loadedSvc(plan)?.type).toBe("umami");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the emit refusals — adoption is one-way", () => {
|
||||
it("refuses a target directory that is not empty", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
attachServiceDomains,
|
||||
fetchEnv,
|
||||
fetchLive,
|
||||
projectServiceDomains,
|
||||
renderAbsentTarget,
|
||||
} from "../src/cli.js";
|
||||
import { CoolifyClient } from "../src/coolify.js";
|
||||
|
|
@ -421,3 +422,37 @@ describe("attachServiceDomains (cast#72)", () => {
|
|||
).rejects.toThrow(/applications array/);
|
||||
});
|
||||
});
|
||||
|
||||
// The projection itself, shared by the diff read above and the draft (#83).
|
||||
// Its two absences mean opposite things, and each caller takes its own
|
||||
// position on `undefined`: attachServiceDomains throws, the draft reports.
|
||||
describe("projectServiceDomains — one projection, two readers (#83)", () => {
|
||||
it("answers {} for a service with no hostnames — an ANSWER, not a failure", () => {
|
||||
expect(
|
||||
projectServiceDomains({
|
||||
applications: [
|
||||
{ name: "web", fqdn: "" },
|
||||
{ name: "collector", fqdn: null },
|
||||
],
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
it("answers undefined — 'not read' — for a body with no applications array", () => {
|
||||
expect(projectServiceDomains(null)).toBeUndefined();
|
||||
expect(projectServiceDomains(undefined)).toBeUndefined();
|
||||
expect(projectServiceDomains({ uuid: "svc-1" })).toBeUndefined();
|
||||
});
|
||||
it("canonicalizes, so the draft and the diff agree to the byte", () => {
|
||||
expect(
|
||||
projectServiceDomains({
|
||||
applications: [
|
||||
{ name: "web", fqdn: "https://b.example.com,https://a.example.com" },
|
||||
{ name: "collector", fqdn: "https://collect.example.com" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
collector: ["https://collect.example.com"],
|
||||
web: ["https://a.example.com", "https://b.example.com"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue