cast/test/draft-cli.test.ts

629 lines
22 KiB
TypeScript
Raw Normal View History

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 { execFileSync, spawn } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { loadBindings } from "../src/bindings.js";
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
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
import { loadManifest } from "../src/manifest.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
import { decryptSecrets } from "../src/secrets.js";
// `cast inventory --emit-draft` against a stub shaped like the box that made it
// necessary: a Coolify nobody declared, holding our stack under names someone
// typed, two third-party client sites, and a database cast cannot even model.
//
// The single most important assertion in this file is that POISON — the source
// box's real DATABASE_URL — appears in NO emitted artifact. A draft that carried
// it would rebuild a box that comes up WORKING, reading and writing the old
// box's database, and nobody would find out until the old box was deleted.
// The live values. If any of these reaches an emitted file, the test fails.
const POISON =
"postgres://postgres:s3cr3t@incubator-database-v2.box-b.internal:5432/app";
const POISON_REDIS = "redis://:r3d1s@incubator-redis.box-b.internal:6379/0";
const POISON_SERVICE_PW = "umami-generated-9f3a1c";
// …and one that MUST survive: an ordinary secret is captured, not placeheld.
const MAILGUN = "key-1a2b3c-real-mailgun";
type Stub = { url: string; close: () => Promise<void> };
const stubs: Stub[] = [];
const ENVS: Record<string, Array<{ key: string; value: string }>> = {
a1: [
{ key: "DATABASE_URL", value: POISON },
{ key: "REDIS_URL", value: POISON_REDIS },
{ key: "MAILGUN_KEY", value: MAILGUN },
{ key: "NODE_ENV", value: "production" },
// Not a name a cast env template can hold. Reported, never dropped in silence.
{ key: "legacy.flag", value: "on" },
],
s1: [
{ key: "SERVICE_PASSWORD_UMAMI", value: POISON_SERVICE_PW },
{ key: "SERVICE_FQDN_UMAMI", value: "https://umami.box-b.example.com" },
{ key: "UMAMI_APP_SECRET", value: "umami-app-secret-xyz" },
],
a9: [{ key: "WP_HOME", value: "https://lafamilia.example.com" }],
};
// One project with resources in TWO environments — cast must refuse to pick.
async function stubCoolify(opts: { ambiguous?: boolean } = {}): Promise<Stub> {
const server = createServer((req, res) => {
const path = (req.url ?? "").replace("/api/v1", "");
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
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
// GET /github-apps returns each App's `id` and `name` (cast#72). The
// incubator app below carries `source_id: 7`, so the binding RESOLVES to
// this App by the read; the third-party public repo has no GithubApp source
// and gets a REVIEW marker instead of being wrongly bound to it.
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
if (path === "/github-apps")
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
return json([{ id: 7, uuid: "g1", name: "hdb-coolify" }]);
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
if (path === "/projects")
return json([
{ uuid: "p1", name: "Incubator" },
{ uuid: "p2", name: "La Familia Site" },
{ uuid: "p3", name: "Martin Reyes Barber Shop" },
]);
if (path === "/projects/p1/environments")
return json([{ name: "production" }, { name: "staging" }]);
if (path === "/projects/p2/environments")
return json([{ name: "production" }]);
if (path === "/projects/p3/environments")
return json([{ name: "production" }]);
// Coolify auto-creates `production`. It is empty (unless the ambiguous
// variant puts a resource in it too). Everything real lives in `staging`.
if (path === "/projects/p1/production")
return json(
opts.ambiguous
? { applications: [{ name: "old-core", uuid: "a7", fqdn: "" }] }
: {},
);
if (path === "/projects/p1/staging")
return json({
applications: [
{
name: "Incubator Stack v2",
uuid: "a1",
git_repository: "heavy-duty/incubator",
git_branch: "main",
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
// Cloned by the GitHub App id 7 (hdb-coolify) — source_id/source_type
// survive serialization, so the binding is resolvable (cast#72).
source_id: 7,
source_type: "App\\Models\\GithubApp",
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
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "/docker-compose.yaml",
docker_compose_domains: JSON.stringify([
{ name: "core", domain: "https://app.example.com" },
]),
destination_id: 3,
// Basic Auth lives here, and cast's manifest has no field for it.
custom_labels:
"traefik.http.middlewares.auth.basicauth.users=admin:$2y$05$x",
},
],
postgresqls: [
{
name: "Incubator Database v2",
uuid: "d1",
database_type: "standalone-postgresql",
image: "postgres:16-alpine",
destination_id: 3,
},
],
services: [
{
name: "Incubator Umami",
uuid: "s1",
service_type: "umami",
destination_id: 3,
},
],
// A database cast's manifest cannot express at all.
mysqls: [{ name: "legacy-analytics", uuid: "m1" }],
});
if (path === "/projects/p2/production")
return json({
applications: [
{
name: "lafamilia-web",
uuid: "a9",
git_repository: "https://github.com/third-party/la-familia.git",
git_branch: "main",
build_pack: "static",
base_directory: "/",
publish_directory: "/dist",
fqdn: "https://lafamilia.example.com",
destination_id: 4,
},
],
});
// A project with no application at all: nothing on the box knows its repo.
if (path === "/projects/p3/production")
return json({
services: [
{ name: "barber-site", uuid: "s9", service_type: "wordpress" },
],
});
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
// The draft's supplementary per-database GET (#75) — the same route
// diff/apply read (#51). One enabled schedule, saving to S3: the block is
// draftable, the S3 TARGET (s3_storage_id, an unmappable int) is not.
if (path === "/databases/d1/backups")
return json([
{
uuid: "sched-1",
enabled: true,
save_s3: true,
frequency: "0 3 * * *",
database_backup_retention_amount_locally: 7,
s3_storage_id: 2,
executions: [],
},
]);
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 envs = path.match(/^\/[a-z]+\/([a-z0-9]+)\/envs$/);
if (envs) return json(ENVS[envs[1]] ?? []);
res.writeHead(404);
res.end("{}");
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
let KEY_FILE = "";
let RECIPIENT = "";
beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
KEY_FILE = join(dir, "key.txt");
execFileSync("age-keygen", ["-o", KEY_FILE], { stdio: "ignore" });
RECIPIENT = execFileSync("age-keygen", ["-y", KEY_FILE], {
encoding: "utf8",
}).trim();
});
function fixture(url: string, opts: { recipient?: string } = {}) {
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
writeFileSync(
join(state, "environments.yaml"),
[
"environments:",
" prod:",
" server: box-b",
" team: { id: 0, name: Root Team }",
...(opts.recipient ? [` age_recipient: ${opts.recipient}`] : []),
"github_apps:",
" heavy-duty/incubator: hdb-coolify",
"",
].join("\n"),
);
const out = join(mkdtempSync(join(tmpdir(), "cast-out-")), "draft");
return { state, out };
}
function run(args: string[]): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", "inventory", ...args], {
stdio: ["pipe", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.stdin.end();
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
// Every file in the emitted tree, path -> bytes as text.
function tree(dir: string, prefix = ""): Record<string, string> {
const out: Record<string, string> = {};
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) Object.assign(out, tree(path, rel));
else out[rel] = readFileSync(path, "utf8");
}
return out;
}
describe("cast inventory --emit-draft (#27)", () => {
it("emits the tree: bindings + registry, a manifest per project, templates, stores, UNCAPTURED", async () => {
const f = fixture((await stubCoolify()).url);
const r = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--recipient",
RECIPIENT,
]);
expect(r.code).toBe(0);
expect(Object.keys(tree(f.out)).sort()).toEqual([
"UNCAPTURED.md",
"environments.yaml",
// One per project — INCLUDING the two third-party client sites nobody ever
// declared. They each sit alone in a Coolify-default `production`, and a
// draft that filtered the instance by one environment name would drop
// exactly them: the projects the verb exists to bootstrap.
"incubator/.infra/env/incubator-stack-v2.prod.env.template",
"incubator/.infra/env/incubator-umami.prod.env.template",
"incubator/.infra/manifest.yaml",
"la-familia/.infra/env/lafamilia-web.prod.env.template",
"la-familia/.infra/manifest.yaml",
"martin-reyes-barber-shop/.infra/manifest.yaml",
"secrets/incubator.prod.env.age",
"secrets/la-familia.prod.env.age",
]);
// Every emitted file says what it is, in its own body.
for (const [path, body] of Object.entries(tree(f.out))) {
if (path.endsWith(".age")) continue;
expect(body, path).toContain("PROPOSAL");
expect(body, path).toContain("apply` does not read");
}
});
it("NEVER copies a provider-generated value — not into any artifact", async () => {
const f = fixture((await stubCoolify()).url);
const r = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--recipient",
RECIPIENT,
]);
expect(r.code).toBe(0);
// 1. Not in any emitted FILE.
for (const [path, body] of Object.entries(tree(f.out))) {
expect(body, path).not.toContain(POISON);
expect(body, path).not.toContain(POISON_REDIS);
expect(body, path).not.toContain(POISON_SERVICE_PW);
}
// 2. Not in the age store either, once decrypted — the store is the one place
// a copied value would actually be, and the one place you cannot grep.
const store = decryptSecrets(
join(f.out, "secrets", "incubator.prod.env.age"),
KEY_FILE,
);
expect(store.DATABASE_URL).toBe(GENERATED_PLACEHOLDER);
expect(store.REDIS_URL).toBe(GENERATED_PLACEHOLDER);
expect(store.SERVICE_PASSWORD_UMAMI).toBe(GENERATED_PLACEHOLDER);
expect(store.SERVICE_FQDN_UMAMI).toBe(GENERATED_PLACEHOLDER);
expect(Object.values(store)).not.toContain(POISON);
// 3. Not on stdout.
expect(r.output).not.toContain(POISON);
// …and an ORDINARY secret IS carried. The discipline is placeholding the
// provider's names, not refusing to capture anything.
expect(store.MAILGUN_KEY).toBe(MAILGUN);
expect(store.UMAMI_APP_SECRET).toBe("umami-app-secret-xyz");
// The manifest declares the placeheld names, so a later `capture` placeholds
// them again with no flag to remember.
const manifest = readFileSync(
join(f.out, "incubator", ".infra", "manifest.yaml"),
"utf8",
);
expect(manifest).toContain("generated_secrets:");
expect(manifest).toContain("- DATABASE_URL");
expect(manifest).toContain("- REDIS_URL");
// The plan says so out loud, in names and provenance — never values.
expect(r.output).toContain("generated");
expect(r.output).toContain(GENERATED_PLACEHOLDER);
expect(r.output).toContain("captured");
});
it("UNCAPTURED.md lists every known-inexpressible setting it SAW", async () => {
const f = fixture((await stubCoolify()).url);
const r = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--recipient",
RECIPIENT,
]);
expect(r.code).toBe(0);
const md = readFileSync(join(f.out, "UNCAPTURED.md"), "utf8");
// Seen on the box, and inexpressible:
expect(md).toContain("Incubator Umami"); // service hostnames (#21 / 4.1.2)
expect(md).toContain("domains (hostnames)");
expect(md).toContain("destination"); // which Docker network (#21)
expect(md).toContain("destination_id 3");
expect(md).toContain("legacy-analytics"); // a MySQL cast cannot model
expect(md).toContain("custom_labels"); // Basic Auth / Traefik labels
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
// The schedule itself is CAPTURED now (#75); what stays uncaptured is the
// S3 target, which reads back only as an unmappable s3_storage_id int.
expect(md).toContain("backup S3 target");
expect(md).not.toContain("does not yet read"); // the stale pre-#51 claim
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(md).toContain("legacy.flag"); // not a name a template can hold
// And the standing sections, emitted on every run whatever was found:
expect(md).toContain("no API coverage in Coolify 4.1.2");
expect(md).toContain("Include Source Commit in Build");
expect(md).toContain("What a rebuild from this draft still cannot restore");
expect(md).toContain("the GitHub App private key");
expect(md).toContain("S3 access keys");
// The run points at it rather than leaving it to be found.
expect(r.output).toContain("UNCAPTURED.md");
});
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
it("reads the backup schedule and emits a real backup block (#75)", async () => {
const f = fixture((await stubCoolify()).url);
const r = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--recipient",
RECIPIENT,
]);
expect(r.code).toBe(0);
// The drafted manifest carries the live schedule, in the exact shape the
// desired side declares — so it diffs clean the moment it is applied.
const manifest = loadManifest(
join(f.out, "incubator", ".infra", "manifest.yaml"),
);
expect(
manifest.environments.prod.databases?.["Incubator Database v2"]?.backup,
).toEqual({ frequency: "0 3 * * *", retention: 7 });
});
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("writes the projects: registry — the list of what exists", async () => {
const f = fixture((await stubCoolify()).url);
await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--recipient",
RECIPIENT,
]);
const yaml = readFileSync(join(f.out, "environments.yaml"), "utf8");
expect(yaml).toContain("projects:");
// Read off the application's git remote — the only place a box knows its repo.
expect(yaml).toContain("third-party/la-familia:");
expect(yaml).toContain("environments:\n - prod");
// The bindings the sweep could actually read.
expect(yaml).toContain("server: box-b");
expect(yaml).toContain("name: Root Team");
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
// Which GitHub App clones a repo is READ from the application's source_id,
// not guessed (cast#72): the incubator app's source_id: 7 resolves to
// hdb-coolify, while the third-party PUBLIC repo (no GithubApp source) gets a
// REVIEW marker rather than being wrongly bound to the only App there is.
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(yaml).toContain("github_apps:");
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
expect(yaml).toContain("heavy-duty/incubator: hdb-coolify");
expect(yaml).toMatch(
/third-party\/la-familia: REVIEW-which-github-app-in-coolify-clones-this-repo/,
);
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
// The barber shop has no application, so the box knows no repo for it. cast
// writes the bare project name — and the registry's own parse-time refusal
// (#25: "a registry key has no meaning without its org") then stops the file
// being used until a human supplies the org. That refusal IS the design: the
// alternatives are inventing an org, or dropping a project from the list —
// and a project missing from the registry is one every fleet run skips in
// silence.
expect(yaml).toContain("martin-reyes-barber-shop:");
const path = join(f.out, "environments.yaml");
expect(() => loadBindings(path)).toThrow(/has no meaning without its org/);
});
it("refuses --emit-draft together with a repo — that is the reconcile path", async () => {
const f = fixture((await stubCoolify()).url);
const r = await run([
"heavy-duty/incubator",
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
]);
expect(r.code).toBe(2);
expect(r.output).toContain("SWEEP-mode flag");
expect(r.output).toContain("Adoption is one-way");
expect(existsSync(f.out)).toBe(false);
});
it("refuses a target directory that already holds a repo", async () => {
const f = fixture((await stubCoolify()).url);
mkdirSync(join(f.out, ".infra"), { recursive: true });
writeFileSync(
join(f.out, ".infra", "manifest.yaml"),
"project: incubator\nenvironments: {}\n",
);
const r = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--recipient",
RECIPIENT,
]);
expect(r.code).toBe(2);
expect(r.output).toContain("is not empty");
// For a declared project the manifest is the truth — a draft must not be able
// to overwrite a reviewed spec with a live box's accumulated cruft.
expect(r.output).toContain("Adoption is one-way");
expect(readFileSync(join(f.out, ".infra", "manifest.yaml"), "utf8")).toBe(
"project: incubator\nenvironments: {}\n",
);
});
it("refuses to emit secrets nobody can decrypt — and takes an explicit opt-out", async () => {
const f = fixture((await stubCoolify()).url);
const refused = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
]);
// No --recipient, no age_recipient binding. Silently skipping the store would
// emit a draft that LOOKS complete and holds not one value.
expect(refused.code).toBe(2);
expect(refused.output).toContain("no age recipient");
expect(refused.output).toContain("--no-secrets");
expect(existsSync(f.out)).toBe(false);
const optOut = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--no-secrets",
]);
expect(optOut.code).toBe(0);
expect(existsSync(join(f.out, "secrets"))).toBe(false);
expect(
existsSync(join(f.out, "incubator", ".infra", "manifest.yaml")),
).toBe(true);
expect(optOut.output).toContain("NOT WRITTEN");
});
it("takes the recipient from the environment's binding when no flag is given", async () => {
const f = fixture((await stubCoolify()).url, { recipient: RECIPIENT });
const r = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
]);
expect(r.code).toBe(0);
const store = decryptSecrets(
join(f.out, "secrets", "incubator.prod.env.age"),
KEY_FILE,
);
expect(store.MAILGUN_KEY).toBe(MAILGUN);
});
it("refuses to pick between two environments that both have resources", async () => {
const f = fixture((await stubCoolify({ ambiguous: true })).url);
const r = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--recipient",
RECIPIENT,
]);
// Picking would emit a blueprint of half a box that says nothing about the
// other half — and this box is where that already happened once (#22).
expect(r.code).toBe(2);
expect(r.output).toContain("MORE THAN ONE environment");
expect(r.output).toContain("Incubator");
expect(r.output).toContain("--environment");
expect(existsSync(f.out)).toBe(false);
// …and --environment breaks the tie. For the projects that HAVE no tie (the
// client sites, each alone in its own `production`), it changes nothing: they
// are drafted either way.
const tied = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--recipient",
RECIPIENT,
"--environment",
"staging",
]);
expect(tied.code).toBe(0);
const files = Object.keys(tree(f.out));
expect(files).toContain("incubator/.infra/manifest.yaml");
expect(files).toContain("la-familia/.infra/manifest.yaml");
// The environment it did NOT draft is named, not dropped in silence.
expect(readFileSync(join(f.out, "UNCAPTURED.md"), "utf8")).toContain(
"other environments",
);
});
it("still sweeps, and still asserts the team, before it writes anything", async () => {
const f = fixture((await stubCoolify()).url);
writeFileSync(
join(f.state, "environments.yaml"),
[
"environments:",
" prod:",
" server: box-b",
" team: { id: 9, name: Some Other Team }",
"",
].join("\n"),
);
const r = await run([
"--env",
"prod",
"--state",
f.state,
"--emit-draft",
f.out,
"--recipient",
RECIPIENT,
]);
// A wrong-team token reads back an empty instance — and would draft a
// blueprint of nothing at all, confidently.
expect(r.code).not.toBe(0);
expect(existsSync(f.out)).toBe(false);
});
});