import type { ResolvedEnv } from "./envtemplate.js"; import { isReservedEnvName, reservedConsequence } from "./reserved.js"; export type ResourceKind = "application" | "database" | "service"; export type Desired = { kind: ResourceKind; name: string; fields: Record; env?: ResolvedEnv; }; export type Live = { kind: ResourceKind; name: string; uuid: string; fields: Record; env?: Record; // The destination (Docker network) Coolify reports this resource on. // // NOT in `fields`, because `fields` is the desired-vs-live comparison // vocabulary and this can never take part in it: Coolify 4.1.2 accepts // `destination_uuid` on write and returns `destination_id` (an integer // primary key) on read, and exposes no endpoint that maps one to the other. // Putting it in `fields` would diff a UUID against an int and report drift // that can never be resolved. See Placement. destinationId?: number; }; export type FieldDiff = { field: string; desired: unknown; live?: unknown; updatable: boolean; }; export type EnvDiff = { key: string; state: "add" | "change" | "remove-candidate"; secret: boolean; }; export type Change = { kind: ResourceKind; name: string; uuid?: string; op: "create" | "update"; fieldDiffs: FieldDiff[]; envDiffs: EnvDiff[]; }; // Where this project's resources actually sit, as far as Coolify will say. // // The destination cannot be diffed the way every other field is (see Live), so // the alternative was to leave it out of the report entirely — and a setting // that reads back as ABSENT rather than WRONG is the exact failure shape cast // keeps legislating against (#12, #14, #17, #18). So it is reported instead of // compared, and reported with the limit stated: // // - `declared` is what the state file asks for. cast sends it on create and // CANNOT check it afterwards. Never silently — renderDiff says so. // - `groups` is what Coolify answers, by `destination_id`. It is an opaque // int, but it is comparable to ITSELF, and that is enough to catch the // thing actually worth catching: a project whose resources do not all share // one network is a project whose isolation is broken, whatever the numbers // happen to be. export type Placement = { declared?: string; groups: { destinationId: number; resources: string[] }[]; split: boolean; }; // A reserved name (SOURCE_COMMIT, COOLIFY_*) found on a LIVE resource. // // NOT an orphan var, and the distinction is the whole point of this type. An // orphan var is a live-only var the manifest does not declare, and its // documented disposition is "apply never removes these; read them by eye" — // cosmetic residue, filed under a heading that invites being read past. A // reserved name is not residue: it is an ACTIVE SUPPRESSION of a value Coolify // would otherwise inject (see reserved.ts), it is the difference between // /version reporting a commit and reporting "unknown", and it is never // cosmetic. So it comes out of that list and is reported as a finding, with the // consequence attached. // // `apply never deletes` still holds, unchanged: cast reports it, a human deletes // it in the Coolify UI. export type ReservedVar = { kind: ResourceKind; name: string; key: string }; export type DiffReport = { mode: "structural" | "full"; changes: Change[]; orphans: { kind: ResourceKind; name: string; uuid: string }[]; // Findings, not drift-to-repair. Never empty in structural mode by accident: // structural mode reads no env vars at all, so it can find none — and says so. reserved: ReservedVar[]; placement: Placement; clean: boolean; }; export const NON_UPDATABLE: Record = { application: ["build_pack"], database: ["type", "version"], service: ["type"], }; function eq(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } function diffEnv( desired: ResolvedEnv, live: Record, ): EnvDiff[] { const diffs: EnvDiff[] = []; for (const [key, v] of Object.entries(desired.vars)) { if (!(key in live)) diffs.push({ key, state: "add", secret: v.secret }); else if (live[key] !== v.value) diffs.push({ key, state: "change", secret: v.secret }); } for (const key of Object.keys(live)) { // A reserved name is deliberately NOT a remove-candidate: it is collected // separately, as a finding (see ReservedVar). Leaving it here as well would // report the same var twice under two headings, one of which says it is // harmless. It also cannot be an `add`/`change`: the manifest side can never // declare one — resolve.ts refuses the run first. if (!(key in desired.vars) && !isReservedEnvName(key)) diffs.push({ key, state: "remove-candidate", secret: false }); } return diffs; } // Read off the LIVE side, and off every live resource — not only the ones the // manifest declares. A reserved name suppresses Coolify's injection on the box // whether or not cast has ever heard of the resource carrying it, so scanning // `changes` (which exists only for declared resources) would miss it on exactly // the resource nobody is watching. Empty in structural mode, where no env var // was read at all. function reservedVars(live: Live[]): ReservedVar[] { const found: ReservedVar[] = []; for (const l of live) { for (const key of Object.keys(l.env ?? {})) { if (isReservedEnvName(key)) found.push({ kind: l.kind, name: l.name, key }); } } return found; } function computePlacement(live: Live[], declared?: string): Placement { const byDestination = new Map(); for (const l of live) { // Coolify returns destination_id on applications, databases and services // alike (none of the three controllers' removeSensitiveData hides it, // v4.1.2). A resource that reports none is not evidence of a split — it is // no evidence at all, so it is left out rather than grouped under a // fabricated id. if (typeof l.destinationId !== "number") continue; const at = byDestination.get(l.destinationId) ?? []; at.push(`${l.kind} ${l.name}`); byDestination.set(l.destinationId, at); } const groups = [...byDestination.entries()] .map(([destinationId, resources]) => ({ destinationId, resources: resources.sort(), })) .sort((a, b) => a.destinationId - b.destinationId); return { declared, groups, split: groups.length > 1 }; } export function computeDiff( desired: Desired[], live: Live[], mode: "structural" | "full", opts: { declaredDestination?: string } = {}, ): DiffReport { const changes: Change[] = []; for (const d of desired) { const l = live.find((x) => x.kind === d.kind && x.name === d.name); if (!l) { changes.push({ kind: d.kind, name: d.name, op: "create", fieldDiffs: Object.entries(d.fields).map(([field, value]) => ({ field, desired: value, updatable: !NON_UPDATABLE[d.kind].includes(field), })), envDiffs: mode === "full" && d.env ? Object.entries(d.env.vars).map(([key, v]) => ({ key, state: "add" as const, secret: v.secret, })) : [], }); continue; } const fieldDiffs: FieldDiff[] = Object.entries(d.fields) .filter(([field, value]) => !eq(value, l.fields[field])) .map(([field, value]) => ({ field, desired: value, live: l.fields[field], updatable: !NON_UPDATABLE[d.kind].includes(field), })); const envDiffs = mode === "full" && d.env ? diffEnv(d.env, l.env ?? {}) : []; if (fieldDiffs.length > 0 || envDiffs.length > 0) { changes.push({ kind: d.kind, name: d.name, uuid: l.uuid, op: "update", fieldDiffs, envDiffs, }); } } const orphans = live .filter((l) => !desired.some((d) => d.kind === l.kind && d.name === l.name)) .map((l) => ({ kind: l.kind, name: l.name, uuid: l.uuid })); const placement = computePlacement(live, opts.declaredDestination); const reserved = reservedVars(live); return { mode, changes, orphans, reserved, placement, // A split project is drift, and drift is not clean — the same disposition // as an orphan: reported, counted, and NOT repaired (apply moves nothing // between networks; see renderDiff). // // A reserved name is not clean either, and for a stronger reason than drift: // it is a live defect. The box it sits on is deploying green and reporting // the wrong commit, and a `diff` that answered "clean" over it would be the // last chance anyone had to notice. clean: changes.length === 0 && orphans.length === 0 && reserved.length === 0 && !placement.split, }; } export function renderDiff(report: DiffReport): string { const lines: string[] = []; if (report.mode === "structural") { lines.push( "env vars not compared (structural mode — full diff needs a session token with read:sensitive)", ); } for (const c of report.changes) { lines.push(`${c.op} ${c.kind} ${c.name}`); for (const f of c.fieldDiffs) { lines.push( ` ${f.field}: ${JSON.stringify(f.live)} → ${JSON.stringify(f.desired)}${f.updatable ? "" : " [NOT UPDATABLE IN PLACE]"}`, ); } for (const e of c.envDiffs) { if (e.state === "remove-candidate") lines.push( ` env ${e.key}: live-only (orphan var — apply never removes)`, ); else if (e.secret) lines.push(` secret ${e.key} differs`); else lines.push(` env ${e.key}: ${e.state}`); } } for (const o of report.orphans) { lines.push( `orphan ${o.kind} ${o.name} (live, not in manifest — removal is a manual runbook act)`, ); } // Printed as a FINDING, in its own paragraph, with the consequence attached — // not as a one-line entry in a list of things that are fine. The failure this // catches is green: the deploy worked, the health check passed, and this line // is the only place anything says otherwise. It has to be readable as an // instruction to go and delete something, because that is what it is. for (const r of report.reserved) { lines.push( `FINDING: ${r.kind} ${r.name} carries env var ${r.key} — DELETE IT (Coolify UI)`, ` ${reservedConsequence(r.key)}`, " cast declares no such var and never will (it refuses a manifest that does),", " and `apply` never deletes — so this one is yours to remove, by hand, in the UI.", ); } const { placement } = report; if (placement.split) { lines.push( `split placement: these resources sit on ${placement.groups.length} different destinations`, ); for (const g of placement.groups) lines.push(` destination ${g.destinationId}: ${g.resources.join(", ")}`); lines.push( " a project's resources must share one destination — that is what the isolation IS.", " apply never moves a live resource between networks: resolve manually (runbook act).", ); } else if (placement.declared && placement.groups.length === 1) { lines.push( `placement: all resources on destination ${placement.groups[0].destinationId}`, ); } if (placement.declared) { // Said out loud on every run that declares one, rather than left to be // inferred from its absence. cast enforces this UUID exactly once — at // create — and can never check it again; an operator who thinks `diff` // covers it is an operator who thinks the isolation is verified. lines.push( `destination ${placement.declared} declared, NOT compared — Coolify 4.1.2 takes`, " destination_uuid on write and returns destination_id on read, and has no endpoint", " mapping one to the other. cast sends it on create; nothing can verify it after.", ); } else { // The other half of the same principle, and #41: declaring NOTHING is also a // decision about placement — cast sends no destination_uuid and lets Coolify // pick — and it was the one placement decision made in silence. The inference // lived in a source comment ("the server's only destination, which is what // Coolify picks anyway"), which is exactly where an assumption is invisible // until it is wrong. // // This reverses a judgment cast used to hold explicitly ("a line on every diff // that says nothing is how a report stops being read" — the test this replaces). // The line does not say nothing: it says which network the next create lands on, // which is a fact about this run and a wrong one to have to infer from a blank // space. It stays on a run that creates nothing, too, because the trap is set // precisely for projects that are already built and clean — the day their server // gains a second destination, every one of them that declared no destination // stops being able to create at all, and nothing will have warned them. // // Two lines, not three: the old judgment was not wrong about noise, only about // which side of it silence was on. lines.push( "placement: server's default destination (none declared) — cast sends no destination_uuid,", " so Coolify picks; a server with more than one destination refuses the create outright.", ); } lines.push( report.clean ? "clean" : `${report.changes.length} change(s), ${report.orphans.length} orphan(s)${ report.reserved.length > 0 ? `, ${report.reserved.length} reserved-name FINDING(s)` : "" }${placement.split ? ", split placement" : ""}`, ); return lines.join("\n"); }