cast/test/draft.test.ts

767 lines
27 KiB
TypeScript
Raw Permalink Normal View History

fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
import { mkdirSync, writeFileSync } from "node:fs";
feat: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
import {
type DraftProject,
assertEmptyTarget,
assertNoExistingManifest,
draftResourcesFrom,
isProviderGenerated,
planDraft,
repoFromGitUrl,
} from "../src/draft.js";
import { templateKeys, templateRefs } from "../src/envtemplate.js";
import { loadManifest } from "../src/manifest.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: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
const ctx = {
env: "prod",
instance: "box-b",
baseUrl: "https://coolify.example.com",
team: { id: 0, name: "Root Team" },
server: "box-b",
recipient: "age1example",
generatedAt: "2026-07-13T00:00:00.000Z",
};
// The value that must never leave the box it was read from.
const POISON = "postgres://postgres:pw@incubator-db-v2.box-b.internal:5432/app";
const project = (over: Partial<DraftProject> = {}): DraftProject => ({
name: "Incubator",
coolifyEnv: "staging",
resources: [
{
kind: "application",
name: "Incubator Stack v2",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
ports_exposes: "3000",
fqdn: "https://app.example.com",
destination_id: 3,
},
env: {
DATABASE_URL: POISON,
MAILGUN_KEY: "key-abc123",
NODE_ENV: "production",
},
},
],
unreadable: [],
otherEnvironments: [],
...over,
});
feat(draft): resolve a repo's GitHub App by source_id, not the only-App guess (#72) `inventory --emit-draft` wrote the `github_apps` binding by guessing: with exactly one App on the instance it bound every repo to it ("no other it could be"), and with none or several it left a REVIEW marker on all of them. The audit (#72) showed the binding is READABLE, so the guess was both unnecessary and, on a single-App instance, silently WRONG for any public repo (a repo cloned without a GitHub App got bound to the one App anyway). Every application carries the `source_id`/`source_type` of the App that clones it — `removeSensitiveData` hides neither (ApplicationsController v4.1.2) — and `GET /github-apps` returns each App's `id` and `name` (only `client_secret`/`webhook_secret` are hidden). So the draft now matches the two: each repo binds to the App its application's `source_id` names. A GitlabApp/public-repo source (or an instance that will not list its Apps) resolves to nothing and still gets a REVIEW marker — and a `source_id` that collides with an App id but carries a non-GithubApp `source_type` is not mistaken for one. The biggest gain is the multi-App instance the old heuristic could not handle at all: it wrote REVIEW on every repo; the lookup resolves each. semantics.md, the draft header, and the NO_API_COVERAGE row are corrected to match (the audit's #51 arc: a limitation filed as a defect gets fixed). `npm run check` clean · 511 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:22:40 +00:00
describe("github_apps binding — resolved by source_id, not guessed (cast#72)", () => {
const appProject = (
name: string,
repo: string,
source?: { source_id: number; source_type: string },
): DraftProject => ({
name,
coolifyEnv: "staging",
resources: [
{
kind: "application",
name,
uuid: `u-${name}`,
raw: {
git_repository: `https://github.com/${repo}`,
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://x.example.com",
...source,
},
env: {},
},
],
unreadable: [],
otherEnvironments: [],
});
const bindings = (plan: ReturnType<typeof planDraft>) =>
plan.files.find((f) => f.path === "environments.yaml")?.content ?? "";
// The payoff the old only-App heuristic could not deliver: with MORE THAN ONE
// App it used to write a REVIEW marker on every repo. source_id resolves each.
it("binds each repo to the App its source_id names, even with several Apps", () => {
const ghApp = "App\\Models\\GithubApp";
const plan = planDraft(
[
appProject("acme-api", "acme/api", {
source_id: 7,
source_type: ghApp,
}),
appProject("beta-web", "beta/web", {
source_id: 9,
source_type: ghApp,
}),
],
{
...ctx,
githubApps: [
{ id: 7, name: "acme-app" },
{ id: 9, name: "beta-app" },
],
},
);
const yaml = bindings(plan);
expect(yaml).toContain("acme/api: acme-app");
expect(yaml).toContain("beta/web: beta-app");
});
it("leaves a REVIEW marker for a public repo (no GithubApp source)", () => {
const plan = planDraft([appProject("pub", "acme/public")], {
...ctx,
githubApps: [{ id: 7, name: "acme-app" }],
});
expect(bindings(plan)).toMatch(/acme\/public: REVIEW-/);
});
it("does not mistake a non-GithubApp source whose id collides with an App id", () => {
const plan = planDraft(
[
appProject("gitlab", "acme/gl", {
source_id: 7,
source_type: "App\\Models\\GitlabApp",
}),
],
{ ...ctx, githubApps: [{ id: 7, name: "acme-app" }] },
);
// id 7 exists as a GitHub App, but this app's source is a GitlabApp — the
// collision must not bind it to acme-app.
expect(bindings(plan)).toMatch(/acme\/gl: REVIEW-/);
});
});
feat: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
describe("isProviderGenerated — the one judgment that must not be wrong", () => {
it("recognizes the datastore families whose value points at the SOURCE box", () => {
for (const key of [
"DATABASE_URL",
"DATABASE_URL_PROD",
"UMAMI_DATABASE_URL",
"REDIS_URL",
"POSTGRES_PASSWORD",
fix(diff): Coolify's own generated vars are not orphans (#87) A prod box with zero drift could not make `cast diff` say clean: sixteen lines of `live-only (orphan var — apply never removes)`, every one of them a var Coolify MINTED — `SERVICE_FQDN_API` for a compose app's per-container domains, `SERVICE_PASSWORD_POSTGRES`/`POSTGRES_*` for the one-click umami service's bundled datastore. They held two resources permanently in `change`. `remove-candidate` means "a live-only var the manifest does not declare; apply never removes it; read it by eye". For a name cast did not put there, cannot declare in any vocabulary, and will never remove, that is a category error — and a report that can never say clean is how an operator learns to stop reading it. #78's own Impact section made the argument: "an operator who learns these always show change stops trusting the diff." cast already knew: draft.ts has held this exact judgment since #27 and used it to refuse copying these into a draft. diffEnv just never asked. So the vocabulary moves to reserved.ts — which already owns "names the platform, not the manifest, controls" — and both callers consult it. TWO WIDTHS, deliberately, because over-matching is safe in a draft and unsafe in a diff: - draft (WIDE): over-matching withholds a value for review — loud and recoverable. Under-matching copies the source box's DATABASE_URL into a new box that boots against the OLD box's database. It errs wide. - diff, applications (NARROW): over-matching HIDES a live-only var. A hand-left DATABASE_URL still pointing at a box nobody declares is the one orphan most worth printing — and it matches the wide rule. Probed against prod: the wide bucket on a real application held DATABASE_URL and REDIS_URL, both of them cast's OWN declared vars. - diff, services (WIDE): a Coolify service is a vendored bundle whose internals cast does not model — `type` + `service_domains` + an env_template is the whole vocabulary, and the rest is the template's. Also fixes a real gap the #87 tests found: the pair-rule missed `POSTGRES_DB` outright, because [POSTGRES, DB] is datastore + datastore with no connection word. A db NAME is a connection coordinate like any other, so `DB` joins them — it is exactly the var a one-click service mints for its bundled Postgres. And corrects LiveEnvVar's comment: it still cited #79's "stale real_value, a stored column Coolify does not refresh". That was false — an accessor cannot go stale, and real_value tracks value on every row of a real box. The split is still right (real_value is an ESCAPED rendering: 'true' is not true); only its motivation was wrong. The drift it chased was #85's preview shadow. Tests: an application carrying only SERVICE_* reads clean; a hand-left DATABASE_URL on an application is STILL reported; a service carrying the one-click template's wiring reads clean; a non-generated live-only var on a service is STILL reported. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:56:10 +00:00
// The db NAME is a connection coordinate too — [POSTGRES, DB] is datastore
// + datastore, so the pair-rule missed it until `DB` joined the connection
// words. It is exactly what a one-click service mints for its bundled
// Postgres (#87).
"POSTGRES_DB",
feat: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
"DB_HOST",
"MONGO_URI",
]) {
expect(isProviderGenerated(key), key).toBe(true);
}
});
it("recognizes Coolify's own per-instance magic vars", () => {
for (const key of [
"SERVICE_FQDN_UMAMI",
"SERVICE_URL_UMAMI",
"SERVICE_PASSWORD_POSTGRES",
"SERVICE_USER_UMAMI",
"SERVICE_BASE64_KEY",
]) {
expect(isProviderGenerated(key), key).toBe(true);
}
});
it("leaves an ordinary secret alone — it is captured, not placeheld", () => {
for (const key of [
"MAILGUN_KEY",
"OPENROUTER_KEY",
"ADMIN_EMAIL",
"NODE_ENV",
"SERVICE_NAME",
"PORT",
]) {
expect(isProviderGenerated(key), key).toBe(false);
}
});
});
describe("repoFromGitUrl — the only place a box knows which repo it is", () => {
it("reads a slug out of every remote shape Coolify stores", () => {
expect(repoFromGitUrl("https://github.com/heavy-duty/incubator")).toBe(
"heavy-duty/incubator",
);
expect(repoFromGitUrl("https://github.com/heavy-duty/incubator.git")).toBe(
"heavy-duty/incubator",
);
expect(repoFromGitUrl("git@github.com:heavy-duty/incubator.git")).toBe(
"heavy-duty/incubator",
);
expect(repoFromGitUrl("heavy-duty/incubator")).toBe("heavy-duty/incubator");
});
it("answers undefined rather than guessing", () => {
expect(repoFromGitUrl("")).toBeUndefined();
expect(repoFromGitUrl(undefined)).toBeUndefined();
expect(repoFromGitUrl("not-a-remote")).toBeUndefined();
});
});
describe("draftResourcesFrom — including what cast cannot model", () => {
it("names a MySQL rather than silently omitting it", () => {
const { resources, unreadable } = draftResourcesFrom({
applications: [{ name: "web", uuid: "a1" }],
postgresqls: [{ name: "db", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
services: [{ name: "umami", uuid: "s1" }],
mysqls: [{ name: "legacy-mysql", uuid: "m1" }],
mongodbs: [{ name: "old-mongo", uuid: "m2" }],
});
expect(resources.map((r) => [r.kind, r.name])).toEqual([
["application", "web"],
["database", "db"],
["database", "cache"],
["service", "umami"],
]);
expect(unreadable).toEqual([
{ kind: "mysql", name: "legacy-mysql" },
{ kind: "mongodb", name: "old-mongo" },
]);
});
});
describe("planDraft — the emitted shape", () => {
it("writes a manifest cast itself can load, keyed by --env", () => {
const plan = planDraft([project()], ctx);
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
expect(manifest?.path).toBe("incubator/.infra/manifest.yaml");
// A file that says what it is. It leaves this process and is read by someone
// deciding whether to trust it.
expect(manifest?.content).toContain("PROPOSAL");
expect(manifest?.content).toContain("`apply` does not read this file");
expect(manifest?.content).toContain("box-b");
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("cast-draft-");
feat: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? "");
const loaded = loadManifest(path);
expect(loaded.project).toBe("Incubator");
const env = loaded.environments.prod;
// The resource keeps the BOX's name — renaming it here would make the file
// unusable against the UI it was read from.
expect(Object.keys(env.applications)).toEqual(["Incubator Stack v2"]);
expect(env.applications["Incubator Stack v2"].source).toEqual({
repo: "heavy-duty/incubator",
branch: "main",
});
// And the manifest DECLARES the placeheld name, so a later `capture` does the
// same placeholding with no flag to remember.
expect(env.generated_secrets).toEqual(["DATABASE_URL"]);
});
// #63: is_static was previously not even in NO_HOME, so a rebuild silently
// lost it — the exact crash. draft now carries it, plus the install/build/
// start commands that used to be flagged as NO_HOME.
it("carries static + install/build/start commands, and does NOT flag them as uncaptured", () => {
const p = project({
resources: [
{
kind: "application",
name: "Landing",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "static",
base_directory: "/",
publish_directory: "/apps/landing-site/dist",
fqdn: "https://landing.example.com",
is_static: true,
install_command: "npm ci",
build_command: "npm run build -w apps/landing-site",
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
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("cast-draft-");
const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? "");
const build =
loadManifest(path).environments.prod.applications.Landing.build;
expect(build.static).toBe(true);
expect(build.install_command).toBe("npm ci");
expect(build.build_command).toBe("npm run build -w apps/landing-site");
// These now have a manifest home, so they must NOT be reported as settings
// cast could see but not express.
for (const setting of ["is_static", "install_command", "build_command"]) {
expect(plan.uncaptured.some((u) => u.setting === setting)).toBe(false);
}
});
// A draft must only ever emit a manifest that LOADS. is_static true with no
// publish_directory would be `static: true` with nothing to serve, which the
// schema refuses — so draft omits `static` for that (rare, malformed) box
// rather than writing a file that throws on load.
it("does not emit static:true when the box has is_static but no publish_directory", () => {
const p = project({
resources: [
{
kind: "application",
name: "Odd",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://odd.example.com",
is_static: true,
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
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("cast-draft-");
const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? "");
// The whole point: it loads (does not throw), and simply carries no `static`.
const build = loadManifest(path).environments.prod.applications.Odd.build;
expect(build).not.toHaveProperty("static");
});
// #70: Coolify 4.1.2 never returns is_static on any read (it lives on the
// ApplicationSetting relation, cast#68), so on that Coolify the key is ABSENT
// from the raw payload and the draft cannot know whether the box is a static
// site. UNCAPTURED.md is the draft's honesty contract (#27): when the app
// even looks static, the unreadable flag must be NAMED there — otherwise a
// reviewer has no cue the field exists to lose, and the #63 crash (static
// site rebuilt as a plain app) re-enters through the draft door.
it("names is_static in UNCAPTURED.md when the live read cannot see it and the app looks static", () => {
const p = project({
resources: [
{
kind: "application",
name: "Landing",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
publish_directory: "/apps/landing-site/dist",
fqdn: "https://landing.example.com",
// no is_static at all — Coolify 4.1.2's read path
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
const item = plan.uncaptured.find((u) => u.setting === "is_static");
expect(item).toBeDefined();
expect(item?.resource).toBe("Landing");
// The entry tells the reviewer where the truth lives: the Coolify UI.
expect(item?.detail).toContain("Coolify UI");
expect(item?.detail).toContain("static: true");
// And it reaches the page a reviewer actually reads.
const uncap = plan.files.find((f) => f.path.endsWith("UNCAPTURED.md"));
expect(uncap?.content).toContain("is_static");
// The manifest itself stays silent — absent is not `true`, and a guessed
// `static: true` would be exactly the fabrication UNCAPTURED.md exists to
// prevent.
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
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("cast-draft-");
const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? "");
const build =
loadManifest(path).environments.prod.applications.Landing.build;
expect(build).not.toHaveProperty("static");
});
// The flag is only worth a reviewer's attention when the app is PLAUSIBLY
// static — nixpacks/static pack serving a publish_directory (the same
// heuristic as #70). An app with nothing to serve statically gets no entry;
// flagging every application would bury the page in noise.
it("does not flag is_static for an app that does not look static", () => {
const p = project({
resources: [
{
kind: "application",
name: "Api",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://api.example.com",
// no publish_directory, no is_static
},
env: {},
},
{
kind: "application",
name: "Built",
uuid: "a2",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "dockerfile",
base_directory: "/",
publish_directory: "/dist",
fqdn: "https://built.example.com",
// dockerfile pack — Coolify's static toggle is a buildpack concept
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
expect(plan.uncaptured.some((u) => u.setting === "is_static")).toBe(false);
});
// A future Coolify that DOES serialize is_static gets the old behavior
// untouched: a real boolean is expressed in the manifest, not flagged.
// (`static: true` emission for is_static: true is covered above; here the
// read said `false`, which is an answer, not an absence.)
it("does not flag is_static when the live read answered false", () => {
const p = project({
resources: [
{
kind: "application",
name: "Plain",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
publish_directory: "/dist",
fqdn: "https://plain.example.com",
is_static: false,
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
expect(plan.uncaptured.some((u) => u.setting === "is_static")).toBe(false);
});
feat: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
it("emits an env template every cast reader can parse", () => {
const plan = planDraft([project()], ctx);
const tpl = plan.files.find((f) => f.path.endsWith(".env.template"));
expect(tpl?.path).toBe(
"incubator/.infra/env/incubator-stack-v2.prod.env.template",
);
const body = tpl?.content ?? "";
expect(templateKeys(body).sort()).toEqual([
"DATABASE_URL",
"MAILGUN_KEY",
"NODE_ENV",
]);
// EVERY var is a ${REF}: values live in the store, never as a literal in a
// file that is about to be committed to a product repo.
expect(
templateRefs(body)
.map((r) => r.ref)
.sort(),
).toEqual(["DATABASE_URL", "MAILGUN_KEY", "NODE_ENV"]);
expect(body).not.toContain(POISON);
expect(body).not.toContain("key-abc123");
});
it("PLACEHOLDS a provider-generated value and captures an ordinary one", () => {
const plan = planDraft([project()], ctx);
const byRef = Object.fromEntries(plan.dispositions.map((d) => [d.ref, d]));
expect(byRef.DATABASE_URL.provenance).toBe("generated");
expect(byRef.DATABASE_URL.value).toBe(GENERATED_PLACEHOLDER);
expect(byRef.MAILGUN_KEY.provenance).toBe("captured");
expect(byRef.MAILGUN_KEY.value).toBe("key-abc123");
// The store carries the placeholder, NOT the source box's Postgres.
const store = plan.stores[0];
expect(store.path).toBe("secrets/incubator.prod.env.age");
expect(store.vars.DATABASE_URL).toBe(GENERATED_PLACEHOLDER);
expect(JSON.stringify(plan.files)).not.toContain(POISON);
});
it("splits one name carrying two values rather than picking", () => {
const p = project();
p.resources.push({
kind: "application",
name: "Landing",
uuid: "a2",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "static",
base_directory: "/",
fqdn: "https://www.example.com",
},
env: { MAILGUN_KEY: "key-DIFFERENT" },
});
const plan = planDraft([p], ctx);
const refs = plan.dispositions.map((d) => d.ref);
// One store holds one value per name (capture refuses a CONFLICT for exactly
// this reason). cast will not pick, so both survive under distinct names.
expect(refs).toContain("INCUBATOR_STACK_V2_MAILGUN_KEY");
expect(refs).toContain("LANDING_MAILGUN_KEY");
expect(refs).not.toContain("MAILGUN_KEY");
expect(
plan.uncaptured.some((u) => u.detail.includes("DIFFERENT values")),
).toBe(true);
});
it("always emits UNCAPTURED.md — even with little to say", () => {
const bare = project({
resources: [
{
kind: "application",
name: "web",
uuid: "a1",
raw: {
git_repository: "git@github.com:acme/web.git",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://web.example.com",
},
env: {},
},
],
});
const md = planDraft([bare], ctx).files.find(
(f) => f.path === "UNCAPTURED.md",
);
expect(md).toBeDefined();
// The standing sections are unconditional: what cast CANNOT SEE does not
// depend on what it happened to find.
expect(md?.content).toContain("no API coverage in Coolify 4.1.2");
expect(md?.content).toContain("Include Source Commit in Build");
expect(md?.content).toContain("the GitHub App private key");
expect(md?.content).toContain("S3 access keys");
});
it("registers what it drafted, and only what it drafted", () => {
const empty = project({
name: "Empty Project",
resources: [],
skipReason: "every environment on it is empty",
});
const bindings = planDraft([project(), empty], ctx).files.find(
(f) => f.path === "environments.yaml",
);
expect(bindings?.content).toContain("heavy-duty/incubator");
expect(bindings?.content).toContain("environments:\n - prod");
// Not registered — a registry entry for a project with no manifest sends
// every future fleet run at nothing.
expect(bindings?.content).not.toContain("Empty Project");
// …but it is not lost either.
const md = planDraft([project(), empty], ctx).files.find(
(f) => f.path === "UNCAPTURED.md",
);
expect(md?.content).toContain("Empty Project");
});
});
fix(draft): read backup schedules and emit backup blocks (#75) --emit-draft still told every reader that backup schedules "are not exposed by Coolify's API" — the exact pre-#51 claim that issue disproved: GET /databases/{uuid}/backups is a route, and diff/apply have read it on every run since. The draft path was never brought along, so it warned instead of reading, and a rebuild from a draft came up with no backups. Now the draft loop makes the same supplementary per-database GET (databaseBackupSchedules) for every DRAFTED database and databaseSpec emits a real backup: { frequency, retention } block for the one shape the manifest can express — a single, enabled schedule. Ungated on purpose: fetchLive's opts.backups gate exists because the read-side sweeps never look at the answer, and the draft is the sweep that does. The read stays sequential (like the existing per-resource env GETs) and a failed read degrades to an UNCAPTURED entry per resource rather than aborting the whole-instance sweep — a draft's reader is a human, not an apply about to write. UNCAPTURED keeps only what the route genuinely cannot answer: - the S3 target: save_s3 now rides on LiveBackup, and a schedule that saves to S3 gets a per-database entry saying the target reads back only as s3_storage_id, an int nothing maps to a storage UUID - a DISABLED schedule (declaring the block would make apply re-enable it) - several schedules where a manifest declares one - an unreadable route (reported, never read as "no backups") The stale NO_API_COVERAGE "backup schedules" row becomes "a backup schedule's S3 target", and semantics.md's draft section now tells the truth about what is captured. Closes #75 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:25:34 +00:00
describe("backup schedules — read and drafted, not hand-waved (#75)", () => {
type Backups = DraftProject["resources"][number]["backups"];
const withDb = (backups: Backups): DraftProject =>
project({
resources: [
...project().resources,
{
kind: "database",
name: "Incubator Database v2",
uuid: "d1",
raw: {
database_type: "standalone-postgresql",
image: "postgres:16-alpine",
},
env: {},
backups,
},
],
});
const schedule = (over: Partial<NonNullable<Backups>[number]> = {}) => ({
uuid: "sched-1",
frequency: "0 3 * * *",
retention: 7,
enabled: true,
saveS3: false,
...over,
});
const loadedDb = (plan: ReturnType<typeof planDraft>) => {
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
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("cast-draft-");
fix(draft): read backup schedules and emit backup blocks (#75) --emit-draft still told every reader that backup schedules "are not exposed by Coolify's API" — the exact pre-#51 claim that issue disproved: GET /databases/{uuid}/backups is a route, and diff/apply have read it on every run since. The draft path was never brought along, so it warned instead of reading, and a rebuild from a draft came up with no backups. Now the draft loop makes the same supplementary per-database GET (databaseBackupSchedules) for every DRAFTED database and databaseSpec emits a real backup: { frequency, retention } block for the one shape the manifest can express — a single, enabled schedule. Ungated on purpose: fetchLive's opts.backups gate exists because the read-side sweeps never look at the answer, and the draft is the sweep that does. The read stays sequential (like the existing per-resource env GETs) and a failed read degrades to an UNCAPTURED entry per resource rather than aborting the whole-instance sweep — a draft's reader is a human, not an apply about to write. UNCAPTURED keeps only what the route genuinely cannot answer: - the S3 target: save_s3 now rides on LiveBackup, and a schedule that saves to S3 gets a per-database entry saying the target reads back only as s3_storage_id, an int nothing maps to a storage UUID - a DISABLED schedule (declaring the block would make apply re-enable it) - several schedules where a manifest declares one - an unreadable route (reported, never read as "no backups") The stale NO_API_COVERAGE "backup schedules" row becomes "a backup schedule's S3 target", and semantics.md's draft section now tells the truth about what is captured. Closes #75 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:25:34 +00:00
const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? "");
return loadManifest(path).environments.prod.databases?.[
"Incubator Database v2"
];
};
const backupItems = (plan: ReturnType<typeof planDraft>) =>
plan.uncaptured.filter((u) => u.setting.startsWith("backup"));
it("emits a real backup block for a single enabled schedule — and nothing uncaptured", () => {
const plan = planDraft([withDb([schedule()])], ctx);
expect(loadedDb(plan)?.backup).toEqual({
frequency: "0 3 * * *",
retention: 7,
});
expect(backupItems(plan)).toEqual([]);
// The stale pre-#51 claim is gone from every artifact.
expect(JSON.stringify(plan.files)).not.toContain("does not yet read");
});
it("reports the S3 target it cannot map when the schedule saves to S3", () => {
const plan = planDraft([withDb([schedule({ saveS3: true })])], ctx);
// The block is still drafted — frequency/retention ARE readable.
expect(loadedDb(plan)?.backup).toEqual({
frequency: "0 3 * * *",
retention: 7,
});
const items = backupItems(plan);
expect(items).toHaveLength(1);
expect(items[0].setting).toBe("backup S3 target");
expect(items[0].detail).toContain("s3_storage_id");
});
it("emits nothing for a clean 'no schedule' read — absence IS the answer", () => {
const plan = planDraft([withDb([])], ctx);
expect(loadedDb(plan)?.backup).toBeUndefined();
expect(backupItems(plan)).toEqual([]);
});
it("does NOT draft a disabled schedule — apply would re-enable it", () => {
const plan = planDraft(
[withDb([schedule({ enabled: false, saveS3: true })])],
ctx,
);
expect(loadedDb(plan)?.backup).toBeUndefined();
const items = backupItems(plan);
expect(items).toHaveLength(1);
expect(items[0].detail).toContain("DISABLED");
expect(items[0].detail).toContain('"0 3 * * *"');
});
it("will not pick between several schedules", () => {
const plan = planDraft(
[withDb([schedule(), schedule({ uuid: "sched-2", frequency: "daily" })])],
ctx,
);
expect(loadedDb(plan)?.backup).toBeUndefined();
const items = backupItems(plan);
expect(items).toHaveLength(1);
expect(items[0].detail).toContain("2 backup schedules");
});
it("reports an unreadable route rather than aborting or claiming 'no backups'", () => {
const plan = planDraft([withDb(undefined)], ctx);
expect(loadedDb(plan)?.backup).toBeUndefined();
const items = backupItems(plan);
expect(items).toHaveLength(1);
expect(items[0].detail).toContain("unreachable");
expect(items[0].resource).toBe("Incubator Database v2");
});
});
feat(draft): capture service hostnames via per-service GET (#83) #73/#81 made a service's per-container hostnames settable (urls) and readable (GET /services/{uuid} -> applications[].fqdn), and diff/apply carry them as service_domains — but the draft path was never brought along: the inventory sweep's environment-list GET does not eager-load service.applications, so --emit-draft emitted every service with no hostnames and an UNCAPTURED hand-wave. Now the draft loop makes the same supplementary per-service GET that diff/apply make (sibling of #75's per-database backups read — one design, both reads: ungated for DRAFTED resources only, sequential, per-resource failure degrades to an UNCAPTURED entry instead of aborting the whole-instance sweep). The projection is SHARED, not duplicated: projectServiceDomains is extracted out of attachServiceDomains and exported, so the draft emits applications[].fqdn through the exact projection + canonicalization (canonicalizeServiceDomains) the diff's read-back uses — a drafted manifest diffs clean the moment it is applied. Its two absences stay distinct: {} is an answer (no hostnames; nothing emitted, nothing reported), undefined is "not read" — attachServiceDomains still fails a one-project diff closed on it, while serviceSpec reports it per resource and keeps sweeping. The stale "service hostnames" NO_API_COVERAGE row and the service_domains (hostnames) always-uncaptured entry are gone, and semantics.md's "does not yet make the per-service GET" line now tells the truth. Closes #83 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:30:49 +00:00
describe("service hostnames — read and drafted via the per-service GET (#83)", () => {
const withService = (
serviceDomains?: Record<string, string[]>,
): DraftProject =>
project({
resources: [
...project().resources,
{
kind: "service",
name: "Incubator Umami",
uuid: "s1",
raw: { service_type: "umami" },
env: {},
serviceDomains,
},
],
});
const loadedSvc = (plan: ReturnType<typeof planDraft>) => {
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
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("cast-draft-");
feat(draft): capture service hostnames via per-service GET (#83) #73/#81 made a service's per-container hostnames settable (urls) and readable (GET /services/{uuid} -> applications[].fqdn), and diff/apply carry them as service_domains — but the draft path was never brought along: the inventory sweep's environment-list GET does not eager-load service.applications, so --emit-draft emitted every service with no hostnames and an UNCAPTURED hand-wave. Now the draft loop makes the same supplementary per-service GET that diff/apply make (sibling of #75's per-database backups read — one design, both reads: ungated for DRAFTED resources only, sequential, per-resource failure degrades to an UNCAPTURED entry instead of aborting the whole-instance sweep). The projection is SHARED, not duplicated: projectServiceDomains is extracted out of attachServiceDomains and exported, so the draft emits applications[].fqdn through the exact projection + canonicalization (canonicalizeServiceDomains) the diff's read-back uses — a drafted manifest diffs clean the moment it is applied. Its two absences stay distinct: {} is an answer (no hostnames; nothing emitted, nothing reported), undefined is "not read" — attachServiceDomains still fails a one-project diff closed on it, while serviceSpec reports it per resource and keeps sweeping. The stale "service hostnames" NO_API_COVERAGE row and the service_domains (hostnames) always-uncaptured entry are gone, and semantics.md's "does not yet make the per-service GET" line now tells the truth. Closes #83 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:30:49 +00:00
const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? "");
return loadManifest(path).environments.prod.services?.["Incubator Umami"];
};
const hostnameItems = (plan: ReturnType<typeof planDraft>) =>
plan.uncaptured.filter((u) => u.setting === "service_domains (hostnames)");
it("emits service_domains as the diff's own projection reads them — and nothing uncaptured", () => {
const plan = planDraft(
[
withService({
umami: ["https://umami.example.com"],
web: ["https://a.example.com", "https://b.example.com"],
}),
],
ctx,
);
expect(loadedSvc(plan)?.service_domains).toEqual({
umami: ["https://umami.example.com"],
web: ["https://a.example.com", "https://b.example.com"],
});
expect(hostnameItems(plan)).toEqual([]);
// The stale "does not yet make the per-service GET" claim is gone from
// every artifact — UNCAPTURED's standing table included.
expect(JSON.stringify(plan.files)).not.toContain("does not yet make");
});
it("emits nothing for a clean 'no hostnames' read — an answer, not a failure", () => {
const plan = planDraft([withService({})], ctx);
expect(loadedSvc(plan)).not.toHaveProperty("service_domains");
expect(hostnameItems(plan)).toEqual([]);
});
it("reports an unreadable per-service GET rather than drafting a blank", () => {
const plan = planDraft([withService(undefined)], ctx);
expect(loadedSvc(plan)).not.toHaveProperty("service_domains");
const items = hostnameItems(plan);
expect(items).toHaveLength(1);
expect(items[0].resource).toBe("Incubator Umami");
expect(items[0].detail).toContain("unreachable");
// The rest of the draft survives: a whole-instance sweep reports one
// unreadable service, it does not abort on it.
expect(loadedSvc(plan)?.type).toBe("umami");
});
});
feat: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
describe("the emit refusals — adoption is one-way", () => {
it("refuses a target directory that is not empty", () => {
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("cast-draft-");
feat: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
writeFileSync(join(dir, "README.md"), "a repo lives here\n");
expect(() => assertEmptyTarget(dir)).toThrow(/is not empty/);
expect(() => assertEmptyTarget(dir)).toThrow(/Adoption is one-way/);
});
it("allows a directory that does not exist yet, and an empty one", () => {
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("cast-draft-");
feat: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
expect(() => assertEmptyTarget(dir)).not.toThrow();
expect(() => assertEmptyTarget(join(dir, "new"))).not.toThrow();
});
it("refuses to write a manifest over one that already exists", () => {
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("cast-draft-");
feat: emit a draft of what a box holds — a proposal, never desired state (#27) `cast inventory` could already see a whole instance (#22). It can now write down what it sees, in the shape of cast's own inputs: cast inventory --env prod --instance box-b --emit-draft ./draft draft/ environments.yaml # bindings as far as they can be read — with the projects: registry (#25) incubator/.infra/manifest.yaml # one per project incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared secrets/<project>.<env>.env.age # encrypted to a recipient you name UNCAPTURED.md # ← the important file Two uses: bootstrapping a project that has no manifest (the third-party sites on the box being drained were never declared, and never will be unless something writes the first draft), and a point-in-time blueprint. A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it: sweep → emit draft → a human reads it → manifest PR → capture → apply Same shape as `terraform import` → HCL, and the boundary is enforced, not merely documented. It never emits into a repo that already has a manifest — for a declared project the manifest IS the truth, and one regenerated from a live box would let that box's accumulated cruft overwrite a reviewed spec, in the one direction nobody reviews. Adoption is one-way. So: a non-empty target refuses, a manifest at the path it would write refuses, and --emit-draft with a repo positional refuses (that is the reconcile path, and it is exactly the case where a draft must not be written). Two things would make a draft actively dangerous, and both are the point: 1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up WORKING, reading and writing the old box's database, and you find out the day the old box is deleted. So the draft applies capture's discipline: a provider-generated name is placeheld with the same GENERATED_PLACEHOLDER literal, its live value is written into no artifact, and the emitted manifest declares it under generated_secrets: so a later capture placeholds it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_* magic vars, and any name carrying a datastore word and a connection word — and it errs wide, because over-matching a real secret is loud and recoverable while under-matching a generated one is silent and is not. Every other var becomes a ${REF} with its value in the age store, never a literal in a committed file: cast cannot know which of a box's vars are secret, and a live key written as a literal is a key in a git repo. 2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run: per resource, every live setting cast saw and could not express — destinations (#21), service hostnames, Basic Auth/Traefik labels, backup schedules, database kinds cast does not model, env names a template cannot hold — plus what no API in 4.1.2 will tell it, and the table of what a blueprint still cannot restore (the GitHub App private key and the S3 keys: re-create by hand). A blueprint that omits these without saying so is worse than no blueprint, because in a disaster you would trust it and rebuild a different box. Secrets are encrypted to a recipient you NAME (--recipient, or the environment's age_recipient binding). With neither, cast refuses rather than quietly emitting a draft that looks complete and holds not one value; --no-secrets says so deliberately. A project with resources in two populated environments is a tie cast will not break — it refuses, and --environment says which, as a tiebreak rather than a filter (filtering by name would drop the client sites, each alone in Coolify's default `production`, out of a blueprint that claims to describe the box). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
mkdirSync(join(dir, ".infra"), { recursive: true });
const path = join(dir, ".infra", "manifest.yaml");
writeFileSync(path, "project: incubator\n");
// For a declared project the manifest IS the truth: regenerating it from a
// live box would let that box's cruft overwrite a reviewed spec.
expect(() => assertNoExistingManifest(path)).toThrow(/already exists/);
expect(() => assertNoExistingManifest(path)).toThrow(
/the manifest IS the truth/,
);
});
});