fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
|
|
|
import { describe, expect, it, vi } from "vitest";
|
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
|
|
|
import {
|
|
|
|
|
attachBackup,
|
|
|
|
|
attachServiceDomains,
|
|
|
|
|
fetchLive,
|
|
|
|
|
renderAbsentTarget,
|
|
|
|
|
} from "../src/cli.js";
|
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
|
|
|
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: [] });
|
|
|
|
|
});
|
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60)
Add a ${resource:<name>.url} env-template ref that resolves to the internal URL
of a database the same manifest declares, read back from the live resource's
internal_db_url — never stored in the age store, never decrypted, never printed.
This deletes the two-pass generated-secret bootstrap for a database's own URL
rather than automating it: no placeholder, no stored copy to drift or overwrite,
and a rotated password is simply followed on the next apply.
Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff
time against databases already on the box (so a matching app shows no drift —
killing the "secret DATABASE_URL differs" noise that ran on every plan), and in
the executor at apply time against a database created earlier in the same run
(the from-nothing case; apply acts databases-before-applications, #45). The
unresolved sentinel is never written — the executor refuses, rather than write a
blank that boots the app pointed at nothing, and re-running once the database is
up resolves it as an ordinary update.
A ${resource:X.url} naming a database the manifest does not declare, or an
attribute other than .url, is a hard plan-time error refused by every verb that
opens a template (apply, diff, capture). generated_secrets and the two-pass
bootstrap remain for the residual class — a provider-generated value that
genuinely is not derivable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
|
|
|
|
|
|
|
|
// #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();
|
|
|
|
|
});
|
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: diff and apply a database's backup schedule (#51)
Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
});
|