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",
|
fix(diff): Coolify's own generated vars are not orphans (#87)
A prod box with zero drift could not make `cast diff` say clean: sixteen
lines of `live-only (orphan var — apply never removes)`, every one of them a
var Coolify MINTED — `SERVICE_FQDN_API` for a compose app's per-container
domains, `SERVICE_PASSWORD_POSTGRES`/`POSTGRES_*` for the one-click umami
service's bundled datastore. They held two resources permanently in `change`.
`remove-candidate` means "a live-only var the manifest does not declare;
apply never removes it; read it by eye". For a name cast did not put there,
cannot declare in any vocabulary, and will never remove, that is a category
error — and a report that can never say clean is how an operator learns to
stop reading it. #78's own Impact section made the argument: "an operator who
learns these always show change stops trusting the diff."
cast already knew: draft.ts has held this exact judgment since #27 and used
it to refuse copying these into a draft. diffEnv just never asked. So the
vocabulary moves to reserved.ts — which already owns "names the platform, not
the manifest, controls" — and both callers consult it.
TWO WIDTHS, deliberately, because over-matching is safe in a draft and unsafe
in a diff:
- draft (WIDE): over-matching withholds a value for review — loud and
recoverable. Under-matching copies the source box's DATABASE_URL into a
new box that boots against the OLD box's database. It errs wide.
- diff, applications (NARROW): over-matching HIDES a live-only var. A
hand-left DATABASE_URL still pointing at a box nobody declares is the one
orphan most worth printing — and it matches the wide rule. Probed against
prod: the wide bucket on a real application held DATABASE_URL and
REDIS_URL, both of them cast's OWN declared vars.
- diff, services (WIDE): a Coolify service is a vendored bundle whose
internals cast does not model — `type` + `service_domains` + an
env_template is the whole vocabulary, and the rest is the template's.
Also fixes a real gap the #87 tests found: the pair-rule missed `POSTGRES_DB`
outright, because [POSTGRES, DB] is datastore + datastore with no connection
word. A db NAME is a connection coordinate like any other, so `DB` joins them
— it is exactly the var a one-click service mints for its bundled Postgres.
And corrects LiveEnvVar's comment: it still cited #79's "stale real_value, a
stored column Coolify does not refresh". That was false — an accessor cannot
go stale, and real_value tracks value on every row of a real box. The split
is still right (real_value is an ESCAPED rendering: 'true' is not true); only
its motivation was wrong. The drift it chased was #85's preview shadow.
Tests: an application carrying only SERVICE_* reads clean; a hand-left
DATABASE_URL on an application is STILL reported; a service carrying the
one-click template's wiring reads clean; a non-generated live-only var on a
service is STILL reported.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:56:10 +00:00
|
|
|
// The db NAME is a connection coordinate too — [POSTGRES, DB] is datastore
|
|
|
|
|
// + datastore, so the pair-rule missed it until `DB` joined the connection
|
|
|
|
|
// words. It is exactly what a one-click service mints for its bundled
|
|
|
|
|
// Postgres (#87).
|
|
|
|
|
"POSTGRES_DB",
|
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
|
|
|
"DB_HOST",
|
|
|
|
|
"MONGO_URI",
|
|
|
|
|
]) {
|
|
|
|
|
expect(isProviderGenerated(key), key).toBe(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("recognizes Coolify's own per-instance magic vars", () => {
|
|
|
|
|
for (const key of [
|
|
|
|
|
"SERVICE_FQDN_UMAMI",
|
|
|
|
|
"SERVICE_URL_UMAMI",
|
|
|
|
|
"SERVICE_PASSWORD_POSTGRES",
|
|
|
|
|
"SERVICE_USER_UMAMI",
|
|
|
|
|
"SERVICE_BASE64_KEY",
|
|
|
|
|
]) {
|
|
|
|
|
expect(isProviderGenerated(key), key).toBe(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("leaves an ordinary secret alone — it is captured, not placeheld", () => {
|
|
|
|
|
for (const key of [
|
|
|
|
|
"MAILGUN_KEY",
|
|
|
|
|
"OPENROUTER_KEY",
|
|
|
|
|
"ADMIN_EMAIL",
|
|
|
|
|
"NODE_ENV",
|
|
|
|
|
"SERVICE_NAME",
|
|
|
|
|
"PORT",
|
|
|
|
|
]) {
|
|
|
|
|
expect(isProviderGenerated(key), key).toBe(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("repoFromGitUrl — the only place a box knows which repo it is", () => {
|
|
|
|
|
it("reads a slug out of every remote shape Coolify stores", () => {
|
|
|
|
|
expect(repoFromGitUrl("https://github.com/heavy-duty/incubator")).toBe(
|
|
|
|
|
"heavy-duty/incubator",
|
|
|
|
|
);
|
|
|
|
|
expect(repoFromGitUrl("https://github.com/heavy-duty/incubator.git")).toBe(
|
|
|
|
|
"heavy-duty/incubator",
|
|
|
|
|
);
|
|
|
|
|
expect(repoFromGitUrl("git@github.com:heavy-duty/incubator.git")).toBe(
|
|
|
|
|
"heavy-duty/incubator",
|
|
|
|
|
);
|
|
|
|
|
expect(repoFromGitUrl("heavy-duty/incubator")).toBe("heavy-duty/incubator");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("answers undefined rather than guessing", () => {
|
|
|
|
|
expect(repoFromGitUrl("")).toBeUndefined();
|
|
|
|
|
expect(repoFromGitUrl(undefined)).toBeUndefined();
|
|
|
|
|
expect(repoFromGitUrl("not-a-remote")).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("draftResourcesFrom — including what cast cannot model", () => {
|
|
|
|
|
it("names a MySQL rather than silently omitting it", () => {
|
|
|
|
|
const { resources, unreadable } = draftResourcesFrom({
|
|
|
|
|
applications: [{ name: "web", uuid: "a1" }],
|
|
|
|
|
postgresqls: [{ name: "db", uuid: "d1" }],
|
|
|
|
|
redis: [{ name: "cache", uuid: "d2" }],
|
|
|
|
|
services: [{ name: "umami", uuid: "s1" }],
|
|
|
|
|
mysqls: [{ name: "legacy-mysql", uuid: "m1" }],
|
|
|
|
|
mongodbs: [{ name: "old-mongo", uuid: "m2" }],
|
|
|
|
|
});
|
|
|
|
|
expect(resources.map((r) => [r.kind, r.name])).toEqual([
|
|
|
|
|
["application", "web"],
|
|
|
|
|
["database", "db"],
|
|
|
|
|
["database", "cache"],
|
|
|
|
|
["service", "umami"],
|
|
|
|
|
]);
|
|
|
|
|
expect(unreadable).toEqual([
|
|
|
|
|
{ kind: "mysql", name: "legacy-mysql" },
|
|
|
|
|
{ kind: "mongodb", name: "old-mongo" },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("planDraft — the emitted shape", () => {
|
|
|
|
|
it("writes a manifest cast itself can load, keyed by --env", () => {
|
|
|
|
|
const plan = planDraft([project()], ctx);
|
|
|
|
|
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
|
|
|
|
expect(manifest?.path).toBe("incubator/.infra/manifest.yaml");
|
|
|
|
|
// A file that says what it is. It leaves this process and is read by someone
|
|
|
|
|
// deciding whether to trust it.
|
|
|
|
|
expect(manifest?.content).toContain("PROPOSAL");
|
|
|
|
|
expect(manifest?.content).toContain("`apply` does not read this file");
|
|
|
|
|
expect(manifest?.content).toContain("box-b");
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
|
fix(draft): name is_static in UNCAPTURED.md when the live read cannot see it (#70)
Coolify 4.1.2 never serializes is_static on any read path — it lives on
the ApplicationSetting relation, which no read endpoint loads (cast#68).
diff already degrades the field honestly (staticNotCompared, warn-and-
skip, #69), but the draft path did not: applicationSpec emits
`static: true` only for a present truthy raw.is_static, so on 4.1.2 the
key is simply absent, the drafted manifest of a live static site
silently omits the flag, and UNCAPTURED.md said nothing. That breaks
the draft's own contract (#27) — a reviewer approving the draft has no
cue the field even exists to lose, and the #63 failure mode (static
site rebuilt and run as a plain app) re-enters through the draft door.
Now, when raw.is_static is absent/null (the same predicate the diff
path's staticNotCompared uses) and the app is plausibly static — a
nixpacks/static build pack with a publish_directory — the draft flags
is_static in UNCAPTURED.md as unreadable on this Coolify, telling the
reviewer to check the box in the UI and add `static: true` by hand if
set. A real boolean (a future Coolify) behaves exactly as before:
expressed in the manifest, never flagged.
Part of #70; the remaining items there are blocked on Coolify v4.2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:20:59 +00:00
|
|
|
// #70: Coolify 4.1.2 never returns is_static on any read (it lives on the
|
|
|
|
|
// ApplicationSetting relation, cast#68), so on that Coolify the key is ABSENT
|
|
|
|
|
// from the raw payload and the draft cannot know whether the box is a static
|
|
|
|
|
// site. UNCAPTURED.md is the draft's honesty contract (#27): when the app
|
|
|
|
|
// even looks static, the unreadable flag must be NAMED there — otherwise a
|
|
|
|
|
// reviewer has no cue the field exists to lose, and the #63 crash (static
|
|
|
|
|
// site rebuilt as a plain app) re-enters through the draft door.
|
|
|
|
|
it("names is_static in UNCAPTURED.md when the live read cannot see it and the app looks static", () => {
|
|
|
|
|
const p = project({
|
|
|
|
|
resources: [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "Landing",
|
|
|
|
|
uuid: "a1",
|
|
|
|
|
raw: {
|
|
|
|
|
git_repository: "https://github.com/heavy-duty/incubator",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
publish_directory: "/apps/landing-site/dist",
|
|
|
|
|
fqdn: "https://landing.example.com",
|
|
|
|
|
// no is_static at all — Coolify 4.1.2's read path
|
|
|
|
|
},
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
const plan = planDraft([p], ctx);
|
|
|
|
|
const item = plan.uncaptured.find((u) => u.setting === "is_static");
|
|
|
|
|
expect(item).toBeDefined();
|
|
|
|
|
expect(item?.resource).toBe("Landing");
|
|
|
|
|
// The entry tells the reviewer where the truth lives: the Coolify UI.
|
|
|
|
|
expect(item?.detail).toContain("Coolify UI");
|
|
|
|
|
expect(item?.detail).toContain("static: true");
|
|
|
|
|
// And it reaches the page a reviewer actually reads.
|
|
|
|
|
const uncap = plan.files.find((f) => f.path.endsWith("UNCAPTURED.md"));
|
|
|
|
|
expect(uncap?.content).toContain("is_static");
|
|
|
|
|
// The manifest itself stays silent — absent is not `true`, and a guessed
|
|
|
|
|
// `static: true` would be exactly the fabrication UNCAPTURED.md exists to
|
|
|
|
|
// prevent.
|
|
|
|
|
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
|
|
|
|
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).not.toHaveProperty("static");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The flag is only worth a reviewer's attention when the app is PLAUSIBLY
|
|
|
|
|
// static — nixpacks/static pack serving a publish_directory (the same
|
|
|
|
|
// heuristic as #70). An app with nothing to serve statically gets no entry;
|
|
|
|
|
// flagging every application would bury the page in noise.
|
|
|
|
|
it("does not flag is_static for an app that does not look static", () => {
|
|
|
|
|
const p = project({
|
|
|
|
|
resources: [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "Api",
|
|
|
|
|
uuid: "a1",
|
|
|
|
|
raw: {
|
|
|
|
|
git_repository: "https://github.com/heavy-duty/incubator",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
fqdn: "https://api.example.com",
|
|
|
|
|
// no publish_directory, no is_static
|
|
|
|
|
},
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "Built",
|
|
|
|
|
uuid: "a2",
|
|
|
|
|
raw: {
|
|
|
|
|
git_repository: "https://github.com/heavy-duty/incubator",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "dockerfile",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
publish_directory: "/dist",
|
|
|
|
|
fqdn: "https://built.example.com",
|
|
|
|
|
// dockerfile pack — Coolify's static toggle is a buildpack concept
|
|
|
|
|
},
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
const plan = planDraft([p], ctx);
|
|
|
|
|
expect(plan.uncaptured.some((u) => u.setting === "is_static")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A future Coolify that DOES serialize is_static gets the old behavior
|
|
|
|
|
// untouched: a real boolean is expressed in the manifest, not flagged.
|
|
|
|
|
// (`static: true` emission for is_static: true is covered above; here the
|
|
|
|
|
// read said `false`, which is an answer, not an absence.)
|
|
|
|
|
it("does not flag is_static when the live read answered false", () => {
|
|
|
|
|
const p = project({
|
|
|
|
|
resources: [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "Plain",
|
|
|
|
|
uuid: "a1",
|
|
|
|
|
raw: {
|
|
|
|
|
git_repository: "https://github.com/heavy-duty/incubator",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
publish_directory: "/dist",
|
|
|
|
|
fqdn: "https://plain.example.com",
|
|
|
|
|
is_static: false,
|
|
|
|
|
},
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
const plan = planDraft([p], ctx);
|
|
|
|
|
expect(plan.uncaptured.some((u) => u.setting === "is_static")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
|
|
|
it("emits an env template every cast reader can parse", () => {
|
|
|
|
|
const plan = planDraft([project()], ctx);
|
|
|
|
|
const tpl = plan.files.find((f) => f.path.endsWith(".env.template"));
|
|
|
|
|
expect(tpl?.path).toBe(
|
|
|
|
|
"incubator/.infra/env/incubator-stack-v2.prod.env.template",
|
|
|
|
|
);
|
|
|
|
|
const body = tpl?.content ?? "";
|
|
|
|
|
expect(templateKeys(body).sort()).toEqual([
|
|
|
|
|
"DATABASE_URL",
|
|
|
|
|
"MAILGUN_KEY",
|
|
|
|
|
"NODE_ENV",
|
|
|
|
|
]);
|
|
|
|
|
// EVERY var is a ${REF}: values live in the store, never as a literal in a
|
|
|
|
|
// file that is about to be committed to a product repo.
|
|
|
|
|
expect(
|
|
|
|
|
templateRefs(body)
|
|
|
|
|
.map((r) => r.ref)
|
|
|
|
|
.sort(),
|
|
|
|
|
).toEqual(["DATABASE_URL", "MAILGUN_KEY", "NODE_ENV"]);
|
|
|
|
|
expect(body).not.toContain(POISON);
|
|
|
|
|
expect(body).not.toContain("key-abc123");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("PLACEHOLDS a provider-generated value and captures an ordinary one", () => {
|
|
|
|
|
const plan = planDraft([project()], ctx);
|
|
|
|
|
const byRef = Object.fromEntries(plan.dispositions.map((d) => [d.ref, d]));
|
|
|
|
|
expect(byRef.DATABASE_URL.provenance).toBe("generated");
|
|
|
|
|
expect(byRef.DATABASE_URL.value).toBe(GENERATED_PLACEHOLDER);
|
|
|
|
|
expect(byRef.MAILGUN_KEY.provenance).toBe("captured");
|
|
|
|
|
expect(byRef.MAILGUN_KEY.value).toBe("key-abc123");
|
|
|
|
|
|
|
|
|
|
// The store carries the placeholder, NOT the source box's Postgres.
|
|
|
|
|
const store = plan.stores[0];
|
|
|
|
|
expect(store.path).toBe("secrets/incubator.prod.env.age");
|
|
|
|
|
expect(store.vars.DATABASE_URL).toBe(GENERATED_PLACEHOLDER);
|
|
|
|
|
expect(JSON.stringify(plan.files)).not.toContain(POISON);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("splits one name carrying two values rather than picking", () => {
|
|
|
|
|
const p = project();
|
|
|
|
|
p.resources.push({
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "Landing",
|
|
|
|
|
uuid: "a2",
|
|
|
|
|
raw: {
|
|
|
|
|
git_repository: "https://github.com/heavy-duty/incubator",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "static",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
fqdn: "https://www.example.com",
|
|
|
|
|
},
|
|
|
|
|
env: { MAILGUN_KEY: "key-DIFFERENT" },
|
|
|
|
|
});
|
|
|
|
|
const plan = planDraft([p], ctx);
|
|
|
|
|
const refs = plan.dispositions.map((d) => d.ref);
|
|
|
|
|
// One store holds one value per name (capture refuses a CONFLICT for exactly
|
|
|
|
|
// this reason). cast will not pick, so both survive under distinct names.
|
|
|
|
|
expect(refs).toContain("INCUBATOR_STACK_V2_MAILGUN_KEY");
|
|
|
|
|
expect(refs).toContain("LANDING_MAILGUN_KEY");
|
|
|
|
|
expect(refs).not.toContain("MAILGUN_KEY");
|
|
|
|
|
expect(
|
|
|
|
|
plan.uncaptured.some((u) => u.detail.includes("DIFFERENT values")),
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("always emits UNCAPTURED.md — even with little to say", () => {
|
|
|
|
|
const bare = project({
|
|
|
|
|
resources: [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "web",
|
|
|
|
|
uuid: "a1",
|
|
|
|
|
raw: {
|
|
|
|
|
git_repository: "git@github.com:acme/web.git",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
fqdn: "https://web.example.com",
|
|
|
|
|
},
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
const md = planDraft([bare], ctx).files.find(
|
|
|
|
|
(f) => f.path === "UNCAPTURED.md",
|
|
|
|
|
);
|
|
|
|
|
expect(md).toBeDefined();
|
|
|
|
|
// The standing sections are unconditional: what cast CANNOT SEE does not
|
|
|
|
|
// depend on what it happened to find.
|
|
|
|
|
expect(md?.content).toContain("no API coverage in Coolify 4.1.2");
|
|
|
|
|
expect(md?.content).toContain("Include Source Commit in Build");
|
|
|
|
|
expect(md?.content).toContain("the GitHub App private key");
|
|
|
|
|
expect(md?.content).toContain("S3 access keys");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("registers what it drafted, and only what it drafted", () => {
|
|
|
|
|
const empty = project({
|
|
|
|
|
name: "Empty Project",
|
|
|
|
|
resources: [],
|
|
|
|
|
skipReason: "every environment on it is empty",
|
|
|
|
|
});
|
|
|
|
|
const bindings = planDraft([project(), empty], ctx).files.find(
|
|
|
|
|
(f) => f.path === "environments.yaml",
|
|
|
|
|
);
|
|
|
|
|
expect(bindings?.content).toContain("heavy-duty/incubator");
|
|
|
|
|
expect(bindings?.content).toContain("environments:\n - prod");
|
|
|
|
|
// Not registered — a registry entry for a project with no manifest sends
|
|
|
|
|
// every future fleet run at nothing.
|
|
|
|
|
expect(bindings?.content).not.toContain("Empty Project");
|
|
|
|
|
// …but it is not lost either.
|
|
|
|
|
const md = planDraft([project(), empty], ctx).files.find(
|
|
|
|
|
(f) => f.path === "UNCAPTURED.md",
|
|
|
|
|
);
|
|
|
|
|
expect(md?.content).toContain("Empty Project");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
fix(draft): read backup schedules and emit backup blocks (#75)
--emit-draft still told every reader that backup schedules "are not
exposed by Coolify's API" — the exact pre-#51 claim that issue disproved:
GET /databases/{uuid}/backups is a route, and diff/apply have read it on
every run since. The draft path was never brought along, so it warned
instead of reading, and a rebuild from a draft came up with no backups.
Now the draft loop makes the same supplementary per-database GET
(databaseBackupSchedules) for every DRAFTED database and databaseSpec
emits a real backup: { frequency, retention } block for the one shape
the manifest can express — a single, enabled schedule. Ungated on
purpose: fetchLive's opts.backups gate exists because the read-side
sweeps never look at the answer, and the draft is the sweep that does.
The read stays sequential (like the existing per-resource env GETs) and
a failed read degrades to an UNCAPTURED entry per resource rather than
aborting the whole-instance sweep — a draft's reader is a human, not an
apply about to write.
UNCAPTURED keeps only what the route genuinely cannot answer:
- the S3 target: save_s3 now rides on LiveBackup, and a schedule that
saves to S3 gets a per-database entry saying the target reads back
only as s3_storage_id, an int nothing maps to a storage UUID
- a DISABLED schedule (declaring the block would make apply re-enable it)
- several schedules where a manifest declares one
- an unreadable route (reported, never read as "no backups")
The stale NO_API_COVERAGE "backup schedules" row becomes "a backup
schedule's S3 target", and semantics.md's draft section now tells the
truth about what is captured.
Closes #75
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:25:34 +00:00
|
|
|
describe("backup schedules — read and drafted, not hand-waved (#75)", () => {
|
|
|
|
|
type Backups = DraftProject["resources"][number]["backups"];
|
|
|
|
|
const withDb = (backups: Backups): DraftProject =>
|
|
|
|
|
project({
|
|
|
|
|
resources: [
|
|
|
|
|
...project().resources,
|
|
|
|
|
{
|
|
|
|
|
kind: "database",
|
|
|
|
|
name: "Incubator Database v2",
|
|
|
|
|
uuid: "d1",
|
|
|
|
|
raw: {
|
|
|
|
|
database_type: "standalone-postgresql",
|
|
|
|
|
image: "postgres:16-alpine",
|
|
|
|
|
},
|
|
|
|
|
env: {},
|
|
|
|
|
backups,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
const schedule = (over: Partial<NonNullable<Backups>[number]> = {}) => ({
|
|
|
|
|
uuid: "sched-1",
|
|
|
|
|
frequency: "0 3 * * *",
|
|
|
|
|
retention: 7,
|
|
|
|
|
enabled: true,
|
|
|
|
|
saveS3: false,
|
|
|
|
|
...over,
|
|
|
|
|
});
|
|
|
|
|
const loadedDb = (plan: ReturnType<typeof planDraft>) => {
|
|
|
|
|
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
|
|
|
|
const path = join(dir, "manifest.yaml");
|
|
|
|
|
writeFileSync(path, manifest?.content ?? "");
|
|
|
|
|
return loadManifest(path).environments.prod.databases?.[
|
|
|
|
|
"Incubator Database v2"
|
|
|
|
|
];
|
|
|
|
|
};
|
|
|
|
|
const backupItems = (plan: ReturnType<typeof planDraft>) =>
|
|
|
|
|
plan.uncaptured.filter((u) => u.setting.startsWith("backup"));
|
|
|
|
|
|
|
|
|
|
it("emits a real backup block for a single enabled schedule — and nothing uncaptured", () => {
|
|
|
|
|
const plan = planDraft([withDb([schedule()])], ctx);
|
|
|
|
|
expect(loadedDb(plan)?.backup).toEqual({
|
|
|
|
|
frequency: "0 3 * * *",
|
|
|
|
|
retention: 7,
|
|
|
|
|
});
|
|
|
|
|
expect(backupItems(plan)).toEqual([]);
|
|
|
|
|
// The stale pre-#51 claim is gone from every artifact.
|
|
|
|
|
expect(JSON.stringify(plan.files)).not.toContain("does not yet read");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reports the S3 target it cannot map when the schedule saves to S3", () => {
|
|
|
|
|
const plan = planDraft([withDb([schedule({ saveS3: true })])], ctx);
|
|
|
|
|
// The block is still drafted — frequency/retention ARE readable.
|
|
|
|
|
expect(loadedDb(plan)?.backup).toEqual({
|
|
|
|
|
frequency: "0 3 * * *",
|
|
|
|
|
retention: 7,
|
|
|
|
|
});
|
|
|
|
|
const items = backupItems(plan);
|
|
|
|
|
expect(items).toHaveLength(1);
|
|
|
|
|
expect(items[0].setting).toBe("backup S3 target");
|
|
|
|
|
expect(items[0].detail).toContain("s3_storage_id");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("emits nothing for a clean 'no schedule' read — absence IS the answer", () => {
|
|
|
|
|
const plan = planDraft([withDb([])], ctx);
|
|
|
|
|
expect(loadedDb(plan)?.backup).toBeUndefined();
|
|
|
|
|
expect(backupItems(plan)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does NOT draft a disabled schedule — apply would re-enable it", () => {
|
|
|
|
|
const plan = planDraft(
|
|
|
|
|
[withDb([schedule({ enabled: false, saveS3: true })])],
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
expect(loadedDb(plan)?.backup).toBeUndefined();
|
|
|
|
|
const items = backupItems(plan);
|
|
|
|
|
expect(items).toHaveLength(1);
|
|
|
|
|
expect(items[0].detail).toContain("DISABLED");
|
|
|
|
|
expect(items[0].detail).toContain('"0 3 * * *"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("will not pick between several schedules", () => {
|
|
|
|
|
const plan = planDraft(
|
|
|
|
|
[withDb([schedule(), schedule({ uuid: "sched-2", frequency: "daily" })])],
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
expect(loadedDb(plan)?.backup).toBeUndefined();
|
|
|
|
|
const items = backupItems(plan);
|
|
|
|
|
expect(items).toHaveLength(1);
|
|
|
|
|
expect(items[0].detail).toContain("2 backup schedules");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reports an unreadable route rather than aborting or claiming 'no backups'", () => {
|
|
|
|
|
const plan = planDraft([withDb(undefined)], ctx);
|
|
|
|
|
expect(loadedDb(plan)?.backup).toBeUndefined();
|
|
|
|
|
const items = backupItems(plan);
|
|
|
|
|
expect(items).toHaveLength(1);
|
|
|
|
|
expect(items[0].detail).toContain("unreachable");
|
|
|
|
|
expect(items[0].resource).toBe("Incubator Database v2");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(draft): capture service hostnames via per-service GET (#83)
#73/#81 made a service's per-container hostnames settable (urls) and
readable (GET /services/{uuid} -> applications[].fqdn), and diff/apply
carry them as service_domains — but the draft path was never brought
along: the inventory sweep's environment-list GET does not eager-load
service.applications, so --emit-draft emitted every service with no
hostnames and an UNCAPTURED hand-wave.
Now the draft loop makes the same supplementary per-service GET that
diff/apply make (sibling of #75's per-database backups read — one
design, both reads: ungated for DRAFTED resources only, sequential,
per-resource failure degrades to an UNCAPTURED entry instead of
aborting the whole-instance sweep).
The projection is SHARED, not duplicated: projectServiceDomains is
extracted out of attachServiceDomains and exported, so the draft emits
applications[].fqdn through the exact projection + canonicalization
(canonicalizeServiceDomains) the diff's read-back uses — a drafted
manifest diffs clean the moment it is applied. Its two absences stay
distinct: {} is an answer (no hostnames; nothing emitted, nothing
reported), undefined is "not read" — attachServiceDomains still fails
a one-project diff closed on it, while serviceSpec reports it per
resource and keeps sweeping.
The stale "service hostnames" NO_API_COVERAGE row and the
service_domains (hostnames) always-uncaptured entry are gone, and
semantics.md's "does not yet make the per-service GET" line now tells
the truth.
Closes #83
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:30:49 +00:00
|
|
|
describe("service hostnames — read and drafted via the per-service GET (#83)", () => {
|
|
|
|
|
const withService = (
|
|
|
|
|
serviceDomains?: Record<string, string[]>,
|
|
|
|
|
): DraftProject =>
|
|
|
|
|
project({
|
|
|
|
|
resources: [
|
|
|
|
|
...project().resources,
|
|
|
|
|
{
|
|
|
|
|
kind: "service",
|
|
|
|
|
name: "Incubator Umami",
|
|
|
|
|
uuid: "s1",
|
|
|
|
|
raw: { service_type: "umami" },
|
|
|
|
|
env: {},
|
|
|
|
|
serviceDomains,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
const loadedSvc = (plan: ReturnType<typeof planDraft>) => {
|
|
|
|
|
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
|
|
|
|
const path = join(dir, "manifest.yaml");
|
|
|
|
|
writeFileSync(path, manifest?.content ?? "");
|
|
|
|
|
return loadManifest(path).environments.prod.services?.["Incubator Umami"];
|
|
|
|
|
};
|
|
|
|
|
const hostnameItems = (plan: ReturnType<typeof planDraft>) =>
|
|
|
|
|
plan.uncaptured.filter((u) => u.setting === "service_domains (hostnames)");
|
|
|
|
|
|
|
|
|
|
it("emits service_domains as the diff's own projection reads them — and nothing uncaptured", () => {
|
|
|
|
|
const plan = planDraft(
|
|
|
|
|
[
|
|
|
|
|
withService({
|
|
|
|
|
umami: ["https://umami.example.com"],
|
|
|
|
|
web: ["https://a.example.com", "https://b.example.com"],
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
expect(loadedSvc(plan)?.service_domains).toEqual({
|
|
|
|
|
umami: ["https://umami.example.com"],
|
|
|
|
|
web: ["https://a.example.com", "https://b.example.com"],
|
|
|
|
|
});
|
|
|
|
|
expect(hostnameItems(plan)).toEqual([]);
|
|
|
|
|
// The stale "does not yet make the per-service GET" claim is gone from
|
|
|
|
|
// every artifact — UNCAPTURED's standing table included.
|
|
|
|
|
expect(JSON.stringify(plan.files)).not.toContain("does not yet make");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("emits nothing for a clean 'no hostnames' read — an answer, not a failure", () => {
|
|
|
|
|
const plan = planDraft([withService({})], ctx);
|
|
|
|
|
expect(loadedSvc(plan)).not.toHaveProperty("service_domains");
|
|
|
|
|
expect(hostnameItems(plan)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reports an unreadable per-service GET rather than drafting a blank", () => {
|
|
|
|
|
const plan = planDraft([withService(undefined)], ctx);
|
|
|
|
|
expect(loadedSvc(plan)).not.toHaveProperty("service_domains");
|
|
|
|
|
const items = hostnameItems(plan);
|
|
|
|
|
expect(items).toHaveLength(1);
|
|
|
|
|
expect(items[0].resource).toBe("Incubator Umami");
|
|
|
|
|
expect(items[0].detail).toContain("unreachable");
|
|
|
|
|
// The rest of the draft survives: a whole-instance sweep reports one
|
|
|
|
|
// unreadable service, it does not abort on it.
|
|
|
|
|
expect(loadedSvc(plan)?.type).toBe("umami");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
|
|
|
describe("the emit refusals — adoption is one-way", () => {
|
|
|
|
|
it("refuses a target directory that is not empty", () => {
|
|
|
|
|
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/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|