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 { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
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",
|
|
|
|
|
"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");
|
|
|
|
|
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
|
|
|
|
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"]);
|
|
|
|
|
});
|
|
|
|
|
|
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
|
|
|
// #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"));
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "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"));
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "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");
|
|
|
|
|
});
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("the emit refusals — adoption is one-way", () => {
|
|
|
|
|
it("refuses a target directory that is not empty", () => {
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
|
|
|
|
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", () => {
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
|
|
|
|
expect(() => assertEmptyTarget(dir)).not.toThrow();
|
|
|
|
|
expect(() => assertEmptyTarget(join(dir, "new"))).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses to write a manifest over one that already exists", () => {
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
|
|
|
|
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/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|