Merge pull request #95 from dan-claude-bot/feat/draft-service-domains
feat(draft): capture service hostnames via per-service GET (#83)
This commit is contained in:
commit
bf24c2986d
8 changed files with 558 additions and 67 deletions
|
|
@ -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
|
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
|
to resolve it to the UUID `destination_uuid:` wants, #21), a service whose
|
||||||
(settable/readable via the API and carried by `diff`/`apply` as `service_domains`
|
hostnames could not be read (they **are captured** since #83 — the draft makes
|
||||||
since #72, but the same as backups below — `inventory --emit-draft` does not yet
|
the per-service `GET /services/{uuid}` that `diff`/`apply` have made since
|
||||||
make the per-service `GET /services/{uuid}`, so a drafted service has none until
|
#72/#81 and emits `service_domains` from `applications[].fqdn`, through the same
|
||||||
you declare them), Basic Auth / custom Traefik labels,
|
projection the diff's read-back uses, so a drafted service diffs clean once
|
||||||
build and deploy command overrides, backup schedules (**a rebuild has no backups
|
applied; a service whose GET is unreachable or unrecognized is reported per
|
||||||
until you declare them** — *not* because they cannot be read, which is what this
|
resource instead — where a one-project diff fails closed, a whole-instance sweep
|
||||||
line used to say and #51 disproved, but because `inventory --emit-draft` has not
|
reports and keeps going), Basic Auth / custom Traefik labels,
|
||||||
yet been taught to read them: `GET /databases/{uuid}/backups` answers, and `diff`
|
build and deploy command overrides, what a backup schedule cannot fully say
|
||||||
and `apply` now use it. Until the draft path does too, a blueprint still omits
|
(the schedule itself **is captured** since #75 — the draft reads
|
||||||
them and still says so), database kinds cast
|
`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,
|
does not model (MySQL, MariaDB, MongoDB, KeyDB, Dragonfly, ClickHouse — named,
|
||||||
never silently dropped), env var names a cast template cannot express, names
|
never silently dropped), env var names a cast template cannot express, names
|
||||||
**reserved by the platform** (`SOURCE_COMMIT`, `COOLIFY_*` — suppressed, never
|
**reserved by the platform** (`SOURCE_COMMIT`, `COOLIFY_*` — suppressed, never
|
||||||
|
|
|
||||||
84
src/cli.ts
84
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
|
// 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
|
// 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
|
// 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
|
// 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
|
// (#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.
|
// 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(
|
export async function attachServiceDomains(
|
||||||
client: CoolifyClient,
|
client: CoolifyClient,
|
||||||
svc: Live,
|
svc: Live,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const raw = (await client.serviceByUuid(svc.uuid)) as {
|
const map = projectServiceDomains(await client.serviceByUuid(svc.uuid));
|
||||||
applications?: Array<{ name?: unknown; fqdn?: unknown }>;
|
if (map === undefined) {
|
||||||
} | null;
|
|
||||||
if (!raw || !Array.isArray(raw.applications)) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`GET /services/${svc.uuid} returned no applications array — cannot read service ${svc.name}'s hostnames to diff them`,
|
`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) {
|
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<number> {
|
||||||
for (const r of resources) {
|
for (const r of resources) {
|
||||||
// Databases hold no manifest-templated env of their own — their URL is
|
// Databases hold no manifest-templated env of their own — their URL is
|
||||||
// what the APPS reference, and that name is generated, not captured.
|
// 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(
|
r.env = flattenEnv(
|
||||||
await fetchEnv(client, { kind: r.kind, uuid: r.uuid }),
|
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({
|
draftProjects.push({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ export type LiveBackup = {
|
||||||
frequency: string;
|
frequency: string;
|
||||||
retention: number;
|
retention: number;
|
||||||
enabled: boolean;
|
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
|
// The result of trying to read a database's schedules. The two absences are
|
||||||
|
|
@ -56,6 +63,15 @@ function readEnabled(raw: Record<string, unknown>): boolean {
|
||||||
return !(v === false || v === 0 || v === "0");
|
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<string, unknown>): 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
|
// 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
|
// 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
|
// 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,
|
frequency,
|
||||||
retention,
|
retention,
|
||||||
enabled: readEnabled(row),
|
enabled: readEnabled(row),
|
||||||
|
saveS3: readSaveS3(row),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return schedules;
|
return schedules;
|
||||||
|
|
|
||||||
159
src/draft.ts
159
src/draft.ts
|
|
@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
import { GENERATED_PLACEHOLDER } from "./capture.js";
|
import { GENERATED_PLACEHOLDER } from "./capture.js";
|
||||||
|
import type { BackupRead } from "./coolify.js";
|
||||||
import {
|
import {
|
||||||
isProviderGeneratedEnvName,
|
isProviderGeneratedEnvName,
|
||||||
isReservedEnvName,
|
isReservedEnvName,
|
||||||
|
|
@ -42,8 +43,8 @@ import { encryptSecrets } from "./secrets.js";
|
||||||
// wrong in four entries out of seventeen is worse than one that is obviously
|
// wrong in four entries out of seventeen is worse than one that is obviously
|
||||||
// incomplete.
|
// incomplete.
|
||||||
//
|
//
|
||||||
// 2. SILENT LOSSES. cast cannot express everything a Coolify holds — service
|
// 2. SILENT LOSSES. cast cannot express everything a Coolify holds —
|
||||||
// hostnames, destinations (#21), Basic Auth, build toggles, whole database
|
// destinations (#21), Basic Auth, build toggles, whole database
|
||||||
// kinds. A blueprint that omits them WITHOUT SAYING SO is worse than no
|
// 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
|
// blueprint, because in a disaster you would trust it and rebuild a
|
||||||
// *different box*. Hence UNCAPTURED.md, which is emitted on every run, even
|
// *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,
|
// Live env vars. Empty for databases (their URL is what the apps reference,
|
||||||
// and that name is generated, not captured).
|
// and that name is generated, not captured).
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
|
// 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<string, string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DraftProject = {
|
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.`,
|
`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
|
// The backup schedule IS captured (#75): the CLI's draft loop reads
|
||||||
// answers, and `diff`/`apply` read and write it (#51). What the DRAFT path cannot
|
// GET /databases/{uuid}/backups — the route diff/apply have read on every run
|
||||||
// yet do is CAPTURE it: `inventory --emit-draft` does not read that route, so a
|
// since #51 — and the one shape the manifest can express (a single, enabled
|
||||||
// `backup:` block is not recovered here. Until it is taught to, a rebuild from
|
// schedule) becomes a real `backup:` block. Everything the route genuinely
|
||||||
// this draft still comes up with NO BACKUPS — the quietest possible loss, and the
|
// cannot answer stays a per-resource UNCAPTURED entry: reported, never
|
||||||
// one you discover at the worst moment — unless the block is declared by hand.
|
// guessed, and never silently dropped.
|
||||||
flag(
|
const backup = draftBackup(r, flag);
|
||||||
"backup",
|
return {
|
||||||
"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.",
|
type,
|
||||||
);
|
...(version ? { version } : {}),
|
||||||
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(
|
function serviceSpec(
|
||||||
|
|
@ -598,24 +680,35 @@ function serviceSpec(
|
||||||
hasEnv: boolean,
|
hasEnv: boolean,
|
||||||
uncaptured: UncapturedItem[],
|
uncaptured: UncapturedItem[],
|
||||||
): Spec {
|
): Spec {
|
||||||
// A service's per-container hostnames (`service.applications[].fqdn`) ARE
|
// A service's per-container hostnames ARE captured (#83): the CLI's draft
|
||||||
// settable and readable via the API — `urls` on create/PATCH, and
|
// loop makes the per-service GET diff/apply have made since #72/#81, and
|
||||||
// GET /services/{uuid} on read — so `diff`/`apply` now carry them as
|
// hands the map in already projected through THE projection the diff's
|
||||||
// `service_domains` (cast#72). What the DRAFT path cannot yet do is CAPTURE
|
// read-back uses (projectServiceDomains + canonicalizeServiceDomains in
|
||||||
// them: the inventory sweep reads the environment list, which does not
|
// cli.ts) — so what is drafted here diffs clean the moment it is applied.
|
||||||
// eager-load `service.applications`, and does not make the supplementary
|
//
|
||||||
// per-service GET. So a service that serves a hostname today comes back with
|
// Only the read that FAILED stays a report. attachServiceDomains fails a
|
||||||
// none in this draft — until it is declared by hand. Same shape as the backup
|
// one-project diff closed on the same answer, because its output feeds an
|
||||||
// schedule (#51): the API answers, the draft path has not been taught to ask.
|
// apply; a draft's reader is a human adopting a box, and trading a
|
||||||
uncaptured.push({
|
// whole-instance blueprint for one unreadable service would be the worse
|
||||||
project,
|
// artifact — so the loss is named, per resource, and the sweep keeps going.
|
||||||
resource: r.name,
|
const domains = r.serviceDomains;
|
||||||
setting: "service_domains (hostnames)",
|
if (domains === undefined) {
|
||||||
detail:
|
uncaptured.push({
|
||||||
"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.",
|
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 {
|
return {
|
||||||
type: String(r.raw.service_type ?? r.raw.type ?? ""),
|
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
|
...(hasEnv
|
||||||
? { env_template: `env/${slug(r.name)}.${ctx.env}.env.template` }
|
? { env_template: `env/${slug(r.name)}.${ctx.env}.env.template` }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
@ -783,10 +876,6 @@ const NO_API_COVERAGE: Array<[string, string]> = [
|
||||||
"destinations",
|
"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).",
|
"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",
|
"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.",
|
"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.",
|
"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",
|
"a backup schedule's S3 target",
|
||||||
"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.",
|
"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",
|
"database kinds cast does not model",
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,13 @@ describe("CoolifyClient", () => {
|
||||||
mockFetch({ "GET /api/v1/databases/db-1/backups": [row()] }),
|
mockFetch({ "GET /api/v1/databases/db-1/backups": [row()] }),
|
||||||
);
|
);
|
||||||
await expect(c.databaseBackupSchedules("db-1")).resolves.toEqual([
|
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
|
// The dangerous 404. fetchLive reads a 404 as "no live environment" three
|
||||||
|
|
@ -106,7 +112,13 @@ describe("CoolifyClient", () => {
|
||||||
describe("parseBackupSchedules", () => {
|
describe("parseBackupSchedules", () => {
|
||||||
it("reads a well-formed collection", () => {
|
it("reads a well-formed collection", () => {
|
||||||
expect(parseBackupSchedules([row()])).toEqual([
|
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", () => {
|
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,
|
parseBackupSchedules([row({ enabled: undefined })])?.[0].enabled,
|
||||||
).toBe(true);
|
).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", () => {
|
it("accepts an integer retention however it is serialized", () => {
|
||||||
expect(
|
expect(
|
||||||
parseBackupSchedules([
|
parseBackupSchedules([
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { join } from "node:path";
|
||||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { loadBindings } from "../src/bindings.js";
|
import { loadBindings } from "../src/bindings.js";
|
||||||
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
|
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
|
||||||
|
import { loadManifest } from "../src/manifest.js";
|
||||||
import { decryptSecrets } from "../src/secrets.js";
|
import { decryptSecrets } from "../src/secrets.js";
|
||||||
|
|
||||||
// `cast inventory --emit-draft` against a stub shaped like the box that made it
|
// `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<Stub> {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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$/);
|
const envs = path.match(/^\/[a-z]+\/([a-z0-9]+)\/envs$/);
|
||||||
if (envs) return json(ENVS[envs[1]] ?? []);
|
if (envs) return json(ENVS[envs[1]] ?? []);
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
|
|
@ -352,13 +383,20 @@ describe("cast inventory --emit-draft (#27)", () => {
|
||||||
const md = readFileSync(join(f.out, "UNCAPTURED.md"), "utf8");
|
const md = readFileSync(join(f.out, "UNCAPTURED.md"), "utf8");
|
||||||
|
|
||||||
// Seen on the box, and inexpressible:
|
// Seen on the box, and inexpressible:
|
||||||
expect(md).toContain("Incubator Umami"); // service hostnames (#21 / 4.1.2)
|
// The umami's hostnames are CAPTURED now (#83) — it has nothing left to
|
||||||
expect(md).toContain("domains (hostnames)");
|
// 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"); // which Docker network (#21)
|
||||||
expect(md).toContain("destination_id 3");
|
expect(md).toContain("destination_id 3");
|
||||||
expect(md).toContain("legacy-analytics"); // a MySQL cast cannot model
|
expect(md).toContain("legacy-analytics"); // a MySQL cast cannot model
|
||||||
expect(md).toContain("custom_labels"); // Basic Auth / Traefik labels
|
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
|
expect(md).toContain("legacy.flag"); // not a name a template can hold
|
||||||
|
|
||||||
// And the standing sections, emitted on every run whatever was found:
|
// 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");
|
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 () => {
|
it("writes the projects: registry — the list of what exists", async () => {
|
||||||
const f = fixture((await stubCoolify()).url);
|
const f = fixture((await stubCoolify()).url);
|
||||||
await run([
|
await run([
|
||||||
|
|
|
||||||
|
|
@ -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<NonNullable<Backups>[number]> = {}) => ({
|
||||||
|
uuid: "sched-1",
|
||||||
|
frequency: "0 3 * * *",
|
||||||
|
retention: 7,
|
||||||
|
enabled: true,
|
||||||
|
saveS3: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const loadedDb = (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.databases?.[
|
||||||
|
"Incubator Database v2"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
const backupItems = (plan: ReturnType<typeof planDraft>) =>
|
||||||
|
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<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", () => {
|
describe("the emit refusals — adoption is one-way", () => {
|
||||||
it("refuses a target directory that is not empty", () => {
|
it("refuses a target directory that is not empty", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
attachServiceDomains,
|
attachServiceDomains,
|
||||||
fetchEnv,
|
fetchEnv,
|
||||||
fetchLive,
|
fetchLive,
|
||||||
|
projectServiceDomains,
|
||||||
renderAbsentTarget,
|
renderAbsentTarget,
|
||||||
} from "../src/cli.js";
|
} from "../src/cli.js";
|
||||||
import { CoolifyClient } from "../src/coolify.js";
|
import { CoolifyClient } from "../src/coolify.js";
|
||||||
|
|
@ -421,3 +422,37 @@ describe("attachServiceDomains (cast#72)", () => {
|
||||||
).rejects.toThrow(/applications array/);
|
).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