cast/test/resolve.test.ts

794 lines
28 KiB
TypeScript
Raw Normal View History

fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
import { tmpdir } from "node:os";
import { join } from "node:path";
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
import { pathToFileURL } from "node:url";
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
import { describe, expect, it, vi } from "vitest";
import { computeDiff } from "../src/diff.js";
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
import { DERIVED_UNRESOLVED } from "../src/envtemplate.js";
fix: authenticate clones via gh / token, never fall into git's prompt (#13) resolveCheckout shelled out to a bare `git clone` and relied entirely on the ambient credential helper. On a workstation with none configured, git falls through to its interactive username/password prompt — which GitHub no longer accepts — and the resulting error talks about *the repository* rather than about cast's missing credentials. Being logged into `gh` does not help: `gh auth login` alone does not wire git's helper (that is `gh auth setup-git`, a separate act most people never run). Not routable around for prod: resolveCheckout refuses --path with --env prod, so the clone is the only path and its auth is mandatory. cast now resolves credentials itself, in order: `gh` borrowed as a per-invocation credential helper (no mutation of the user's global git config), then GITHUB_TOKEN / GH_TOKEN, then the ambient helper. The token is never embedded in the clone URL or in http.extraheader — both leak it into `ps`, and the latter persists it into the clone's git config. The helper reads it from the environment at run time, so what lands in argv is the literal text `$CAST_GIT_TOKEN`, never its value. GIT_TERMINAL_PROMPT=0 on every path: whichever credential was used, git may never fall through to a prompt it cannot satisfy — it can only hang, or hide the real fault. When there were no credentials at all, cast now says so, and names the fix. Note that the empty `credential.helper=` reset clears URL-scoped helpers (`credential.https://github.com.helper`, what `gh auth setup-git` writes) as well as generic ones — verified against a live private clone, along with all three acceptance criteria. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:30:04 +00:00
import {
cloneFailureMessage,
desiredFromManifest,
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
fillDesiredDerived,
requiredSecrets,
fix: authenticate clones via gh / token, never fall into git's prompt (#13) resolveCheckout shelled out to a bare `git clone` and relied entirely on the ambient credential helper. On a workstation with none configured, git falls through to its interactive username/password prompt — which GitHub no longer accepts — and the resulting error talks about *the repository* rather than about cast's missing credentials. Being logged into `gh` does not help: `gh auth login` alone does not wire git's helper (that is `gh auth setup-git`, a separate act most people never run). Not routable around for prod: resolveCheckout refuses --path with --env prod, so the clone is the only path and its auth is mandatory. cast now resolves credentials itself, in order: `gh` borrowed as a per-invocation credential helper (no mutation of the user's global git config), then GITHUB_TOKEN / GH_TOKEN, then the ambient helper. The token is never embedded in the clone URL or in http.extraheader — both leak it into `ps`, and the latter persists it into the clone's git config. The helper reads it from the environment at run time, so what lands in argv is the literal text `$CAST_GIT_TOKEN`, never its value. GIT_TERMINAL_PROMPT=0 on every path: whichever credential was used, git may never fall through to a prompt it cannot satisfy — it can only hang, or hide the real fault. When there were no credentials at all, cast now says so, and names the fix. Note that the empty `credential.helper=` reset clears URL-scoped helpers (`credential.https://github.com.helper`, what `gh auth setup-git` writes) as well as generic ones — verified against a live private clone, along with all three acceptance criteria. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:30:04 +00:00
resolveCheckout,
resolveGitAuth,
} from "../src/resolve.js";
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
import { tmp } from "./helpers/tmp.js";
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
describe("resolveCheckout", () => {
it("hard-refuses --path with prod", () => {
expect(() =>
resolveCheckout("acme/widget", { env: "prod", path: "/tmp/x" }),
).toThrow(/--path.*prod/);
});
it("returns --path for non-prod", () => {
expect(
resolveCheckout("acme/widget", {
env: "staging",
path: "/tmp/x",
}),
).toBe("/tmp/x");
});
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
// #117: the ephemeral checkout used to survive the process that made it, so
// every `cast apply`/`diff`/`capture` without --path left a full clone behind.
// This has to run in a real child process — the reaper is an exit hook, and
// the thing under test is precisely what happens when the process ends.
//
// A stub `git` on PATH makes it hermetic and fast: the clone fails, which is
// the *harder* case, since the directory is created before the clone runs and
// the failure path rethrows. If the dir is gone after a failed clone, the
// registration happens early enough to cover the successful one too.
it("removes the ephemeral checkout when the process exits", () => {
const bin = tmp("cast-fakebin-");
writeFileSync(join(bin, "git"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
// Other suites (and other machines) have their own checkouts lying around;
// only the one this child allocates is ours to assert on.
const preexisting = new Set(
readdirSync(tmpdir()).filter((d) => d.startsWith("infra-checkout-")),
);
const script = `
const { resolveCheckout } = await import(${JSON.stringify(
pathToFileURL(join(process.cwd(), "dist/resolve.js")).href,
)});
try { resolveCheckout("acme/widget", { env: "dev" }); } catch {}
// Report what was allocated, then let the process exit normally.
const fs = await import("node:fs");
const os = await import("node:os");
console.log(JSON.stringify(
fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith("infra-checkout-")),
));
`;
const out = execFileSync(
process.execPath,
["--input-type=module", "-e", script],
{
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
encoding: "utf8",
},
);
const allocated: string[] = JSON.parse(
out.trim().split("\n").pop() ?? "[]",
);
const mine = allocated.filter((d) => !preexisting.has(d));
// It must have allocated exactly one — otherwise this test proves nothing.
expect(mine).toHaveLength(1);
// ...and that one must be gone now that the child has exited.
expect(existsSync(join(tmpdir(), mine[0]))).toBe(false);
});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
});
fix: authenticate clones via gh / token, never fall into git's prompt (#13) resolveCheckout shelled out to a bare `git clone` and relied entirely on the ambient credential helper. On a workstation with none configured, git falls through to its interactive username/password prompt — which GitHub no longer accepts — and the resulting error talks about *the repository* rather than about cast's missing credentials. Being logged into `gh` does not help: `gh auth login` alone does not wire git's helper (that is `gh auth setup-git`, a separate act most people never run). Not routable around for prod: resolveCheckout refuses --path with --env prod, so the clone is the only path and its auth is mandatory. cast now resolves credentials itself, in order: `gh` borrowed as a per-invocation credential helper (no mutation of the user's global git config), then GITHUB_TOKEN / GH_TOKEN, then the ambient helper. The token is never embedded in the clone URL or in http.extraheader — both leak it into `ps`, and the latter persists it into the clone's git config. The helper reads it from the environment at run time, so what lands in argv is the literal text `$CAST_GIT_TOKEN`, never its value. GIT_TERMINAL_PROMPT=0 on every path: whichever credential was used, git may never fall through to a prompt it cannot satisfy — it can only hang, or hide the real fault. When there were no credentials at all, cast now says so, and names the fix. Note that the empty `credential.helper=` reset clears URL-scoped helpers (`credential.https://github.com.helper`, what `gh auth setup-git` writes) as well as generic ones — verified against a live private clone, along with all three acceptance criteria. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:30:04 +00:00
describe("resolveGitAuth", () => {
const noGh = () => false;
const yesGh = () => true;
it("prefers gh, borrowed as a per-invocation credential helper", () => {
const auth = resolveGitAuth({ GITHUB_TOKEN: "t" }, yesGh);
expect(auth.source).toBe("gh");
expect(auth.configArgs.join(" ")).toContain("!gh auth git-credential");
// No token is materialized when gh is driving.
expect(auth.env).toEqual({});
});
it("falls back to GITHUB_TOKEN when gh is absent", () => {
const auth = resolveGitAuth({ GITHUB_TOKEN: "ghp_secret" }, noGh);
expect(auth.source).toBe("token");
expect(auth.env).toEqual({ CAST_GIT_TOKEN: "ghp_secret" });
});
it("accepts GH_TOKEN as well as GITHUB_TOKEN", () => {
const auth = resolveGitAuth({ GH_TOKEN: "ghp_secret" }, noGh);
expect(auth.source).toBe("token");
expect(auth.env).toEqual({ CAST_GIT_TOKEN: "ghp_secret" });
});
// The acceptance criterion from #13: "the token never appears in process
// arguments or on disk". The helper string git receives must carry the
// NAME of the variable, never its value — sh expands it inside the helper.
it("never puts the token value in the git argv", () => {
const auth = resolveGitAuth({ GITHUB_TOKEN: "ghp_secret" }, noGh);
const argv = auth.configArgs.join(" ");
expect(argv).not.toContain("ghp_secret");
expect(argv).toContain("$CAST_GIT_TOKEN");
});
// A helper configured globally would otherwise be consulted first and
// silently decide the outcome, defeating the order cast just established.
it("resets the inherited helper list before installing its own", () => {
for (const auth of [
resolveGitAuth({}, yesGh),
resolveGitAuth({ GITHUB_TOKEN: "t" }, noGh),
]) {
expect(auth.configArgs.slice(0, 2)).toEqual(["-c", "credential.helper="]);
}
});
it("falls through to the ambient helper when there is nothing else", () => {
expect(resolveGitAuth({}, noGh)).toEqual({
source: "ambient",
configArgs: [],
env: {},
});
});
});
describe("cloneFailureMessage", () => {
const ambient = { source: "ambient" as const, configArgs: [], env: {} };
const gh = { source: "gh" as const, configArgs: [], env: {} };
// The original bug: git's own error talked about THE REPOSITORY when the
// real fault was cast having no credentials at all.
it("blames the missing credentials, not the repo, when there were none", () => {
const msg = cloneFailureMessage("heavy-duty/incubator", ambient, "");
expect(msg).toMatch(/no GitHub credentials/);
expect(msg).toMatch(/gh auth login/);
expect(msg).toMatch(/GITHUB_TOKEN/);
expect(msg).not.toMatch(/does not exist/);
});
// ...and the converse: once cast DID authenticate, the repo really is a
// candidate explanation again, and 404-means-403 has to be spelled out.
it("names both roads when a credential was used and GitHub still refused", () => {
const msg = cloneFailureMessage("heavy-duty/incubator", gh, "");
expect(msg).toMatch(/gh/);
expect(msg).toMatch(/does not exist/);
expect(msg).toMatch(/private/);
expect(msg).not.toMatch(/no GitHub credentials/);
});
it("passes git's own stderr through rather than swallowing it", () => {
const msg = cloneFailureMessage(
"heavy-duty/incubator",
ambient,
"fatal: could not read Username for 'https://github.com'",
);
expect(msg).toMatch(/could not read Username/);
});
});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
describe("desiredFromManifest", () => {
it("maps manifest + templates to Desired[] with resolved env", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
core-api:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /apps/core }
port: 3000
healthcheck: /health
domains: ["http://api.staging.example.com"]
env_template: core-api.staging.env.template
`,
);
writeFileSync(
join(dir, ".infra", "env", "core-api.staging.env.template"),
"PORT=3000\nMG=${MG}\n",
);
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
const { desired, resolvedEnvs } = desiredFromManifest(dir, "staging", {
MG: "secret-v",
});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
expect(desired).toHaveLength(1);
expect(desired[0]).toMatchObject({
kind: "application",
name: "core-api",
fields: {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/apps/core",
port: 3000,
healthcheck: "/health",
domains: ["http://api.staging.example.com"],
},
});
expect(resolvedEnvs["core-api"].vars.MG).toEqual({
value: "secret-v",
secret: true,
});
// None of the four build settings are emitted for an app that declares none:
// managing is_static is opt-in (declaring it would otherwise PATCH static
// serving OFF on an un-migrated app), and the commands default to "let the
// build pack decide".
expect(desired[0].fields).not.toHaveProperty("is_static");
expect(desired[0].fields).not.toHaveProperty("install_command");
expect(desired[0].fields).not.toHaveProperty("build_command");
expect(desired[0].fields).not.toHaveProperty("start_command");
});
it("emits is_static:false when static:false is explicitly declared (a guard against a UI flip)", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /, static: false }
domains: ["https://c.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields.is_static).toBe(false);
});
// #63: the static-site build settings a workspace monorepo needs.
it("emits is_static:true and the three commands for a non-compose app that declares them", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
landing:
source: { repo: acme/widget, branch: main }
build:
pack: static
base_directory: /
publish_directory: /apps/landing-site/dist
install_command: npm ci
build_command: npm run build -w apps/landing-site
start_command: node server.js
static: true
domains: ["https://landing.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields).toMatchObject({
is_static: true,
install_command: "npm ci",
build_command: "npm run build -w apps/landing-site",
start_command: "node server.js",
publish_directory: "/apps/landing-site/dist",
});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
});
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 reverse of what this file used to assert. `backup` was deliberately
// routed AROUND `fields` into a side channel, because live Coolify was
// believed not to expose a schedule back; it does (GET
// /databases/{uuid}/backups), and the side channel is what made a `backup:`
// block added to an existing database silently do nothing (#51).
it("puts a database backup block in fields, so it is diffed like any other", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
databases:
postgres:
type: postgresql
version: "17"
backup: { frequency: "0 3 * * *", retention: 7 }
`,
);
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
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields).toEqual({
type: "postgresql",
version: "17",
backup: { frequency: "0 3 * * *", retention: 7 },
});
});
it("leaves `backup` out of fields entirely when none is declared", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
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
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
databases:
postgres:
type: postgresql
version: "17"
`,
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
);
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
const { desired } = desiredFromManifest(dir, "staging", {});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
expect(desired[0].fields).toEqual({ type: "postgresql", version: "17" });
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
// Undeclared means uncompared, NOT "delete whatever is there": a live
// schedule on a database whose manifest says nothing about backups is left
// alone, like every other thing apply never removes.
expect("backup" in desired[0].fields).toBe(false);
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
});
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
it("emits a service's service_domains into fields, canonicalized (cast#72)", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
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
prod:
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
applications: {}
services:
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
umami:
type: umami
service_domains:
umami: ["https://b.example.com", "https://a.example.com"]
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
`,
);
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
const { desired } = desiredFromManifest(dir, "prod", {});
// Keys and each URL array are sorted so container order never false-drifts
// against Coolify's read-back ordering.
expect(desired[0].fields).toEqual({
type: "umami",
service_domains: {
umami: ["https://a.example.com", "https://b.example.com"],
},
});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
});
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
it("is clean for a service whose live per-container hostnames match (cast#72, no perpetual update)", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications: {}
services:
umami:
type: umami
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
service_domains:
umami: ["https://analytics.example.com"]
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
`,
);
const { desired } = desiredFromManifest(dir, "prod", {});
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
const report = computeDiff(
desired,
[
{
kind: "service",
name: "umami",
uuid: "svc-uuid",
fields: {
type: "umami",
service_domains: { umami: ["https://analytics.example.com"] },
},
},
],
"full",
);
expect(report.clean).toBe(true);
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
});
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
it("diffs a service whose declared hostname is missing live (apply will set it)", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications: {}
services:
umami:
type: umami
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
service_domains:
umami: ["https://analytics.example.com"]
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
`,
);
const { desired } = desiredFromManifest(dir, "prod", {});
const report = computeDiff(
desired,
[
{
kind: "service",
name: "umami",
uuid: "svc-uuid",
fields: { type: "umami" },
},
],
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
"full",
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
);
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
expect(report.clean).toBe(false);
expect(report.changes[0].fieldDiffs).toEqual([
{
field: "service_domains",
desired: { umami: ["https://analytics.example.com"] },
live: undefined,
updatable: true,
},
]);
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
});
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
it("a service with no service_domains carries only its type", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
services:
plausible:
type: plausible
`,
);
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
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields).toEqual({ type: "plausible" });
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
});
it("resolves a dockercompose app to docker_compose_location/docker_compose_domains and no port/healthcheck/domains keys", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
service_domains:
api: ["https://api.widget.example.com"]
env_template: core.prod.env.template
`,
);
writeFileSync(
join(dir, ".infra", "env", "core.prod.env.template"),
"PORT=3000\n",
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
const { desired } = desiredFromManifest(dir, "prod", {});
warn.mockRestore();
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
expect(desired).toHaveLength(1);
expect(desired[0]).toMatchObject({
kind: "application",
name: "core",
fields: {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "/docker-compose.yaml",
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
docker_compose_domains: {
api: ["https://api.widget.example.com"],
},
},
});
expect(desired[0].fields).not.toHaveProperty("port");
expect(desired[0].fields).not.toHaveProperty("healthcheck");
expect(desired[0].fields).not.toHaveProperty("domains");
// A compose app builds from its compose file — none of the static/command
// fields belong on it, not even is_static (which every NON-compose app gets).
expect(desired[0].fields).not.toHaveProperty("is_static");
expect(desired[0].fields).not.toHaveProperty("install_command");
expect(desired[0].fields).not.toHaveProperty("build_command");
expect(desired[0].fields).not.toHaveProperty("start_command");
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
});
it('warns that apply cannot enable "Include Source Commit in Build" on a dockercompose app (unsettable via the Coolify 4.1.2 API)', () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
api: ["https://api.widget.example.com"]
`,
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired } = desiredFromManifest(dir, "prod", {});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toMatch(/application core/);
expect(warn.mock.calls[0][0]).toMatch(/Include Source Commit in Build/);
expect(warn.mock.calls[0][0]).toMatch(/Coolify UI/);
warn.mockRestore();
// The setting is absent from Coolify 4.1.2's create/PATCH allowlists, which
// reject unknown keys outright — so it must never reach `fields`, or apply
// would 422 on every run. Guards the fix a future reader would reach for.
expect(desired[0].fields).not.toHaveProperty(
"include_source_commit_in_build",
);
});
it("does not warn about the source-commit toggle for a non-dockercompose app (the build arg is a compose concern)", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
site:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://widget.example.com"]
`,
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
desiredFromManifest(dir, "prod", {});
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
it("throws when the env is missing from the manifest", () => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
"project: x\nenvironments: {}\n",
);
expect(() => desiredFromManifest(dir, "prod", {})).toThrow(
/environment prod not in manifest/,
);
});
});
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
describe("derived resource refs (#60)", () => {
// A manifest with one app whose template derives a DB URL, plus the database
// the ref names. `dbName` and `attr` are knobs the validation cases turn.
const write = (
ref = "${resource:postgres.url}",
dbBlock = " databases:\n postgres: { type: postgresql }\n",
): string => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
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
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /apps/core }
domains: ["http://api.example.com"]
env_template: core.staging.env.template
${dbBlock}`,
);
writeFileSync(
join(dir, ".infra", "env", "core.staging.env.template"),
`DATABASE_URL=${ref}\n`,
);
return dir;
};
it("emits a derived var (unresolved) and does not demand it as a secret", () => {
const dir = write();
const { desired } = desiredFromManifest(dir, "staging", {});
const app = desired.find((d) => d.name === "core");
expect(app?.env?.vars.DATABASE_URL).toEqual({
value: DERIVED_UNRESOLVED,
secret: true,
derived: { resource: "postgres", attr: "url" },
});
// capture's view: it is NOT a required store secret.
const req = requiredSecrets(dir, "staging");
expect(req.required.map((r) => r.ref)).not.toContain(
"resource:postgres.url",
);
expect(req.required).toHaveLength(0);
});
it("fillDesiredDerived fills it from a URL map keyed by manifest name", () => {
const dir = write();
const { desired } = desiredFromManifest(dir, "staging", {});
const filled = fillDesiredDerived(desired, {
postgres: "postgres://u:p@uuid:5432/db",
});
const app = filled.find((d) => d.name === "core");
expect(app?.env?.vars.DATABASE_URL.value).toBe(
"postgres://u:p@uuid:5432/db",
);
});
it("hard-refuses a ref naming a database the manifest does not declare", () => {
// No databases block at all — the ref points at nothing.
const dir = write("${resource:postgres.url}", "");
expect(() => desiredFromManifest(dir, "staging", {})).toThrow(
/no database named postgres/,
);
// capture refuses it too, in the same voice — every verb that opens a template.
expect(() => requiredSecrets(dir, "staging")).toThrow(
/no database named postgres/,
);
});
it("hard-refuses an attribute other than .url", () => {
const dir = write("${resource:postgres.password}");
expect(() => desiredFromManifest(dir, "staging", {})).toThrow(
/unknown resource attribute/,
);
});
});
feat(resolve): derive base-URL env vars from manifest domains via ${domain:...} (#66) A public base URL an app reads (LANDING_BASE_URL, ADMIN_WEB_BASE_URL) is a fact the manifest already states in `domains`/`service_domains` — the same fields cast parses to reconcile Coolify domains. Hand-transcribing it into an env template is a second copy that drifts (incubator's prod LANDING_BASE_URL silently kept a pre-apex host). So a template can now say it directly: LANDING_BASE_URL=${domain:landing} ADMIN_WEB_BASE_URL=${domain:core.admin} - ${domain:<app>} -> applications.<app>.domains[0] - ${domain:<app>.<service>} -> applications.<app>.service_domains.<service>[0] Symmetric with ${resource:...} (#60) — parse -> sentinel -> validate -> fill — but a domain is PURE MANIFEST DATA, known at plan time, so it resolves fully in desiredFromManifest against a map built from the manifest: no live read, no executor deferral, no unresolved-at-write path. Domains are PUBLIC, so they resolve to secret:false (printed in diffs) and read as plain literals downstream (no diff.ts change). Not secrets: excluded from templateRefs, never captured. assertDomainRefs is the single validation gate (apply/diff/capture), refusing an undeclared app/service, a wrong-shape ref, or an empty/blank domain list before the sentinel can escape. Applications only (Coolify 4.1.2 can't set service domains). REPORTING_TZ-style operator literals stay literal. Closes #66. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:02:33 +00:00
describe("derived domain refs (#66)", () => {
// Assemble a manifest from an applications block plus one env template. The
// env_template line is appended to whichever app comes last in `apps`.
const write = (apps: string, tmpl: string): string => {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const dir = tmp("infra-co-");
feat(resolve): derive base-URL env vars from manifest domains via ${domain:...} (#66) A public base URL an app reads (LANDING_BASE_URL, ADMIN_WEB_BASE_URL) is a fact the manifest already states in `domains`/`service_domains` — the same fields cast parses to reconcile Coolify domains. Hand-transcribing it into an env template is a second copy that drifts (incubator's prod LANDING_BASE_URL silently kept a pre-apex host). So a template can now say it directly: LANDING_BASE_URL=${domain:landing} ADMIN_WEB_BASE_URL=${domain:core.admin} - ${domain:<app>} -> applications.<app>.domains[0] - ${domain:<app>.<service>} -> applications.<app>.service_domains.<service>[0] Symmetric with ${resource:...} (#60) — parse -> sentinel -> validate -> fill — but a domain is PURE MANIFEST DATA, known at plan time, so it resolves fully in desiredFromManifest against a map built from the manifest: no live read, no executor deferral, no unresolved-at-write path. Domains are PUBLIC, so they resolve to secret:false (printed in diffs) and read as plain literals downstream (no diff.ts change). Not secrets: excluded from templateRefs, never captured. assertDomainRefs is the single validation gate (apply/diff/capture), refusing an undeclared app/service, a wrong-shape ref, or an empty/blank domain list before the sentinel can escape. Applications only (Coolify 4.1.2 can't set service domains). REPORTING_TZ-style operator literals stay literal. Closes #66. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:02:33 +00:00
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
${apps}`,
);
writeFileSync(join(dir, ".infra", "env", "refs.env.template"), tmpl);
return dir;
};
// A plain app (a `domains` list) and a compose app (`service_domains`); the
// env_template line appended after either makes that app carry the template.
const LANDING = ` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://new.heavyduty.builders"]
`;
const CORE = ` core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
admin: ["https://admin.heavyduty.builders"]
`;
const TMPL = " env_template: refs.env.template\n";
it("resolves ${domain:<app>} and ${domain:<app>.<service>} to the manifest's domains, secret:false", () => {
const dir = write(
LANDING + CORE + TMPL,
"ADMIN_WEB_BASE_URL=${domain:core.admin}\nLANDING_BASE_URL=${domain:landing}\n",
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired, resolvedEnvs } = desiredFromManifest(dir, "prod", {});
warn.mockRestore();
const core = desired.find((d) => d.name === "core");
// The app.service ref and the app ref both resolve to the verbatim domain
// (scheme and all), public, and with no `domain` marker — a plain literal.
expect(core?.env?.vars.ADMIN_WEB_BASE_URL).toEqual({
value: "https://admin.heavyduty.builders",
secret: false,
});
expect(core?.env?.vars.LANDING_BASE_URL).toEqual({
value: "https://new.heavyduty.builders",
secret: false,
});
// resolvedEnvs is domain-filled too, and no sentinel escapes anywhere.
expect(resolvedEnvs.core.vars.LANDING_BASE_URL.value).toBe(
"https://new.heavyduty.builders",
);
expect(JSON.stringify(desired)).not.toContain("cast:unresolved-domain-ref");
});
it("does not list domain refs as required secrets (capture validates but never captures them)", () => {
const dir = write(
LANDING + CORE + TMPL,
"ADMIN_WEB_BASE_URL=${domain:core.admin}\nLANDING_BASE_URL=${domain:landing}\nMG=${MG}\n",
);
const req = requiredSecrets(dir, "prod");
// Only the real ${MG} secret is required — the two domain refs are not.
expect(req.required.map((r) => r.ref)).toEqual(["MG"]);
});
it("refuses a ref naming an application the manifest does not declare", () => {
const dir = write(LANDING + TMPL, "X=${domain:nope}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(
/no application named nope/,
);
// Every verb that opens a template refuses it, in the same voice.
expect(() => desiredFromManifest(dir, "prod", {})).toThrow(
/no application named nope/,
);
});
it("refuses ${domain:<app>} on a compose app whose domains live per service", () => {
const dir = write(CORE + TMPL, "X=${domain:core}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(
/domains live per service/,
);
});
it("refuses ${domain:<app>.<service>} on an app that declares a plain domains list", () => {
const dir = write(LANDING + TMPL, "X=${domain:landing.admin}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(/plain `domains` list/);
});
it("refuses a service the app's service_domains does not declare", () => {
const dir = write(CORE + TMPL, "X=${domain:core.nope}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(/no service named nope/);
});
it("refuses a ref whose selected domain list is declared but empty", () => {
const dir = write(
` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: []
${TMPL}`,
"X=${domain:landing}\n",
);
expect(() => requiredSecrets(dir, "prod")).toThrow(/domain list is empty/);
});
it('refuses a ref whose selected list has a blank first entry (domains: [""]) — the sentinel must not escape', () => {
const dir = write(
` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: [""]
${TMPL}`,
"X=${domain:landing}\n",
);
// Schema-valid (a non-empty array of strings), so it PASSES manifest load —
// the assert is the gate. buildDomainMap would store "" and fillDomainEnv
// would read "" as unresolved, leaving DOMAIN_UNRESOLVED in a returned env.
expect(() => requiredSecrets(dir, "prod")).toThrow(
/empty or its first entry is blank/,
);
// Every verb that opens a template refuses it — the sentinel never escapes
// into a returned desired set.
expect(() => desiredFromManifest(dir, "prod", {})).toThrow(
/empty or its first entry is blank/,
);
});
it('refuses a service ref whose selected list has a blank first entry (service_domains: {admin: [""]})', () => {
const dir = write(
` core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
admin: [""]
${TMPL}`,
"X=${domain:core.admin}\n",
);
expect(() => requiredSecrets(dir, "prod")).toThrow(
/empty or its first entry is blank/,
);
});
it("gives the domains-app-shape message (not 'no service named') for a service ref against an empty-domains app", () => {
// An empty `domains: []` is still a domains app (shape is by key presence).
// A ${domain:app.svc} ref against it is a spurious-service error, not an
// unknown-service one.
const dir = write(
` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: []
${TMPL}`,
"X=${domain:landing.admin}\n",
);
expect(() => requiredSecrets(dir, "prod")).toThrow(/plain `domains` list/);
});
});