diff --git a/docs/semantics.md b/docs/semantics.md index d1ade5f..6a8b382 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -1056,17 +1056,23 @@ 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 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 -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 -yet been taught to read them: `GET /databases/{uuid}/backups` answers, and `diff` -and `apply` now use it. Until the draft path does too, a blueprint still omits -them and still says so), database kinds cast +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, +and a single enabled schedule becomes a real `backup: { frequency, retention }` +block; what stays uncaptured is reported per database instead of guessed: the S3 +*target*, which reads back only as an unmappable `s3_storage_id` int, a DISABLED +schedule — the manifest cannot say "disabled", and declaring the block would make +the first `apply` re-enable it — a database carrying *several* schedules where a +manifest declares one, and a read that failed outright), database kinds cast does not model (MySQL, MariaDB, MongoDB, KeyDB, Dragonfly, ClickHouse — named, never silently dropped), env var names a cast template cannot express, names **reserved by the platform** (`SOURCE_COMMIT`, `COOLIFY_*` — suppressed, never diff --git a/src/cli.ts b/src/cli.ts index a929cfc..2a04a02 100644 --- a/src/cli.ts +++ b/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 | undefined { + const body = raw as { + applications?: Array<{ name?: unknown; fqdn?: unknown }>; + } | null; + if (!body || !Array.isArray(body.applications)) return undefined; + const map: Record = {}; + 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 { - 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 = {}; - 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; } } @@ -1952,10 +1977,37 @@ async function main(): Promise { for (const r of resources) { // Databases hold no manifest-templated env of their own — their URL is // what the APPS reference, and that name is generated, not captured. - if (r.kind === "database") continue; + // What a database DOES hold is a backup schedule, on its own route + // (#51) — and a blueprint that omits backups is the quietest possible + // loss, so the draft pays the one supplementary GET per DRAFTED + // database that diff/apply pay per compared one. Ungated on purpose: + // fetchLive's `opts.backups` gate exists because the read-side sweeps + // never look at the answer, and the draft is the sweep that does. A + // failed read lands on `undefined` and becomes an UNCAPTURED entry + // (see draftBackup) — reported, never aborting the whole-instance + // sweep the way diff/apply refuse a single-project plan. + if (r.kind === "database") { + r.backups = await client.databaseBackupSchedules(r.uuid); + continue; + } 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, diff --git a/src/coolify.ts b/src/coolify.ts index 7375ee0..182f66e 100644 --- a/src/coolify.ts +++ b/src/coolify.ts @@ -31,6 +31,13 @@ export type LiveBackup = { frequency: string; retention: number; enabled: boolean; + // Whether the schedule saves to S3 (`save_s3`). Carried for the DRAFT's + // UNCAPTURED report (#75): the schedule itself is now capturable, but its + // TARGET reads back only as `s3_storage_id` — an int no endpoint maps to a + // storage UUID — so "saves to S3" is readable and "saves to WHICH S3" is not, + // and a draft has to say so. diff/apply ignore it (apply asserts save_s3 + + // the environment's s3_destination on every write). + saveS3: boolean; }; // The result of trying to read a database's schedules. The two absences are @@ -56,6 +63,15 @@ function readEnabled(raw: Record): boolean { return !(v === false || v === 0 || v === "0"); } +// `save_s3` is a tinyint with no cast, exactly like `enabled`, so accept 1/0 +// too. Unlike `enabled`, an ABSENT value reads as FALSE: the only consumer is +// the draft's "this backup lands in S3" report, and that claim must never be +// made off a field the row did not carry. +function readSaveS3(raw: Record): boolean { + const v = raw.save_s3; + return v === true || v === 1 || v === "1"; +} + // Strict on purpose: a value cast cannot read EXACTLY is not coerced into a // guess, it collapses the whole read to `undefined` (= "not compared", said out // loud). Silence about a backup is the failure being fixed here; a wrong number @@ -114,6 +130,7 @@ export function parseBackupSchedules(raw: unknown): BackupRead { frequency, retention, enabled: readEnabled(row), + saveS3: readSaveS3(row), }); } return schedules; diff --git a/src/draft.ts b/src/draft.ts index 4dfcde7..cbc4779 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { stringify } from "yaml"; import { GENERATED_PLACEHOLDER } from "./capture.js"; +import type { BackupRead } from "./coolify.js"; import { isProviderGeneratedEnvName, isReservedEnvName, @@ -42,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 @@ -149,6 +150,24 @@ export type DraftResource = { // Live env vars. Empty for databases (their URL is what the apps reference, // and that name is generated, not captured). env: Record; + // Databases only: the backup schedules read off GET /databases/{uuid}/backups + // — the same supplementary per-database GET diff/apply have made on every run + // since #51, made here by the CLI's draft loop for every DRAFTED database. + // BackupRead's two absences stay distinct (see coolify.ts): `[]` is a clean + // "no schedule" (nothing to draft, nothing to report), `undefined` is "could + // 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; }; export type DraftProject = { @@ -578,17 +597,80 @@ function databaseSpec( `image "${image}" — no version could be read from its tag, so none was written and \`apply\` would create this database on Coolify's default image.`, ); } - // Coolify DOES expose a database's backup schedule — GET /databases/{uuid}/backups - // answers, and `diff`/`apply` read and write it (#51). What the DRAFT path cannot - // yet do is CAPTURE it: `inventory --emit-draft` does not read that route, so a - // `backup:` block is not recovered here. Until it is taught to, a rebuild from - // this draft still comes up with NO BACKUPS — the quietest possible loss, and the - // one you discover at the worst moment — unless the block is declared by hand. - flag( - "backup", - "backup schedules are NOT in this draft — `inventory --emit-draft` does not yet read `GET /databases/{uuid}/backups` (which `diff` and `apply` do, #51). If this database is backed up, a rebuild from here would not be until you declare it. Read the schedule from a `cast diff` or the Coolify UI (Backups tab) and set `backup: { frequency, retention }` yourself.", - ); - return { type, ...(version ? { version } : {}) }; + // The backup schedule IS captured (#75): the CLI's draft loop reads + // GET /databases/{uuid}/backups — the route diff/apply have read on every run + // since #51 — and the one shape the manifest can express (a single, enabled + // schedule) becomes a real `backup:` block. Everything the route genuinely + // cannot answer stays a per-resource UNCAPTURED entry: reported, never + // guessed, and never silently dropped. + const backup = draftBackup(r, flag); + return { + type, + ...(version ? { version } : {}), + ...(backup ? { backup } : {}), + }; +} + +// The drafted `backup:` block, or the reason there isn't one. The same four +// answers attachBackup (cli.ts) reads for a diff — but where diff refuses or +// skips a comparison, a draft REPORTS: its reader is a human adopting a box, +// not an `apply` about to write one, and a sweep that aborted on one database +// would trade a whole blueprint for one row. +// +// unreadable -> UNCAPTURED, no block. If this database is backed up, the +// draft cannot say so — and a rebuild from it would not be. +// no schedule -> nothing at all. Read cleanly, absence IS the answer, and a +// manifest with no `backup:` block expresses it exactly. +// one enabled -> a real block, `{ frequency, retention }` — the same +// projection the desired side builds (resolve.ts), so the +// drafted manifest diffs clean the moment it is applied. Plus +// an UNCAPTURED entry for the S3 TARGET when the schedule +// saves to S3: the route returns it only as `s3_storage_id`, +// an int no endpoint maps to a storage UUID (#72), so the +// block cannot carry WHICH bucket. +// one disabled -> UNCAPTURED, no block. The manifest cannot express a +// disabled schedule (`backup:` asks for backups, and every +// cast write asserts enabled: true), so emitting the block +// would make the first `apply` re-enable a schedule someone +// turned off on purpose. +// several -> UNCAPTURED, no block. A manifest declares ONE schedule; +// picking one to write down would be a coin toss dressed up +// as a blueprint. +function draftBackup( + r: DraftResource, + flag: (setting: string, detail: string) => void, +): { frequency: string; retention: number } | undefined { + const read = r.backups; + if (read === undefined) { + flag( + "backup", + "`GET /databases/{uuid}/backups` was unreachable or returned a shape cast does not recognize, so the schedule is NOT in this draft. If this database is backed up, a rebuild from here would not be until you declare it — read the schedule off a `cast diff` or the Coolify UI (Backups tab) and set `backup: { frequency, retention }` yourself.", + ); + return undefined; + } + if (read.length === 0) return undefined; + if (read.length > 1) { + flag( + "backup", + `Coolify holds ${read.length} backup schedules for this database and a manifest declares ONE, so none was drafted. Decide which schedule the manifest should carry and declare its \`backup: { frequency, retention }\` yourself.`, + ); + return undefined; + } + const schedule = read[0]; + if (!schedule.enabled) { + flag( + "backup", + `a backup schedule exists (frequency "${schedule.frequency}", retention ${schedule.retention}) but it is DISABLED — it backs nothing up, and the manifest cannot say "disabled": declaring \`backup:\` asks for backups, and the first \`apply\` would re-enable it. It is NOT in this draft; decide whether it was turned off on purpose before you declare it.`, + ); + return undefined; + } + if (schedule.saveS3) { + flag( + "backup S3 target", + "this schedule saves to S3, and the draft cannot say WHERE: Coolify returns the target only as `s3_storage_id`, an int no endpoint maps to a storage UUID (the destination_id problem again, #21/#72). The drafted `backup:` block carries frequency and retention; `apply` points the schedule at the environment's own `s3_destination` — verify that is the bucket you meant.", + ); + } + return { frequency: schedule.frequency, retention: schedule.retention }; } function serviceSpec( @@ -598,24 +680,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: { : [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: { : [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` } : {}), @@ -783,10 +876,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.", @@ -796,8 +885,8 @@ const NO_API_COVERAGE: Array<[string, string]> = [ "and its neighbours on a resource's Settings tab: no API coverage in 4.1.2, and not returned by the endpoints cast reads.", ], [ - "backup schedules", - "the API exposes them (`GET /databases/{uuid}/backups`, which `diff`/`apply` use — #51), but `inventory --emit-draft` does not yet read that route, so no `backup:` block is captured. A rebuild from this draft has NO backups until you declare them.", + "a backup schedule's S3 target", + "the schedule itself IS captured (`GET /databases/{uuid}/backups`, the route `diff`/`apply` have used since #51 — a single enabled schedule becomes a real `backup:` block above), but its target reads back only as `s3_storage_id`, an int no endpoint maps to a storage UUID. `apply` points every schedule it writes at the environment's own `s3_destination`; whether that is the bucket the source box used must be verified by hand.", ], [ "database kinds cast does not model", diff --git a/test/coolify.test.ts b/test/coolify.test.ts index 552459a..a6734ce 100644 --- a/test/coolify.test.ts +++ b/test/coolify.test.ts @@ -85,7 +85,13 @@ describe("CoolifyClient", () => { mockFetch({ "GET /api/v1/databases/db-1/backups": [row()] }), ); await expect(c.databaseBackupSchedules("db-1")).resolves.toEqual([ - { uuid: "sched-1", frequency: "0 3 * * *", retention: 7, enabled: true }, + { + uuid: "sched-1", + frequency: "0 3 * * *", + retention: 7, + enabled: true, + saveS3: true, + }, ]); }); // The dangerous 404. fetchLive reads a 404 as "no live environment" three @@ -106,7 +112,13 @@ describe("CoolifyClient", () => { describe("parseBackupSchedules", () => { it("reads a well-formed collection", () => { expect(parseBackupSchedules([row()])).toEqual([ - { uuid: "sched-1", frequency: "0 3 * * *", retention: 7, enabled: true }, + { + uuid: "sched-1", + frequency: "0 3 * * *", + retention: 7, + enabled: true, + saveS3: true, + }, ]); }); it("reads an empty list as a trustworthy 'no schedule', not as unknown", () => { @@ -160,6 +172,18 @@ describe("parseBackupSchedules", () => { parseBackupSchedules([row({ enabled: undefined })])?.[0].enabled, ).toBe(true); }); + it("reads save_s3 however Coolify spells it — and absent as false", () => { + // Same tinyint-with-no-cast story as `enabled`, opposite default: "this + // backup lands in S3" must never be claimed off a field the row lacks. + expect(parseBackupSchedules([row({ save_s3: 1 })])?.[0].saveS3).toBe(true); + expect(parseBackupSchedules([row({ save_s3: 0 })])?.[0].saveS3).toBe(false); + expect(parseBackupSchedules([row({ save_s3: false })])?.[0].saveS3).toBe( + false, + ); + expect( + parseBackupSchedules([row({ save_s3: undefined })])?.[0].saveS3, + ).toBe(false); + }); it("accepts an integer retention however it is serialized", () => { expect( parseBackupSchedules([ diff --git a/test/draft-cli.test.ts b/test/draft-cli.test.ts index 7a762ca..6906217 100644 --- a/test/draft-cli.test.ts +++ b/test/draft-cli.test.ts @@ -14,6 +14,7 @@ import { join } from "node:path"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { loadBindings } from "../src/bindings.js"; import { GENERATED_PLACEHOLDER } from "../src/capture.js"; +import { loadManifest } from "../src/manifest.js"; import { decryptSecrets } from "../src/secrets.js"; // `cast inventory --emit-draft` against a stub shaped like the box that made it @@ -157,6 +158,36 @@ async function stubCoolify(opts: { ambiguous?: boolean } = {}): Promise { ], }); + // The draft's supplementary per-database GET (#75) — the same route + // diff/apply read (#51). One enabled schedule, saving to S3: the block is + // draftable, the S3 TARGET (s3_storage_id, an unmappable int) is not. + if (path === "/databases/d1/backups") + return json([ + { + uuid: "sched-1", + enabled: true, + save_s3: true, + frequency: "0 3 * * *", + database_backup_retention_amount_locally: 7, + s3_storage_id: 2, + executions: [], + }, + ]); + + // 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); @@ -352,13 +383,20 @@ 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 expect(md).toContain("custom_labels"); // Basic Auth / Traefik labels - expect(md).toContain("backup"); // API exposes it, draft doesn't capture it yet (#51) + // The schedule itself is CAPTURED now (#75); what stays uncaptured is the + // S3 target, which reads back only as an unmappable s3_storage_id int. + expect(md).toContain("backup S3 target"); + expect(md).not.toContain("does not yet read"); // the stale pre-#51 claim expect(md).toContain("legacy.flag"); // not a name a template can hold // And the standing sections, emitted on every run whatever was found: @@ -372,6 +410,68 @@ describe("cast inventory --emit-draft (#27)", () => { expect(r.output).toContain("UNCAPTURED.md"); }); + it("reads the backup schedule and emits a real backup block (#75)", 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); + // The drafted manifest carries the live schedule, in the exact shape the + // desired side declares — so it diffs clean the moment it is applied. + const manifest = loadManifest( + join(f.out, "incubator", ".infra", "manifest.yaml"), + ); + expect( + manifest.environments.prod.databases?.["Incubator Database v2"]?.backup, + ).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([ diff --git a/test/draft.test.ts b/test/draft.test.ts index 628b04f..2e468f4 100644 --- a/test/draft.test.ts +++ b/test/draft.test.ts @@ -569,6 +569,174 @@ describe("planDraft — the emitted shape", () => { }); }); +describe("backup schedules — read and drafted, not hand-waved (#75)", () => { + type Backups = DraftProject["resources"][number]["backups"]; + const withDb = (backups: Backups): DraftProject => + project({ + resources: [ + ...project().resources, + { + kind: "database", + name: "Incubator Database v2", + uuid: "d1", + raw: { + database_type: "standalone-postgresql", + image: "postgres:16-alpine", + }, + env: {}, + backups, + }, + ], + }); + const schedule = (over: Partial[number]> = {}) => ({ + uuid: "sched-1", + frequency: "0 3 * * *", + retention: 7, + enabled: true, + saveS3: false, + ...over, + }); + const loadedDb = (plan: ReturnType) => { + 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.databases?.[ + "Incubator Database v2" + ]; + }; + const backupItems = (plan: ReturnType) => + plan.uncaptured.filter((u) => u.setting.startsWith("backup")); + + it("emits a real backup block for a single enabled schedule — and nothing uncaptured", () => { + const plan = planDraft([withDb([schedule()])], ctx); + expect(loadedDb(plan)?.backup).toEqual({ + frequency: "0 3 * * *", + retention: 7, + }); + expect(backupItems(plan)).toEqual([]); + // The stale pre-#51 claim is gone from every artifact. + expect(JSON.stringify(plan.files)).not.toContain("does not yet read"); + }); + + it("reports the S3 target it cannot map when the schedule saves to S3", () => { + const plan = planDraft([withDb([schedule({ saveS3: true })])], ctx); + // The block is still drafted — frequency/retention ARE readable. + expect(loadedDb(plan)?.backup).toEqual({ + frequency: "0 3 * * *", + retention: 7, + }); + const items = backupItems(plan); + expect(items).toHaveLength(1); + expect(items[0].setting).toBe("backup S3 target"); + expect(items[0].detail).toContain("s3_storage_id"); + }); + + it("emits nothing for a clean 'no schedule' read — absence IS the answer", () => { + const plan = planDraft([withDb([])], ctx); + expect(loadedDb(plan)?.backup).toBeUndefined(); + expect(backupItems(plan)).toEqual([]); + }); + + it("does NOT draft a disabled schedule — apply would re-enable it", () => { + const plan = planDraft( + [withDb([schedule({ enabled: false, saveS3: true })])], + ctx, + ); + expect(loadedDb(plan)?.backup).toBeUndefined(); + const items = backupItems(plan); + expect(items).toHaveLength(1); + expect(items[0].detail).toContain("DISABLED"); + expect(items[0].detail).toContain('"0 3 * * *"'); + }); + + it("will not pick between several schedules", () => { + const plan = planDraft( + [withDb([schedule(), schedule({ uuid: "sched-2", frequency: "daily" })])], + ctx, + ); + expect(loadedDb(plan)?.backup).toBeUndefined(); + const items = backupItems(plan); + expect(items).toHaveLength(1); + expect(items[0].detail).toContain("2 backup schedules"); + }); + + it("reports an unreadable route rather than aborting or claiming 'no backups'", () => { + const plan = planDraft([withDb(undefined)], ctx); + expect(loadedDb(plan)?.backup).toBeUndefined(); + const items = backupItems(plan); + expect(items).toHaveLength(1); + expect(items[0].detail).toContain("unreachable"); + expect(items[0].resource).toBe("Incubator Database v2"); + }); +}); + +describe("service hostnames — read and drafted via the per-service GET (#83)", () => { + const withService = ( + serviceDomains?: Record, + ): DraftProject => + project({ + resources: [ + ...project().resources, + { + kind: "service", + name: "Incubator Umami", + uuid: "s1", + raw: { service_type: "umami" }, + env: {}, + serviceDomains, + }, + ], + }); + const loadedSvc = (plan: ReturnType) => { + 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) => + 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-")); diff --git a/test/live-lookup.test.ts b/test/live-lookup.test.ts index 33c3311..4ef741f 100644 --- a/test/live-lookup.test.ts +++ b/test/live-lookup.test.ts @@ -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"], + }); + }); +});