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

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

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

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

`npm run check` clean · 514 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00

348 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
attachBackup,
attachServiceDomains,
fetchLive,
renderAbsentTarget,
} from "../src/cli.js";
import { CoolifyClient } from "../src/coolify.js";
// A Coolify that answers GET /projects with `projects`, and
// GET /projects/{uuid}/{env} with whatever `envByProject` holds for it
// (undefined → 404, which is how Coolify says "no such environment").
function coolify(
projects: Array<{ uuid: string; name: string }>,
envByProject: Record<string, unknown> = {},
): CoolifyClient {
const fetchImpl = vi.fn(async (url: string | URL) => {
const path = new URL(String(url)).pathname.replace("/api/v1", "");
if (path === "/projects") {
return new Response(JSON.stringify(projects), { status: 200 });
}
const m = path.match(/^\/projects\/([^/]+)\/(.+)$/);
if (m) {
const body = envByProject[`${m[1]}/${m[2]}`];
return body === undefined
? new Response(JSON.stringify({ message: "Not found." }), {
status: 404,
})
: new Response(JSON.stringify(body), { status: 200 });
}
return new Response("{}", { status: 500 });
}) as unknown as typeof fetch;
return new CoolifyClient("https://coolify.test", "tok", fetchImpl);
}
describe("fetchLive", () => {
it("returns the live resources when project and environment both exist", async () => {
const client = coolify([{ uuid: "p1", name: "incubator" }], {
"p1/prod": {
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "db", uuid: "d1" }],
},
});
const r = await fetchLive(client, "incubator", "prod");
expect(r.found).toBe(true);
if (!r.found) throw new Error("unreachable");
expect(r.live.map((l) => l.name).sort()).toEqual(["core", "db"]);
});
// The bug this whole change exists for: an absent project used to come back
// as [], which computeDiff reads as "every desired resource is missing" and
// renders as a confident full-create plan. Absence must be its own answer,
// distinguishable from an empty-but-real environment.
it("reports an ABSENT project as absent, not as empty", async () => {
const client = coolify([
{ uuid: "p1", name: "incubator-prod" },
{ uuid: "p2", name: "umami" },
]);
const r = await fetchLive(client, "incubator", "prod");
expect(r).toEqual({
found: false,
missing: "project",
project: "incubator",
available: ["incubator-prod", "umami"],
});
});
// Same lie, a different road: the project is real but the environment name
// is not. A hand-built project is very often `production`, not `prod`.
it("reports an ABSENT environment as absent, not as empty", async () => {
const client = coolify([{ uuid: "p1", name: "incubator" }], {
"p1/production": { applications: [] },
});
const r = await fetchLive(client, "incubator", "prod");
expect(r).toEqual({
found: false,
missing: "environment",
project: "incubator",
environment: "prod",
});
});
// The distinction has to be real in BOTH directions, or the gate would just
// trade a false pass for a false alarm: a project whose environment exists
// and is genuinely empty is `found`, with zero resources.
it("distinguishes a real-but-empty environment from an absent one", async () => {
const client = coolify([{ uuid: "p1", name: "incubator" }], {
"p1/prod": { applications: [], postgresqls: [], services: [] },
});
const r = await fetchLive(client, "incubator", "prod");
expect(r).toEqual({ found: true, live: [] });
});
// #60: a database carries its internal_db_url onto Live (what an app's
// ${resource:<name>.url} derives from); an application does not. This is the
// plumbing runProject reads to build its URL map, so it is worth pinning.
it("plumbs a database's internal_db_url onto Live, and only for databases", async () => {
const client = coolify([{ uuid: "p1", name: "incubator" }], {
"p1/prod": {
applications: [
{ name: "core", uuid: "a1", internal_db_url: "nonsense" },
],
postgresqls: [
{
name: "db",
uuid: "d1",
internal_db_url: "postgres://u:p@d1:5432/app",
},
],
redis: [{ name: "cache", uuid: "r1" }],
},
});
const r = await fetchLive(client, "incubator", "prod");
if (!r.found) throw new Error("unreachable");
const byName = Object.fromEntries(r.live.map((l) => [l.name, l]));
expect(byName.db.internalDbUrl).toBe("postgres://u:p@d1:5432/app");
// An application never carries it — even if the raw record has the key.
expect(byName.core.internalDbUrl).toBeUndefined();
// A database whose read carried no URL simply has none (not "").
expect(byName.cache.internalDbUrl).toBeUndefined();
});
});
describe("renderAbsentTarget", () => {
it("names what it looked for, where the name came from, and what exists", () => {
const msg = renderAbsentTarget(
{
found: false,
missing: "project",
project: "incubator",
available: ["incubator-prod", "umami"],
},
{ orgRepo: "heavy-duty/incubator", overridden: false },
);
expect(msg).toMatch(/no project named "incubator"/);
expect(msg).toMatch(/derived from the repo slug heavy-duty\/incubator/);
expect(msg).toMatch(/incubator-prod, umami/);
expect(msg).toMatch(/--project <name>/);
// The reader must not be able to walk away thinking a clean diff was a pass.
expect(msg).toMatch(/verified\s+nothing/);
});
it("says the name came from --project when it was overridden", () => {
const msg = renderAbsentTarget(
{
found: false,
missing: "project",
project: "typo",
available: ["incubator"],
},
{ orgRepo: "heavy-duty/incubator", overridden: true },
);
expect(msg).toMatch(/\(--project\)/);
});
it("points at the UI-naming gotcha when the environment is what is missing", () => {
const msg = renderAbsentTarget(
{
found: false,
missing: "environment",
project: "incubator",
environment: "prod",
},
{ orgRepo: "heavy-duty/incubator", overridden: false },
);
expect(msg).toMatch(/has no environment "prod"/);
expect(msg).toMatch(/production/);
expect(msg).toMatch(/--env/);
});
});
// The read half of #51: a database's backup schedule is on its own route, so
// the live side has to go and get it. These pin the mapping from what that
// route answers onto what the diff is allowed to conclude.
describe("attachBackup", () => {
const db = () => ({
kind: "database" as const,
name: "postgres",
uuid: "db-1",
fields: { type: "postgresql" },
});
// A Coolify whose GET /databases/db-1/backups answers with `body` (or a
// status, to exercise the unreachable path).
const client = (body: unknown, status = 200) =>
new CoolifyClient(
"https://coolify.test",
"tok",
vi.fn(
async () =>
new Response(status === 200 ? JSON.stringify(body) : "boom", {
status,
}),
) as unknown as typeof fetch,
);
it("reads a schedule onto the live fields, in the desired key order", async () => {
const l = db();
await attachBackup(
client([
{
uuid: "s1",
frequency: "0 3 * * *",
database_backup_retention_amount_locally: 7,
enabled: true,
},
]),
l,
);
// Key order matters: computeDiff compares by JSON.stringify, against the
// object resolve.ts builds. Same keys, same order, or every run drifts.
expect(JSON.stringify(l.fields.backup)).toBe(
JSON.stringify({ frequency: "0 3 * * *", retention: 7 }),
);
expect(l.backupNotCompared).toBeUndefined();
});
it("leaves fields.backup absent when the database genuinely has none", async () => {
const l = db();
await attachBackup(client([]), l);
// Absence IS the answer here, and a trustworthy one: a declared backup is
// then real drift, and apply creates the schedule.
expect("backup" in l.fields).toBe(false);
expect(l.backupNotCompared).toBeUndefined();
});
it("carries a disabled schedule through as disabled", async () => {
const l = db();
await attachBackup(
client([
{
uuid: "s1",
frequency: "0 3 * * *",
database_backup_retention_amount_locally: 7,
enabled: false,
},
]),
l,
);
// The row exists (so apply PATCHes rather than POSTing a second one) but it
// backs nothing up (so it must not compare equal to a declared block).
expect(l.fields.backup).toEqual({
frequency: "0 3 * * *",
retention: 7,
enabled: false,
});
});
it("marks the read not-compared when Coolify cannot be read", async () => {
const l = db();
await attachBackup(client(null, 500), l);
expect("backup" in l.fields).toBe(false);
expect(l.backupNotCompared).toMatch(/unreachable|does not recognize/);
});
it("marks the read not-compared when a database carries several schedules", async () => {
const l = db();
await attachBackup(
client([
{
uuid: "s1",
frequency: "0 3 * * *",
database_backup_retention_amount_locally: 7,
},
{
uuid: "s2",
frequency: "0 9 * * *",
database_backup_retention_amount_locally: 2,
},
]),
l,
);
// A manifest declares one schedule. Picking one of two to compare against
// would be a coin toss reported as a fact.
expect(l.backupNotCompared).toMatch(/2 schedules/);
expect("backup" in l.fields).toBe(false);
});
});
describe("attachServiceDomains (cast#72)", () => {
const svc = () => ({
kind: "service" as const,
name: "umami",
uuid: "svc-1",
fields: { type: "umami" },
});
// A Coolify whose GET /services/svc-1 answers with `body`.
const client = (body: unknown, status = 200) =>
new CoolifyClient(
"https://coolify.test",
"tok",
vi.fn(
async () =>
new Response(status === 200 ? JSON.stringify(body) : "boom", {
status,
}),
) as unknown as typeof fetch,
);
it("projects applications[].fqdn into canonicalized service_domains", async () => {
const l = svc();
await attachServiceDomains(
client({
applications: [
// Out of order, comma-joined, one container with two URLs — all
// canonicalized so the read matches the desired side regardless.
{ name: "web", fqdn: "https://b.example.com,https://a.example.com" },
{ name: "collector", fqdn: "https://collect.example.com" },
],
}),
l,
);
expect(l.fields.service_domains).toEqual({
collector: ["https://collect.example.com"],
web: ["https://a.example.com", "https://b.example.com"],
});
});
it("leaves service_domains absent when no container has a hostname", async () => {
const l = svc();
await attachServiceDomains(
client({
applications: [
{ name: "web", fqdn: "" },
{ name: "collector", fqdn: null },
],
}),
l,
);
// A service with no hostnames stays clean against a manifest that declares
// none, and drifts against one that declares some.
expect("service_domains" in l.fields).toBe(false);
});
it("FAILS CLOSED — throws rather than projecting empty — when the read is unreachable", async () => {
const l = svc();
// A blind empty projection would diff a declared hostname as "will set" and
// re-PATCH it forever; refusing is the safe answer (#12/#14/#17).
await expect(attachServiceDomains(client(null, 500), l)).rejects.toThrow();
expect("service_domains" in l.fields).toBe(false);
});
it("FAILS CLOSED when the answer has no applications array", async () => {
const l = svc();
await expect(
attachServiceDomains(client({ uuid: "svc-1" }), l),
).rejects.toThrow(/applications array/);
});
});