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,
|
|
|
|
|
});
|
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
|
|
|
// 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-");
|
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
|
|
|
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-");
|
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
|
|
|
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 }
|
2026-07-14 22:20:27 +00:00
|
|
|
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",
|
|
|
|
|
);
|
2026-07-14 22:24:10 +00:00
|
|
|
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", {});
|
2026-07-14 22:24:10 +00:00
|
|
|
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: "/",
|
2026-07-14 22:20:27 +00:00
|
|
|
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");
|
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
|
|
|
// 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
|
|
|
});
|
2026-07-14 22:24:10 +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-");
|
2026-07-14 22:24:10 +00:00
|
|
|
mkdirSync(join(dir, ".infra"), { recursive: true });
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(dir, ".infra", "manifest.yaml"),
|
|
|
|
|
`project: widget
|
|
|
|
|
environments:
|
|
|
|
|
prod:
|
|
|
|
|
applications:
|
|
|
|
|
core:
|
|
|
|
|
source: { repo: acme/widget, branch: main }
|
2026-07-14 22:46:06 +00:00
|
|
|
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
|
2026-07-14 22:24:10 +00:00
|
|
|
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-");
|
2026-07-14 22:24:10 +00:00
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
});
|