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 { existsSync , mkdirSync , readdirSync , writeFileSync } from "node:fs" ;
import { dirname , join } from "node:path" ;
import { stringify } from "yaml" ;
import { GENERATED_PLACEHOLDER } from "./capture.js" ;
fix(draft): read backup schedules and emit backup blocks (#75)
--emit-draft still told every reader that backup schedules "are not
exposed by Coolify's API" — the exact pre-#51 claim that issue disproved:
GET /databases/{uuid}/backups is a route, and diff/apply have read it on
every run since. The draft path was never brought along, so it warned
instead of reading, and a rebuild from a draft came up with no backups.
Now the draft loop makes the same supplementary per-database GET
(databaseBackupSchedules) for every DRAFTED database and databaseSpec
emits a real backup: { frequency, retention } block for the one shape
the manifest can express — a single, enabled schedule. Ungated on
purpose: fetchLive's opts.backups gate exists because the read-side
sweeps never look at the answer, and the draft is the sweep that does.
The read stays sequential (like the existing per-resource env GETs) and
a failed read degrades to an UNCAPTURED entry per resource rather than
aborting the whole-instance sweep — a draft's reader is a human, not an
apply about to write.
UNCAPTURED keeps only what the route genuinely cannot answer:
- the S3 target: save_s3 now rides on LiveBackup, and a schedule that
saves to S3 gets a per-database entry saying the target reads back
only as s3_storage_id, an int nothing maps to a storage UUID
- a DISABLED schedule (declaring the block would make apply re-enable it)
- several schedules where a manifest declares one
- an unreadable route (reported, never read as "no backups")
The stale NO_API_COVERAGE "backup schedules" row becomes "a backup
schedule's S3 target", and semantics.md's draft section now tells the
truth about what is captured.
Closes #75
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:25:34 +00:00
import type { BackupRead } from "./coolify.js" ;
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
import {
isProviderGeneratedEnvName ,
isReservedEnvName ,
reservedConsequence ,
} from "./reserved.js" ;
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
import { encryptSecrets } from "./secrets.js" ;
// `inventory` can already SEE a whole instance (#22). This is it writing down
// what it saw, in the shape of cast's own inputs — a manifest per project, an
// env template per resource, a bindings file with the registry, an age store,
// and UNCAPTURED.md.
//
// The verb is only allowed to exist because of one boundary, and everything in
// this file is bent around it:
//
// **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. It is emitted, reviewed, and lands in
// a product repo as a PR; the repo stays the source of truth. Which is why a
// draft is NEVER emitted into a repo that already has a manifest: for a declared
// project the manifest *is* the truth, and regenerating it from a live box would
// let that box's accumulated cruft overwrite the reviewed spec, silently, in the
// one direction nobody reviews. Adoption is one-way.
//
// Two things would make a draft actively dangerous, and they are the two things
// this file spends its length on:
//
// 1. COPIED PROVIDER-GENERATED VALUES. A `DATABASE_URL` read off the source
// points at the SOURCE box's Postgres. Emit it, rebuild elsewhere, and the
// new box comes up WORKING — reading and writing the old box's database. You
// find out the day the old box is deleted. So the draft applies `capture`'s
// discipline (see classify): a provider-generated name is placeheld with the
// same `pending-coolify-generated` literal and listed for disposition, and
// the source's value is not written anywhere. A draft that is confidently
// wrong in four entries out of seventeen is worse than one that is obviously
// incomplete.
//
// 2. SILENT LOSSES. cast cannot express everything a Coolify holds — service
// hostnames, destinations (#21), Basic Auth, build toggles, whole database
// kinds. A blueprint that omits them WITHOUT SAYING SO is worse than no
// blueprint, because in a disaster you would trust it and rebuild a
// *different box*. Hence UNCAPTURED.md, which is emitted on every run, even
// when it has little to say.
// --- Provider-generated names -------------------------------------------------
//
// The single most consequential judgment in this file, and it is made by NAME —
// never by value, and never by "it looks like a URL".
//
// Two families:
//
// 1. Coolify's own magic vars. `SERVICE_FQDN_*`, `SERVICE_URL_*`,
// `SERVICE_PASSWORD_*`, `SERVICE_USER_*`, `SERVICE_BASE64_*` are generated
// per-instance by Coolify when it creates a service, and mean nothing
// anywhere else — a `SERVICE_PASSWORD_UMAMI` carried to a new box is the
// OLD box's password, sitting in the new box's config next to a database
// that has a different one.
//
// 2. Connection coordinates for a datastore the PROVIDER creates. A name that
// carries both a datastore word (DATABASE, POSTGRES, REDIS, …) and a
// connection word (URL, HOST, PASSWORD, …) as segments — `DATABASE_URL`,
// `UMAMI_DATABASE_URL`, `REDIS_URL_PROD`, `DB_HOST`. Coolify mints these
// when it creates the resource; the target's real value does not exist
// until it does.
//
// Deliberately erring WIDE. The two errors are not symmetric:
//
// - Over-match a name that was really a secret: it is placeheld, listed in
// UNCAPTURED.md for disposition, and the operator puts the value back. The
// source box is still there. Noisy, recoverable, LOUD.
// - Under-match one that was really provider-generated: it is copied, the
// rebuilt box boots working against the old box's database, and nobody finds
// out until the old box is deleted. Silent, unrecoverable, QUIET.
//
// A name-pattern rule is not a promise, and the docs say so: a var that points
// at the source box under a name cast does not recognize WILL be copied. That is
// what the disposition table printed at the end of a run is for — read it.
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 rule itself lives in reserved.ts (#87) — one home for the names the
// PLATFORM owns, shared with the diff, which asks the same vocabulary for a
// NARROWER width. The width is the whole difference between the two callers and
// reserved.ts explains why: here, over-matching withholds a value for review
// (loud, recoverable); in a diff it would hide a live-only var (silent). This is
// the wide one, deliberately.
export { isProviderGeneratedEnvName as isProviderGenerated } ;
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
// --- Names -------------------------------------------------------------------
// A box names things for a human reading a UI ("La Familia Site", "Incubator
// Stack v2"); a repo path and an age-store key cannot hold that. Slugs are used
// for PATHS only — the manifest keeps the box's own name as the resource key,
// because a draft that renamed everything to our vocabulary and never mentioned
// theirs would be unusable against the UI it describes (see inventory.ts).
export function slug ( name : string ) : string {
return (
name
. toLowerCase ( )
. replace ( /[^a-z0-9]+/g , "-" )
. replace ( /^-+|-+$/g , "" ) || "unnamed"
) ;
}
const constCase = ( name : string ) = > slug ( name ) . toUpperCase ( ) . replace ( /-/g , "_" ) ;
// `<org>/<repo>` out of whatever Coolify recorded as the app's git remote — the
// only place on a live box that knows which REPO a project belongs to, and the
// registry (#25) is keyed by exactly that.
//
// Three shapes, all real: Coolify stores a bare `org/repo` for an application
// created through a GitHub App (the shape `apply` itself posts), and a full URL —
// https or scp-style — for a public one. The last two segments are the answer in
// every case; anything with no `/` in it at all is not a repo, and gets
// `undefined` rather than a guess.
export function repoFromGitUrl ( raw : unknown ) : string | undefined {
if ( typeof raw !== "string" || raw === "" ) return undefined ;
const m = raw
. trim ( )
. replace ( /\.git$/ , "" )
. match ( /([^/:\s]+)\/([^/\s]+)$/ ) ;
return m ? ` ${ m [ 1 ] } / ${ m [ 2 ] } ` : undefined ;
}
// A cast env template's grammar is `KEY=value` with KEY matching this (see
// parseTemplate in envtemplate.ts — ONE grammar, shared by every reader). A live
// var whose key does not match cannot be written into a template at all, so it
// is not silently dropped: it is listed in UNCAPTURED.md.
const TEMPLATE_KEY = /^[A-Z][A-Z0-9_]*$/ ;
// --- Inputs ------------------------------------------------------------------
export type DraftResourceKind = "application" | "database" | "service" ;
export type DraftResource = {
kind : DraftResourceKind ;
// The BOX's name for it. There is no other.
name : string ;
uuid : string ;
// The live Coolify object, as it came off the wire. Kept whole, because the
// uncaptured pass's whole job is to notice fields cast has no home for — and
// it cannot notice what a projection already threw away.
raw : Record < string , unknown > ;
// Live env vars. Empty for databases (their URL is what the apps reference,
// and that name is generated, not captured).
env : Record < string , string > ;
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
// Databases only: the backup schedules read off GET /databases/{uuid}/backups
// — the same supplementary per-database GET diff/apply have made on every run
// since #51, made here by the CLI's draft loop for every DRAFTED database.
// BackupRead's two absences stay distinct (see coolify.ts): `[]` is a clean
// "no schedule" (nothing to draft, nothing to report), `undefined` is "could
// not read" — which the draft REPORTS in UNCAPTURED.md rather than aborting a
// whole-instance sweep the way diff/apply refuse a single-project plan.
backups? : BackupRead ;
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
} ;
export type DraftProject = {
// The Coolify project's own name.
name : string ;
// The BOX's environment the resources below were read from.
coolifyEnv : string ;
resources : DraftResource [ ] ;
// Resources of a kind cast cannot model AT ALL (a MySQL, a MongoDB): seen,
// named, and left out — the loudest possible silent loss if it went unsaid.
unreadable : Array < { kind : string ; name : string } > ;
// Environments on this project that this draft does NOT carry.
otherEnvironments : Array < { name : string ; resources : number } > ;
// Why nothing was drafted from this project. A project the draft passes over
// gets no manifest and no registry entry — and an entry in UNCAPTURED.md
// saying so, because a reader who takes this draft for "the box" would
// otherwise never learn the project was there. (The sweep printed it; a file
// on disk outlives a terminal.)
skipReason? : string ;
} ;
// The live environment document, split into what cast can model and what it
// cannot. `fetchLive` maps the same response, but through a projection — and the
// uncaptured pass cannot notice a field a projection has already discarded, so
// the draft reads the RAW document instead.
//
// The `unreadable` half is the point. Coolify's environment_details eager-loads
// mysqls, mariadbs, mongodbs, keydbs, dragonflies and clickhouses too
// (ProjectController@environment_details, v4.1.2); cast's manifest speaks
// postgresql and redis only. A MySQL on the box is therefore invisible to every
// other verb — and a blueprint that quietly leaves out a database is the exact
// artifact that gets someone to rebuild a box and only later find out what is
// missing from it. So it is read, named, and reported as inexpressible.
const KIND_OF : Array < [ string , DraftResourceKind ] > = [
[ "applications" , "application" ] ,
[ "postgresqls" , "database" ] ,
[ "redis" , "database" ] ,
[ "services" , "service" ] ,
] ;
const UNREADABLE_KINDS = [
"mysqls" ,
"mariadbs" ,
"mongodbs" ,
"keydbs" ,
"dragonflies" ,
"clickhouses" ,
] ;
export function draftResourcesFrom ( env : Record < string , unknown > ) : {
resources : DraftResource [ ] ;
unreadable : Array < { kind : string ; name : string } > ;
} {
const resources : DraftResource [ ] = [ ] ;
for ( const [ field , kind ] of KIND_OF ) {
const items = env [ field ] ;
if ( ! Array . isArray ( items ) ) continue ;
for ( const raw of items as Array < Record < string , unknown > > ) {
resources . push ( {
kind ,
name : String ( raw . name ) ,
uuid : String ( raw . uuid ) ,
raw ,
env : { } ,
} ) ;
}
}
const unreadable : Array < { kind : string ; name : string } > = [ ] ;
for ( const field of UNREADABLE_KINDS ) {
const items = env [ field ] ;
if ( ! Array . isArray ( items ) ) continue ;
for ( const raw of items as Array < Record < string , unknown > > ) {
unreadable . push ( {
kind : field.replace ( /s$/ , "" ) ,
name : String ( raw . name ) ,
} ) ;
}
}
return { resources , unreadable } ;
}
export type DraftContext = {
// OUR environment name (--env): the key every emitted artifact is filed under.
env : string ;
instance : string ;
baseUrl : string ;
team : { id : number ; name : string } ;
server? : string ;
2026-07-16 15:22:40 +00:00
// The GitHub Apps configured on the instance, each with the `id` an
// application's `source_id` points at (cast#72). bindingsDoc resolves a repo's
// App by that match — a READ, not the only-App guess this used to make — and
// falls back to a REVIEW marker only when a resource carries no resolvable
// source (a public repo, or an instance that would not list its Apps). See
// bindingsDoc.
githubApps? : Array < { id : number ; name : string } > ;
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
recipient? : string ;
generatedAt : string ;
} ;
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
// What the draft did with a name it read off the live box.
//
// captured — its value went to the store, and a ${REF} to the template.
// generated — provider-generated (see isProviderGenerated): placeheld, value
// not read into any artifact.
// suppressed — reserved by the platform (see reserved.ts): SOURCE_COMMIT,
// COOLIFY_*. Not in the template, not in the store, not
// anywhere — and named in UNCAPTURED.md, because a name cast
// declines to carry has to be said out loud rather than dropped.
//
// `suppressed` is the third one because the second was not enough. Before it,
// isProviderGenerated was the ONLY filter between a live var and a drafted
// manifest, and it recognizes SERVICE_* and datastore-connection names — nothing
// else. `SOURCE_COMMIT` splits to [SOURCE, COMMIT]: no SERVICE_ prefix, no
// datastore word, no connection word. So it was captured verbatim, with its live
// (usually EMPTY) value, and drafting a working box reproduced in the new box's
// manifest the exact var that suppresses Coolify's own injection — which the next
// `apply` would then dutifully write. The draft's whole promise is that it does
// not carry a box's traps forward.
export type Provenance = "captured" | "generated" | "suppressed" ;
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
export type DraftDisposition = {
project : string ;
ref : string ;
provenance : Provenance ;
sites : string [ ] ;
// Never rendered. Held so emitDraft can encrypt it, and nowhere else.
value : string ;
} ;
export type UncapturedItem = {
project : string ;
resource? : string ;
setting : string ;
detail : string ;
} ;
export type DraftFile = { path : string ; content : string } ;
export type DraftPlan = {
files : DraftFile [ ] ;
// project slug -> the age store's contents. Kept out of `files` because it is
// the one artifact that is not text on the way out.
stores : Array < {
project : string ;
path : string ;
vars : Record < string , string > ;
} > ;
dispositions : DraftDisposition [ ] ;
uncaptured : UncapturedItem [ ] ;
} ;
// --- Headers -----------------------------------------------------------------
//
// Every emitted file says what it is, in its own body. The artifacts leave this
// process and are read by a person deciding whether to TRUST them — as a
// blueprint of a box they may have to rebuild — and a file that does not say it
// was machine-generated from a live box will be read as if someone meant it.
function header (
ctx : DraftContext ,
lines : string [ ] ,
// UNCAPTURED.md carries the same header, minus the line telling you to go and
// read UNCAPTURED.md. It IS the thing being pointed at.
opts : { self? : boolean } = { } ,
) : string [ ] {
return [
"PROPOSAL — not desired state. `apply` does not read this file." ,
"" ,
"Machine-generated by `cast inventory --emit-draft` from a LIVE box:" ,
` instance: ${ ctx . instance } ( ${ ctx . baseUrl } ) ` ,
` team: ${ ctx . team . id } ( ${ ctx . team . name } ) ` ,
` generated: ${ ctx . generatedAt } ` ,
"" ,
. . . lines ,
"" ,
"Everything a box accumulates that nobody meant — a hand-edited var, a resource" ,
"somebody made once — is in here too. Review it, decide what should be declared" ,
"and what is cruft that must not travel, and land it as a PR. Then: capture → apply." ,
. . . ( opts . self
? [ ]
: [
"" ,
"Read UNCAPTURED.md first: it lists what cast SAW on this box and could not" ,
"express. A blueprint that omits things without saying so is worse than none." ,
] ) ,
] ;
}
const comment = ( lines : string [ ] ) = >
lines . map ( ( l ) = > ( l ? ` # ${ l } ` : "#" ) ) . join ( "\n" ) ;
// --- The manifest ------------------------------------------------------------
const PACKS = new Set ( [ "nixpacks" , "static" , "dockerfile" , "dockercompose" ] ) ;
type Spec = Record < string , unknown > ;
function applicationSpec (
r : DraftResource ,
ctx : DraftContext ,
project : string ,
hasEnv : boolean ,
uncaptured : UncapturedItem [ ] ,
) : Spec | undefined {
const flag = ( setting : string , detail : string ) = >
uncaptured . push ( { project , resource : r.name , setting , detail } ) ;
const pack = String ( r . raw . build_pack ? ? "" ) ;
if ( ! PACKS . has ( pack ) ) {
// NOT "pick the closest pack". An application cast's manifest cannot express
// is left OUT of the manifest and named here — a fabricated build pack would
// rebuild a different application, which is the exact failure UNCAPTURED.md
// exists to prevent, dressed up as coverage.
flag (
"the whole application" ,
` build pack " ${ pack || "(none)" } " — cast's manifest supports ${ [ . . . PACKS ] . join ( ", " ) } . This application is NOT in the draft; it cannot be expressed, and guessing a pack would rebuild a different app. Its env vars WERE read, and are in the store and an env template beside it — the values are not lost, only the structure. ` ,
) ;
return undefined ;
}
const repo = repoFromGitUrl ( r . raw . git_repository ) ;
if ( ! repo ) {
flag (
"source.repo" ,
` git remote " ${ String ( r . raw . git_repository ? ? "" ) } " — cast could not read an <org>/<repo> out of it, and wrote it through verbatim. \` apply \` resolves a GitHub App by that slug; fix it before you trust this. ` ,
) ;
}
const branch = r . raw . git_branch ;
if ( typeof branch !== "string" || branch === "" ) {
flag (
"source.branch" ,
"the box reports no branch for this application. `main` was written; confirm it." ,
) ;
}
const compose = pack === "dockercompose" ;
const fqdn = String ( r . raw . fqdn ? ? "" )
. split ( "," )
. filter ( Boolean ) ;
if ( compose && fqdn . length > 0 ) {
flag (
"domains" ,
` the box has a flat fqdn ( ${ fqdn . join ( ", " ) } ) on this compose application; a compose app's hostnames are expressed per-container (service_domains) and cast cannot map one onto the other. ` ,
) ;
}
if ( ! compose && fqdn . length === 0 ) {
flag (
"domains" ,
"no hostname is set on this application; `domains: []` was written, which `apply` would create it with — a rebuilt box would serve nothing here." ,
) ;
}
// Real settings, present on the live object, that the manifest has no field
// for. Each one changes what the application IS, and each would be silently
// absent from a rebuild.
const NO_HOME : Array < [ string , string ] > = [
[ "custom_labels" , "custom Traefik/Docker labels (Basic Auth lives here)" ] ,
[ "ports_mappings" , "host port mappings" ] ,
[ "pre_deployment_command" , "a pre-deployment command" ] ,
[ "post_deployment_command" , "a post-deployment command" ] ,
[ "dockerfile" , "an inline Dockerfile" ] ,
[ "dockerfile_location" , "a non-default Dockerfile location" ] ,
[ "watch_paths" , "watch paths (which changes trigger a deploy)" ] ,
[ "redirect" , "a www/non-www redirect policy" ] ,
] ;
for ( const [ field , what ] of NO_HOME ) {
const v = r . raw [ field ] ;
if ( v === undefined || v === null || v === "" ) continue ;
flag ( field , ` ${ what } is set on the box. The manifest has no field for it. ` ) ;
}
// `port` is one number in a manifest and a comma-separated list on the wire.
// The draft writes the first and says so — a rebuilt app exposing one of the
// three ports it used to is the kind of difference that surfaces as a broken
// healthcheck weeks later.
const exposed = String ( r . raw . ports_exposes ? ? "" )
. split ( "," )
. filter ( Boolean ) ;
if ( exposed . length > 1 ) {
flag (
"port" ,
` the box exposes ${ exposed . join ( ", " ) } ; a manifest declares ONE port, so only ${ exposed [ 0 ] } is in the draft. ` ,
) ;
}
return {
source : {
repo : repo ? ? String ( r . raw . git_repository ? ? "" ) ,
branch : typeof branch === "string" && branch ? branch : "main" ,
} ,
build : {
pack ,
base_directory : String ( r . raw . base_directory ? ? "/" ) ,
. . . ( compose
? {
compose_file : String (
r . raw . docker_compose_location ? ? "/docker-compose.yaml" ,
) ,
}
: { } ) ,
. . . ( ! compose && r . raw . publish_directory
? { publish_directory : String ( r . raw . publish_directory ) }
: { } ) ,
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
// The three build/run commands and the static flag now HAVE manifest
// fields (see manifest.ts), so a rebuild carries them instead of silently
// dropping them. `is_static` was previously not even in NO_HOME, so a
// rebuild lost it without a word — that is exactly the #63 crash: a static
// site whose is_static was true on the box came back as false, got built
// and RUN from the repo-root package.json, and crash-looped. draft now
// carries it. (Compose apps get none of these — see the else above.)
. . . ( ! compose && r . raw . install_command
? { install_command : String ( r . raw . install_command ) }
: { } ) ,
. . . ( ! compose && r . raw . build_command
? { build_command : String ( r . raw . build_command ) }
: { } ) ,
. . . ( ! compose && r . raw . start_command
? { start_command : String ( r . raw . start_command ) }
: { } ) ,
// Gated on publish_directory as well: the schema refuses `static: true`
// with nothing to serve, and a draft must only ever emit a manifest that
// loads. Coolify's static apps carry a publish_directory (its default is
// `/`), so this drops `static` only for a box in a state the manifest could
// not express anyway — a loud, correct omission rather than an unloadable
// file.
. . . ( ! compose &&
( r . raw . is_static === true || r . raw . is_static === 1 ) &&
r . raw . publish_directory
? { static : true }
: { } ) ,
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
} ,
. . . ( compose
? { }
: {
. . . ( r . raw . ports_exposes
? { port : Number ( String ( r . raw . ports_exposes ) . split ( "," ) [ 0 ] ) }
: { } ) ,
. . . ( r . raw . health_check_path
? { healthcheck : String ( r . raw . health_check_path ) }
: { } ) ,
domains : fqdn ,
} ) ,
. . . ( compose
? { service_domains : composeDomains ( r , project , uncaptured ) }
: { } ) ,
. . . ( hasEnv
? { env_template : ` env/ ${ slug ( r . name ) } . ${ ctx . env } .env.template ` }
: { } ) ,
} ;
}
function composeDomains (
r : DraftResource ,
project : string ,
uncaptured : UncapturedItem [ ] ,
) : Record < string , string [ ] > {
const raw = r . raw . docker_compose_domains ;
const map : Record < string , string [ ] > = { } ;
if ( typeof raw === "string" && raw !== "" ) {
try {
const parsed = JSON . parse ( raw ) ;
if ( Array . isArray ( parsed ) ) {
for ( const e of parsed ) {
const name = ( e as { name? : unknown } ) ? . name ;
const domain = ( e as { domain? : unknown } ) ? . domain ;
if ( typeof name === "string" && typeof domain === "string" ) {
map [ name ] = domain . split ( "," ) . filter ( Boolean ) ;
}
}
}
} catch {
// Unreadable, not absent. Say so rather than write {} and move on.
}
}
if ( Object . keys ( map ) . length === 0 ) {
uncaptured . push ( {
project ,
resource : r.name ,
setting : "service_domains" ,
detail :
"this compose application exposes no readable per-container domains; `service_domains: {}` was written. A rebuilt stack would serve no hostnames until they are declared." ,
} ) ;
}
return map ;
}
function databaseSpec (
r : DraftResource ,
project : string ,
uncaptured : UncapturedItem [ ] ,
) : Spec {
const flag = ( setting : string , detail : string ) = >
uncaptured . push ( { project , resource : r.name , setting , detail } ) ;
const rawType = String ( r . raw . database_type ? ? r . raw . type ? ? "" ) ;
const type =
rawType === "standalone-postgresql"
? "postgresql"
: rawType === "standalone-redis"
? "redis"
: rawType ;
const image = typeof r . raw . image === "string" ? r.raw.image : undefined ;
const version = image ? . split ( ":" ) [ 1 ] ? . match ( /^(\d+(?:\.\d+)*)/ ) ? . [ 1 ] ;
if ( image && ! version ) {
flag (
"version" ,
` image " ${ image } " — no version could be read from its tag, so none was written and \` apply \` would create this database on Coolify's default image. ` ,
) ;
}
fix(draft): read backup schedules and emit backup blocks (#75)
--emit-draft still told every reader that backup schedules "are not
exposed by Coolify's API" — the exact pre-#51 claim that issue disproved:
GET /databases/{uuid}/backups is a route, and diff/apply have read it on
every run since. The draft path was never brought along, so it warned
instead of reading, and a rebuild from a draft came up with no backups.
Now the draft loop makes the same supplementary per-database GET
(databaseBackupSchedules) for every DRAFTED database and databaseSpec
emits a real backup: { frequency, retention } block for the one shape
the manifest can express — a single, enabled schedule. Ungated on
purpose: fetchLive's opts.backups gate exists because the read-side
sweeps never look at the answer, and the draft is the sweep that does.
The read stays sequential (like the existing per-resource env GETs) and
a failed read degrades to an UNCAPTURED entry per resource rather than
aborting the whole-instance sweep — a draft's reader is a human, not an
apply about to write.
UNCAPTURED keeps only what the route genuinely cannot answer:
- the S3 target: save_s3 now rides on LiveBackup, and a schedule that
saves to S3 gets a per-database entry saying the target reads back
only as s3_storage_id, an int nothing maps to a storage UUID
- a DISABLED schedule (declaring the block would make apply re-enable it)
- several schedules where a manifest declares one
- an unreadable route (reported, never read as "no backups")
The stale NO_API_COVERAGE "backup schedules" row becomes "a backup
schedule's S3 target", and semantics.md's draft section now tells the
truth about what is captured.
Closes #75
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:25:34 +00:00
// The backup schedule IS captured (#75): the CLI's draft loop reads
// GET /databases/{uuid}/backups — the route diff/apply have read on every run
// since #51 — and the one shape the manifest can express (a single, enabled
// schedule) becomes a real `backup:` block. Everything the route genuinely
// cannot answer stays a per-resource UNCAPTURED entry: reported, never
// guessed, and never silently dropped.
const backup = draftBackup ( r , flag ) ;
return {
type ,
. . . ( version ? { version } : { } ) ,
. . . ( backup ? { backup } : { } ) ,
} ;
}
// The drafted `backup:` block, or the reason there isn't one. The same four
// answers attachBackup (cli.ts) reads for a diff — but where diff refuses or
// skips a comparison, a draft REPORTS: its reader is a human adopting a box,
// not an `apply` about to write one, and a sweep that aborted on one database
// would trade a whole blueprint for one row.
//
// unreadable -> UNCAPTURED, no block. If this database is backed up, the
// draft cannot say so — and a rebuild from it would not be.
// no schedule -> nothing at all. Read cleanly, absence IS the answer, and a
// manifest with no `backup:` block expresses it exactly.
// one enabled -> a real block, `{ frequency, retention }` — the same
// projection the desired side builds (resolve.ts), so the
// drafted manifest diffs clean the moment it is applied. Plus
// an UNCAPTURED entry for the S3 TARGET when the schedule
// saves to S3: the route returns it only as `s3_storage_id`,
// an int no endpoint maps to a storage UUID (#72), so the
// block cannot carry WHICH bucket.
// one disabled -> UNCAPTURED, no block. The manifest cannot express a
// disabled schedule (`backup:` asks for backups, and every
// cast write asserts enabled: true), so emitting the block
// would make the first `apply` re-enable a schedule someone
// turned off on purpose.
// several -> UNCAPTURED, no block. A manifest declares ONE schedule;
// picking one to write down would be a coin toss dressed up
// as a blueprint.
function draftBackup (
r : DraftResource ,
flag : ( setting : string , detail : string ) = > void ,
) : { frequency : string ; retention : number } | undefined {
const read = r . backups ;
if ( read === undefined ) {
flag (
"backup" ,
"`GET /databases/{uuid}/backups` was unreachable or returned a shape cast does not recognize, so the schedule is NOT in this draft. If this database is backed up, a rebuild from here would not be until you declare it — read the schedule off a `cast diff` or the Coolify UI (Backups tab) and set `backup: { frequency, retention }` yourself." ,
) ;
return undefined ;
}
if ( read . length === 0 ) return undefined ;
if ( read . length > 1 ) {
flag (
"backup" ,
` Coolify holds ${ read . length } backup schedules for this database and a manifest declares ONE, so none was drafted. Decide which schedule the manifest should carry and declare its \` backup: { frequency, retention } \` yourself. ` ,
) ;
return undefined ;
}
const schedule = read [ 0 ] ;
if ( ! schedule . enabled ) {
flag (
"backup" ,
` a backup schedule exists (frequency " ${ schedule . frequency } ", retention ${ schedule . retention } ) but it is DISABLED — it backs nothing up, and the manifest cannot say "disabled": declaring \` backup: \` asks for backups, and the first \` apply \` would re-enable it. It is NOT in this draft; decide whether it was turned off on purpose before you declare it. ` ,
) ;
return undefined ;
}
if ( schedule . saveS3 ) {
flag (
"backup S3 target" ,
"this schedule saves to S3, and the draft cannot say WHERE: Coolify returns the target only as `s3_storage_id`, an int no endpoint maps to a storage UUID (the destination_id problem again, #21/#72). The drafted `backup:` block carries frequency and retention; `apply` points the schedule at the environment's own `s3_destination` — verify that is the bucket you meant." ,
) ;
}
return { frequency : schedule.frequency , retention : schedule.retention } ;
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
}
function serviceSpec (
r : DraftResource ,
ctx : DraftContext ,
project : string ,
hasEnv : boolean ,
uncaptured : UncapturedItem [ ] ,
) : Spec {
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
// A service's per-container hostnames (`service.applications[].fqdn`) ARE
// settable and readable via the API — `urls` on create/PATCH, and
// GET /services/{uuid} on read — so `diff`/`apply` now carry them as
// `service_domains` (cast#72). What the DRAFT path cannot yet do is CAPTURE
// them: the inventory sweep reads the environment list, which does not
// eager-load `service.applications`, and does not make the supplementary
// per-service GET. So a service that serves a hostname today comes back with
// none in this draft — until it is declared by hand. Same shape as the backup
// schedule (#51): the API answers, the draft path has not been taught to ask.
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
uncaptured . push ( {
project ,
resource : r.name ,
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
setting : "service_domains (hostnames)" ,
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
detail :
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
"a service's per-container hostnames ARE settable/readable via the API (`urls` on create/PATCH, `service.applications[].fqdn` on GET /services/{uuid}) — `diff`/`apply` carry them as `service_domains` (cast#72) — but `inventory --emit-draft` does not yet make that per-service GET, so they are NOT captured here. Read them off a `cast diff` or the Coolify UI and declare `service_domains: { <container>: [url] }` yourself." ,
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
} ) ;
return {
type : String ( r . raw . service_type ? ? r . raw . type ? ? "" ) ,
. . . ( hasEnv
? { env_template : ` env/ ${ slug ( r . name ) } . ${ ctx . env } .env.template ` }
: { } ) ,
} ;
}
// --- Secrets -----------------------------------------------------------------
//
// Every live var becomes a `${REF}` in the template, and its value goes to the
// age store. NOTHING is written as a template literal.
//
// That is a deliberate one-way bet. cast cannot know which of a box's vars are
// secret — nobody wrote it down, which is why this verb exists — and the two
// mistakes are not symmetric: a non-secret in the encrypted store is untidy, and
// a live API key written as a literal into a manifest is a key in a git repo.
// So: no heuristic decides where a VALUE goes. Only where a NAME goes (see
// isProviderGenerated), and that decision withholds the value rather than
// publishing it.
function planSecrets (
p : DraftProject ,
uncaptured : UncapturedItem [ ] ,
) : {
// resource name -> [KEY, ref][]
templates : Map < string , Array < [ string , string ] > > ;
dispositions : DraftDisposition [ ] ;
generated : string [ ] ;
} {
const templates = new Map < string , Array < [ string , string ] > > ( ) ;
// key -> sites, and the distinct values seen for it across the project
const byKey = new Map < string , Array < { resource : string ; value : string } > > ( ) ;
for ( const r of p . resources ) {
const usable : Array < [ string , string ] > = [ ] ;
for ( const [ key , value ] of Object . entries ( r . env ) ) {
if ( ! TEMPLATE_KEY . test ( key ) ) {
// A cast template cannot hold this name at all — one grammar, shared by
// every reader of a template (envtemplate.ts). Dropping it quietly would
// rebuild the resource without a var it has today.
uncaptured . push ( {
project : p.name ,
resource : r.name ,
setting : ` env var ${ key } ` ,
detail : ` this box sets an env var named " ${ key } ", which is not a name a cast env template can express (KEY must match ${ TEMPLATE_KEY . source } ). It is NOT in this draft. ` ,
} ) ;
continue ;
}
const sites = byKey . get ( key ) ? ? [ ] ;
sites . push ( { resource : r.name , value } ) ;
byKey . set ( key , sites ) ;
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
// Dispositioned (above — it gets an entry, and a line in the table), but
// never templated: a reserved name in an emitted template is the trap
// itself, copied forward. See Provenance / reserved.ts.
if ( isReservedEnvName ( key ) ) continue ;
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
usable . push ( [ key , "" ] ) ;
}
if ( usable . length > 0 ) templates . set ( r . name , usable ) ;
}
const dispositions : DraftDisposition [ ] = [ ] ;
const generated : string [ ] = [ ] ;
const refOf = new Map < string , string > ( ) ; // `${resource}::${key}` -> ref
for ( const [ key , sites ] of byKey ) {
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
const provenance : Provenance = isReservedEnvName ( key )
? "suppressed"
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
: isProviderGeneratedEnvName ( key )
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
? "generated"
: "captured" ;
if ( provenance === "suppressed" ) {
// Said out loud, in the file that exists precisely so that what cast
// declines to carry is stated rather than dropped. The reader is being
// told two things: it is not in your draft, AND it is a live bug on the
// box you drafted from.
uncaptured . push ( {
project : p.name ,
setting : ` env var ${ key } ` ,
detail : ` ${ sites . map ( ( s ) = > ` " ${ s . resource } " ` ) . join ( ", " ) } set ${ key } on this box. It is NOT in this draft — not in a template, not in the store, and its live value was not read into any artifact. ${ reservedConsequence ( key ) } Carrying it into the new box's manifest would reproduce that suppression there, and the first \` apply \` would write it; cast refuses a manifest that declares one. Delete it on the source box too (Coolify UI) — it is suppressing the injection there right now. ` ,
} ) ;
}
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
// A provider-generated name is placeheld everywhere it appears, so two
// resources disagreeing about its value is not a conflict cast has to
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
// resolve — neither value is being carried. Same for a suppressed one, and
// more so: it is not being carried anywhere at all.
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
const distinct = new Set ( sites . map ( ( s ) = > s . value ) ) ;
const split = provenance === "captured" && distinct . size > 1 ;
if ( split ) {
// The store holds ONE value per name (see classify's CONFLICT refusal), and
// this key carries two. cast will not pick — so it does not: each site gets
// its own ref, both values survive, and the reviewer collapses them if they
// were meant to be the same thing.
uncaptured . push ( {
project : p.name ,
setting : ` env var ${ key } ` ,
detail : ` ${ sites . map ( ( s ) = > ` " ${ s . resource } " ` ) . join ( " and " ) } each set ${ key } , to DIFFERENT values. One store holds one value per name, so cast split them into ${ sites . map ( ( s ) = > ` ${ constCase ( s . resource ) } _ ${ key } ` ) . join ( " and " ) } rather than pick. Collapse them if they were meant to be one. ` ,
} ) ;
}
for ( const s of sites ) {
const ref = split ? ` ${ constCase ( s . resource ) } _ ${ key } ` : key ;
refOf . set ( ` ${ s . resource } :: ${ key } ` , ref ) ;
if ( ! split && dispositions . some ( ( d ) = > d . ref === ref ) ) {
// Same ref, same value, second site: record the site, not a second entry.
const d = dispositions . find ( ( x ) = > x . ref === ref ) ;
d ? . sites . push ( ` ${ s . resource } . ${ key } ` ) ;
continue ;
}
dispositions . push ( {
project : p.name ,
ref ,
provenance ,
sites : [ ` ${ s . resource } . ${ key } ` ] ,
// THE line this whole file is bent around: a provider-generated name is
// placeheld with the same literal `capture` writes, and the source box's
// value is not written anywhere — not into a template, not into a store,
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
// not into a log. A suppressed name gets no value at all: it is not
// placeheld, because there is nothing for it to be a placeholder FOR —
// the platform supplies it, and the correct manifest says nothing. The
// empty string here never reaches an artifact (planDraft drops suppressed
// entries from the store), and it must not start to.
value :
provenance === "generated"
? GENERATED_PLACEHOLDER
: provenance === "suppressed"
? ""
: s . value ,
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
} ) ;
if ( provenance === "generated" && ! generated . includes ( ref ) )
generated . push ( ref ) ;
}
}
for ( const [ resource , pairs ] of templates ) {
templates . set (
resource ,
pairs . map ( ( [ key ] ) = > [ key , refOf . get ( ` ${ resource } :: ${ key } ` ) ? ? key ] ) ,
) ;
}
return { templates , dispositions , generated : generated.sort ( ) } ;
}
// --- Uncaptured, the parts that are not per-resource ---------------------------
function placementItems ( p : DraftProject , uncaptured : UncapturedItem [ ] ) : void {
const ids = new Set (
p . resources
. map ( ( r ) = > r . raw . destination_id )
. filter ( ( v ) : v is number = > typeof v === "number" ) ,
) ;
if ( ids . size === 0 ) return ;
// #21: `destination_id` is the ONLY thing Coolify tells us about placement —
// an integer primary key. `destination_uuid:` (the binding `apply` needs) takes
// the UUID, and Coolify 4.1.2 has no destinations API at all, so cast cannot
// resolve one to the other. On a server with one destination this is inert; on
// a server with two — which is exactly the box you are draining — it decides
// which Docker network a resource can reach, and getting it wrong builds a
// stack whose app cannot see its own database.
uncaptured . push ( {
project : p.name ,
setting : "destination (Docker network)" ,
detail : ` these resources sit on destination_id ${ [ . . . ids ] . sort ( ) . join ( ", " ) } ${ ids . size > 1 ? " — MORE THAN ONE, so this project is split across Docker networks" : "" } . cast cannot turn that integer into the UUID \` environments.<env>.projects.<repo>.destination_uuid \` needs: Coolify 4.1.2 has no destinations API (see reference/README.md). Read the UUID off the Coolify UI and bind it yourself, or a rebuild lands on whichever network Coolify picks first. ` ,
} ) ;
}
const NO_API_COVERAGE : Array < [ string , string ] > = [
[
"destinations" ,
"Coolify 4.1.2 serves no destinations endpoint. A resource's `destination_id` comes back; the UUID that names it never does. Placement must be read from the UI (#21)." ,
] ,
[
"service hostnames" ,
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
"settable/readable via the API (`urls` on create/PATCH, `service.applications[].fqdn` on GET /services/{uuid}) — `diff`/`apply` carry them as `service_domains` (cast#72) — but `inventory --emit-draft` does not yet make the per-service GET, so a drafted service has none until you declare them." ,
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
] ,
[
"Basic Auth / custom Traefik labels" ,
"carried as raw container labels. cast's manifest has no field for them, so a rebuilt resource is UNPROTECTED where the original was not." ,
] ,
[
'"Include Source Commit in Build"' ,
"and its neighbours on a resource's Settings tab: no API coverage in 4.1.2, and not returned by the endpoints cast reads." ,
] ,
[
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
"a backup schedule's S3 target" ,
"the schedule itself IS captured (`GET /databases/{uuid}/backups`, the route `diff`/`apply` have used since #51 — a single enabled schedule becomes a real `backup:` block above), but its target reads back only as `s3_storage_id`, an int no endpoint maps to a storage UUID. `apply` points every schedule it writes at the environment's own `s3_destination`; whether that is the bucket the source box used must be verified by hand." ,
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
] ,
[
"database kinds cast does not model" ,
"MySQL, MariaDB, MongoDB, KeyDB, Dragonfly, ClickHouse. cast's manifest speaks postgresql and redis only; any of the others found on the box are named above and are NOT in this draft." ,
] ,
[
"which GitHub App clones a repo" ,
2026-07-16 15:22:40 +00:00
"resolved from the application's `source_id` against GET /github-apps (cast#72) — a lookup, not a guess. A repo whose application has no GithubApp source (a public repo) or an instance that will not list its Apps gets a REVIEW marker instead." ,
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
] ,
[
"anything configured in the UI with no manifest field" ,
"this list is what cast KNOWS it cannot express. It is not a proof that nothing else is missing." ,
] ,
] ;
// The table that stops "rebuild from the repo" being over-claimed. Two of these
// six rows are `❌`, and they are the two that decide whether a DR runbook is
// true: they are not in the repo (correctly — it holds no live credentials) and
// they cannot be regenerated from it.
const CANNOT_RESTORE : Array < [ string , string ] > = [
[ "control plane" , "`rig coolify install` ✅" ] ,
[ "structure" , "this draft → a manifest PR → `apply` ✅" ] ,
[ "secret **values**" , "the age store + your key ✅" ] ,
[ "**data**" , "Coolify's DB backups → S3 ✅ (a separate path — not this one)" ] ,
[
"**the GitHub App private key**" ,
"❌ re-create by hand. It is not in the repo and cannot be regenerated from it." ,
] ,
[
"**S3 access keys**" ,
"❌ re-mint by hand. Same reason: the repo holds no live credentials." ,
] ,
] ;
export function renderUncaptured (
items : UncapturedItem [ ] ,
projects : DraftProject [ ] ,
ctx : DraftContext ,
) : string {
const lines = [
"# UNCAPTURED — what this draft does NOT carry" ,
"" ,
// Fenced, not quoted: the header is aligned text, and a markdown blockquote
// would reflow it into one paragraph.
"```" ,
. . . header (
ctx ,
[ "This file is the reason the draft beside it is allowed to exist." ] ,
{ self : true } ,
) ,
"```" ,
"" ,
"cast cannot express everything a Coolify holds. A blueprint that omits those" ,
"things **without saying so** is worse than no blueprint at all — in a disaster" ,
"you would trust it, rebuild from it, and get a *different box*, without ever" ,
"being told. So everything cast saw on this instance and could not write down is" ,
"listed here, by resource. Nothing on this page is in the draft." ,
"" ,
"## Seen on this box, not in the draft" ,
"" ,
] ;
if ( items . length === 0 ) {
lines . push (
"Nothing — cast expressed every setting it could see on every resource it" ,
"drafted. That is a statement about what cast can SEE (the sections below say" ,
"what it cannot), not a clean bill of health." ,
"" ,
) ;
}
for ( const p of projects ) {
const mine = items . filter ( ( i ) = > i . project === p . name ) ;
if ( mine . length === 0 ) continue ;
lines . push ( ` ### ${ p . name } — Coolify environment \` ${ p . coolifyEnv } \` ` , "" ) ;
const projectWide = mine . filter ( ( i ) = > ! i . resource ) ;
for ( const i of projectWide ) {
lines . push ( ` - ** ${ i . setting } ** — ${ i . detail } ` ) ;
}
if ( projectWide . length > 0 ) lines . push ( "" ) ;
const resources = [ . . . new Set ( mine . map ( ( i ) = > i . resource ) ) ] . filter (
( r ) : r is string = > r !== undefined ,
) ;
for ( const resource of resources ) {
lines . push ( ` ** ${ resource } ** ` , "" ) ;
for ( const i of mine . filter ( ( x ) = > x . resource === resource ) ) {
lines . push ( ` - \` ${ i . setting } \` — ${ i . detail } ` ) ;
}
lines . push ( "" ) ;
}
if ( p . otherEnvironments . length > 0 ) {
lines . push (
` - **other environments** — this project also has ${ p . otherEnvironments
. map ( ( e ) = > ` \` ${ e . name } \` ( ${ e . resources } resource(s)) ` )
. join (
", " ,
) } on the box . This draft carries \ ` ${ p . coolifyEnv } \` only. ` ,
"" ,
) ;
}
}
lines . push (
"## Cannot be seen at all — no API coverage in Coolify 4.1.2" ,
"" ,
"Not gaps in this draft: gaps in the API it was read through. Nothing cast can" ,
"do would recover these, so a rebuild must set them by hand." ,
"" ,
"| setting | why |" ,
"| --- | --- |" ,
. . . NO_API_COVERAGE . map ( ( [ k , v ] ) = > ` | ${ k } | ${ v } | ` ) ,
"" ,
"## What a rebuild from this draft still cannot restore" ,
"" ,
"| | |" ,
"| --- | --- |" ,
. . . CANNOT_RESTORE . map ( ( [ k , v ] ) = > ` | ${ k } | ${ v } | ` ) ,
"" ,
"The last two rows are the ones a DR runbook has to say out loud. They are not" ,
"in the state repo — correctly, it holds no live credentials — and they cannot be" ,
"regenerated from it. Re-create them by hand, and expect to." ,
"" ,
"## How values were treated" ,
"" ,
"Every live env var in this draft became a `${REF}` in a template, with its" ,
"value in the age store — never a literal in a committed file. Names that look" ,
"**provider-generated** (Coolify's `SERVICE_*` magic vars; anything carrying a" ,
"datastore word and a connection word, like `DATABASE_URL` or `DB_HOST`) were" ,
` **placeheld** with \` ${ GENERATED_PLACEHOLDER } \` and their live values were NOT ` ,
"read into any artifact: a `DATABASE_URL` copied off this box points at THIS" ,
"box's Postgres, and a rebuilt box carrying it would come up *working*, reading" ,
"and writing the old box's database — until the day the old box is deleted." ,
"" ,
"That rule is by NAME. A var that points at this box under a name cast does not" ,
"recognize **will have been copied**. The disposition table printed at the end of" ,
"the run is the list to read." ,
"" ,
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
"Names **reserved by the platform** (`SOURCE_COMMIT`, `COOLIFY_*`) were" ,
"**suppressed**: not written to a template, not written to the store, not carried" ,
"at all. Coolify injects those itself at runtime — and it *skips* its own" ,
"injection when the resource already carries a var of that name, so a var of that" ,
"name (even an EMPTY one) suppresses the platform's value, on a deploy that stays" ,
"green. If one is listed above, it is not merely absent from this draft: it is" ,
"doing that, right now, on the box this was read from. Delete it in the Coolify" ,
"UI." ,
"" ,
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
) ;
return lines . join ( "\n" ) ;
}
// --- The plan ----------------------------------------------------------------
export function planDraft (
projects : DraftProject [ ] ,
ctx : DraftContext ,
) : DraftPlan {
const files : DraftFile [ ] = [ ] ;
const stores : DraftPlan [ "stores" ] = [ ] ;
const dispositions : DraftDisposition [ ] = [ ] ;
const uncaptured : UncapturedItem [ ] = [ ] ;
for ( const p of projects ) {
const dir = projectDir ( p ) ;
for ( const u of p . unreadable ) {
uncaptured . push ( {
project : p.name ,
resource : u.name ,
setting : "the whole resource" ,
detail : ` a ${ u . kind } — cast's manifest speaks postgresql and redis only. It is NOT in this draft, and a rebuild from here would not have it. ` ,
} ) ;
}
// Nothing to express: no manifest, no store, and NO REGISTRY ENTRY — a
// registry that claimed a project this draft carries nothing for would send
// every future fleet run at a project with no manifest to run against. It is
// still named, in UNCAPTURED.md, because it is on the box.
if ( p . resources . length === 0 ) {
uncaptured . push ( {
project : p.name ,
setting : "the whole project" ,
detail : ` nothing was drafted from it: ${ p . skipReason ? ? "it has no resources cast can express" } . It EXISTS on this box ${ p . otherEnvironments . length > 0 ? "" : " and is not in this draft" } . ` ,
} ) ;
continue ;
}
placementItems ( p , uncaptured ) ;
const secrets = planSecrets ( p , uncaptured ) ;
dispositions . push ( . . . secrets . dispositions ) ;
const applications : Record < string , Spec > = { } ;
const databases : Record < string , Spec > = { } ;
const services : Record < string , Spec > = { } ;
for ( const r of p . resources ) {
const hasEnv = secrets . templates . has ( r . name ) ;
if ( r . kind === "application" ) {
const spec = applicationSpec ( r , ctx , p . name , hasEnv , uncaptured ) ;
if ( spec ) applications [ r . name ] = spec ;
} else if ( r . kind === "database" ) {
databases [ r . name ] = databaseSpec ( r , p . name , uncaptured ) ;
} else {
services [ r . name ] = serviceSpec ( r , ctx , p . name , hasEnv , uncaptured ) ;
}
}
const manifest = {
// The BOX's project name, not a slug: it is what `--project` takes, and the
// one string that connects this file to the UI it was read from.
project : p.name ,
environments : {
[ ctx . env ] : {
applications ,
. . . ( Object . keys ( databases ) . length > 0 ? { databases } : { } ) ,
. . . ( Object . keys ( services ) . length > 0 ? { services } : { } ) ,
. . . ( secrets . generated . length > 0
? { generated_secrets : secrets.generated }
: { } ) ,
} ,
} ,
} ;
files . push ( {
path : join ( dir , ".infra" , "manifest.yaml" ) ,
content : ` ${ comment (
header ( ctx , [
` project: " ${ p . name } " ` ,
` environment: " ${ p . coolifyEnv } " on the box → filed here under \` ${ ctx . env } \` , YOUR name for it ` ,
"" ,
"Resources keep the names the BOX gives them. They are what someone typed into" ,
"a UI, and renaming them here would make this file unusable against that UI —" ,
"so a rename is a review decision, made with `--resource <manifest>=<live>` on" ,
"the read side until the names agree." ,
] ) ,
) } \ n \ n $ { stringify ( manifest ) } ` ,
} ) ;
for ( const [ resource , pairs ] of secrets . templates ) {
files . push ( {
path : join (
dir ,
".infra" ,
"env" ,
` ${ slug ( resource ) } . ${ ctx . env } .env.template ` ,
) ,
content : ` ${ comment (
header ( ctx , [
` resource: " ${ resource } " ` ,
"" ,
"Every live var is a ${REF}: the VALUES are in the age store, never here. cast" ,
"cannot know which of a box's vars are secret — nobody wrote it down, which is" ,
"why this verb exists — and a live key written as a literal is a key in a git" ,
"repo. Move the ones that are plainly not secret back to literals yourself." ,
] ) ,
) } \ n $ { pairs . map ( ( [ key , ref ] ) = > ` ${ key } = \ ${ $ { ref } } ` ) . join ( "\n" ) } \ n ` ,
} ) ;
}
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
// Suppressed names never reach the store. A `SOURCE_COMMIT` in it would be
// a name waiting for a template to reference it — and the store is the one
// artifact a reviewer does not read, because it is encrypted.
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
const vars = Object . fromEntries (
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
secrets . dispositions
. filter ( ( d ) = > d . provenance !== "suppressed" )
. map ( ( d ) = > [ d . ref , d . value ] ) ,
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
) ;
if ( Object . keys ( vars ) . length > 0 ) {
stores . push ( {
project : p.name ,
path : join ( "secrets" , ` ${ storeKey ( p ) } . ${ ctx . env } .env.age ` ) ,
vars ,
} ) ;
}
}
files . push ( {
path : "environments.yaml" ,
content : ` ${ comment (
header ( ctx , [
"The state file this instance implies — bindings as far as they can be READ, plus" ,
"`projects:`, the registry: the list of what exists, which nothing before a" ,
"whole-instance sweep was able to write down." ,
"" ,
2026-07-16 15:22:40 +00:00
"`github_apps` IS readable (cast#72): an application carries the `source_id`" ,
` of the App that clones it, and this instance lists ${ ctx . githubApps ? . length ? ? 0 } , so cast ` ,
"resolved each repo's App by that match. A repo whose application resolves to" ,
"none (a public repo, or an unlistable instance) gets a `REVIEW-…` marker;" ,
"`apply` refuses on those until you fix them." ,
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
"" ,
"Do not copy it over a state file you already have. Merge the registry into" ,
"yours, by hand, having decided which of these projects are yours to declare." ,
] ) ,
) } \ n \ n $ { stringify ( bindingsDoc ( projects , ctx ) ) } ` ,
} ) ;
files . push ( {
path : "UNCAPTURED.md" ,
content : renderUncaptured ( uncaptured , projects , ctx ) ,
} ) ;
return { files , stores , dispositions , uncaptured } ;
}
// The repo short name when the box knows the repo, else the project's slug —
// `incubator/`, `la-familia-site/`. Only a path; nothing resolves by it.
function projectDir ( p : DraftProject ) : string {
const repo = registryKey ( p ) ;
const short = repo . includes ( "/" ) ? repo . split ( "/" ) [ 1 ] : repo ;
return slug ( short ) ;
}
const storeKey = ( p : DraftProject ) = > projectDir ( p ) ;
// The `<org>/<repo>` slug the registry is keyed by — and the box only knows it
// through an APPLICATION's git remote. A project with no application (a lone
// service, a database somebody made once) has no repo on the box at all, so cast
// writes the bare project name and the registry's own parse-time refusal ("a
// registry key has no meaning without its org", #25) stops the file being used
// until a human supplies the org.
//
// That refusal is the right outcome, and deliberately not worked around: the
// alternatives are inventing an org, or leaving the project out of the list — and
// a project missing from the registry is a project every fleet run skips in
// silence, which reads exactly like a clean one.
function registryKey ( p : DraftProject ) : string {
for ( const r of p . resources ) {
if ( r . kind !== "application" ) continue ;
const repo = repoFromGitUrl ( r . raw . git_repository ) ;
if ( repo ) return repo ;
}
return slug ( p . name ) ;
}
2026-07-16 15:22:40 +00:00
// The GitHub App a project's repo is cloned by, resolved from an APPLICATION's
// `source_id` against the instance's Apps (cast#72). Coolify hides neither
// `source_id` nor `source_type` on an application, and GET /github-apps returns
// each App's `id` — so this is a lookup, not a guess. Only a GithubApp source
// counts: a public-repo application has a different `source_type` and no App to
// bind, and a numeric `source_id` that happens to collide with an App id must
// not be mistaken for one. The first application that resolves wins; a project
// whose apps resolve to none gets a REVIEW marker (see bindingsDoc).
function githubAppNameForProject (
p : DraftProject ,
appById : Map < number , string > ,
) : string | undefined {
for ( const r of p . resources ) {
if ( r . kind !== "application" ) continue ;
if ( ! /github.?app/i . test ( String ( r . raw . source_type ? ? "" ) ) ) continue ;
const id = r . raw . source_id ;
const name = typeof id === "number" ? appById . get ( id ) : undefined ;
if ( name ) return name ;
}
return undefined ;
}
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
// The bindings the box implies — plus `projects:`, THE REGISTRY (#25): the list
// of what exists, which nothing before a whole-instance sweep was in a position
// to write down. A rebuild cannot even be attempted without it, because you
// cannot restore what you cannot enumerate.
function bindingsDoc ( projects : DraftProject [ ] , ctx : DraftContext ) {
const registry : Record < string , { environments : string [ ] } > = { } ;
const githubApps : Record < string , string > = { } ;
2026-07-16 15:22:40 +00:00
// Resolve each repo's App by the read, not a guess (cast#72): an application
// carries the `source_id` of the App that clones it, and ctx.githubApps carries
// each App's `id` and `name`. A guess is what this used to be — binding every
// repo to the only App there was — and it was silently wrong for any public
// repo (source_type is not a GithubApp) even on a single-App instance, and
// unmakeable on a multi-App one. A REVIEW marker resolves to nothing and
// `apply` says so; a wrong App resolves to a real uuid and clones the wrong
// repo, silently (githubAppNameFor, #12).
const appById = new Map ( ( ctx . githubApps ? ? [ ] ) . map ( ( a ) = > [ a . id , a . name ] ) ) ;
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
for ( const p of projects ) {
// Only what the draft actually carries a manifest for. See planDraft.
if ( p . resources . length === 0 ) continue ;
const repo = registryKey ( p ) ;
registry [ repo ] = { environments : [ ctx . env ] } ;
githubApps [ repo ] =
2026-07-16 15:22:40 +00:00
githubAppNameForProject ( p , appById ) ? ?
"REVIEW-which-github-app-in-coolify-clones-this-repo" ;
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
}
return {
environments : {
[ ctx . env ] : {
// NOT readable off a resource: Coolify's environment_details response
// carries no server. What is written here is the server the environment
// you swept UNDER is bound to — a fact about your state file, not about
// this box. With no binding to read it from, it is left for review, and
// cast's own schema will refuse the file until it is filled in.
server : ctx.server ? ? "REVIEW-no-server-is-readable-from-a-live-box" ,
team : { id : ctx.team.id , name : ctx.team.name } ,
instance : ctx.instance ,
. . . ( ctx . recipient ? { age_recipient : ctx.recipient } : { } ) ,
} ,
} ,
projects : registry ,
github_apps : githubApps ,
} ;
}
// --- Refusals ----------------------------------------------------------------
// A draft is emitted into a NEW directory, and only ever into one.
//
// The refusal is not tidiness. `--emit-draft .` inside a product repo would write
// a manifest generated from a live box straight over the reviewed one — and a
// manifest regenerated from a box carries everything that box has accumulated
// that nobody meant. Adoption is one-way: the repo is the truth for a project it
// already declares, and the draft is a proposal for one it does not.
export function assertEmptyTarget ( dir : string ) : void {
if ( ! existsSync ( dir ) ) return ;
const entries = readdirSync ( dir ) ;
if ( entries . length === 0 ) return ;
throw new Error (
[
` refusing to emit a draft: ${ dir } is not empty ` ,
"" ,
" looked for: an empty or non-existent directory" ,
` found: ${ entries . slice ( 0 , 8 ) . join ( ", " ) } ${ entries . length > 8 ? ` , … ( ${ entries . length } entries) ` : "" } ` ,
"" ,
"A draft is a PROPOSAL, machine-generated from a live box, and it is written" ,
"into a directory of its own so that it can be READ before any of it is" ,
"believed. Emitted over a repo that already has a manifest, it would overwrite" ,
"a reviewed spec with whatever that box has accumulated — the one direction" ,
"nobody reviews. Adoption is one-way." ,
"" ,
"Point --emit-draft at a new directory, and land what survives review as a PR." ,
] . join ( "\n" ) ,
) ;
}
// The same rule, once more at the file. Unreachable through the CLI (an empty
// target cannot hold a manifest) and kept anyway: it is the invariant, not the
// check that happens to enforce it today, and the next caller of emitDraft will
// not have read assertEmptyTarget.
export function assertNoExistingManifest ( path : string ) : void {
if ( ! existsSync ( path ) ) return ;
throw new Error (
[
` refusing to emit a draft: ${ path } already exists ` ,
"" ,
"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, silently. A draft is" ,
"for a project that has NO manifest; for one that has, `cast inventory <org>/<repo>`" ,
"reconciles the two and you decide, key by key, what the manifest should gain." ,
] . join ( "\n" ) ,
) ;
}
// `--emit-draft` with a repo positional. Not an argument-parsing nicety: the two
// flags mean opposite things about the same box, and the combination is the one
// that would do damage.
export function renderRepoWithDraft ( orgRepo : string , dir : string ) : string {
return [
"refusing to emit a draft: --emit-draft is a SWEEP-mode flag, and a repo was given" ,
"" ,
` looked at: ${ orgRepo } ` ,
` emitting to: ${ dir } ` ,
"" ,
"With a repo, `inventory` reconciles a box against a manifest that ALREADY EXISTS." ,
"That is precisely the case where a draft must not be written: for a declared" ,
"project the manifest is the truth, and one regenerated from a live box would" ,
"carry back everything that box has accumulated that nobody meant — over the top" ,
"of a reviewed spec, in the one direction nobody reviews. Adoption is one-way." ,
"" ,
"To reconcile a project that has a manifest:" ,
"" ,
` cast inventory ${ orgRepo } --env <env> [--project <name>] [--environment <name>] ` ,
"" ,
"To draft the projects that have none:" ,
"" ,
` cast inventory --env <env> --emit-draft ${ dir } ` ,
] . join ( "\n" ) ;
}
// No recipient, and no explicit opt-out. Refuse — never quietly emit a draft
// with no store in it.
//
// A draft whose secrets were silently skipped looks COMPLETE: a manifest, env
// templates full of ${REF}s, an UNCAPTURED.md — and not one value anywhere. You
// would find out when `apply` refused for want of a store, which is some time
// after the box those values were on stopped existing.
export function renderNoRecipient ( envName : string ) : string {
return [
` refusing to emit a draft: no age recipient for ${ envName } ` ,
"" ,
` looked for: --recipient, then environments. ${ envName } .age_recipient ` ,
"" ,
"The draft's stores are encrypted to a recipient you NAME. cast will not skip" ,
"them for you: a draft with templates full of ${REF}s and no store behind them" ,
"looks complete, and the values it did not write are on a box you are about to" ,
"stop paying for." ,
"" ,
"Name one:" ,
"" ,
" cast inventory --env <env> --emit-draft <dir> --recipient age1…" ,
"" ,
"or bind it (it is the public half — safe to commit next to the bindings):" ,
"" ,
" environments:" ,
` ${ envName } : ` ,
" age_recipient: age1…" ,
"" ,
"or say, explicitly, that you want the structure without the values:" ,
"" ,
" cast inventory --env <env> --emit-draft <dir> --no-secrets" ,
] . join ( "\n" ) ;
}
// A project with resources in two environments. cast will not pick one.
//
// Picking would produce a blueprint of HALF A BOX that says nothing about the
// other half — the exact artifact this verb exists to not produce. And the
// likeliest split is the one that has already bitten this project once: a
// Coolify-auto-created `production` beside the `staging` where everything
// actually runs (#22).
export function renderAmbiguousEnvironments (
projects : Array < { name : string ; environments : string [ ] } > ,
envName : string ,
) : string {
return [
` refusing to emit a draft: ${ projects . length } project(s) have resources in MORE THAN ONE environment ` ,
"" ,
. . . projects . flatMap ( ( p ) = > [
` ${ p . name } ` ,
. . . p . environments . map ( ( e ) = > ` ${ e } ` ) ,
] ) ,
"" ,
"A draft carries ONE environment per project. Picking for you would emit a" ,
"blueprint of half a box that says nothing about the other half — and the box" ,
"you are looking at is the one where that already happened once: Coolify" ,
"auto-creates `production` in every project, so the environment things actually" ,
"run in is whatever someone typed instead." ,
"" ,
"Say which:" ,
"" ,
` cast inventory --env ${ envName } --emit-draft <dir> --environment <name> ` ,
"" ,
"and run it once per environment you mean to keep, into a directory each." ,
] . join ( "\n" ) ;
}
// --- Emit --------------------------------------------------------------------
export function emitDraft (
dir : string ,
plan : DraftPlan ,
opts : { recipient? : string } ,
) : string [ ] {
assertEmptyTarget ( dir ) ;
const written : string [ ] = [ ] ;
for ( const f of plan . files ) {
const path = join ( dir , f . path ) ;
if ( path . endsWith ( "manifest.yaml" ) ) assertNoExistingManifest ( path ) ;
mkdirSync ( dirname ( path ) , { recursive : true } ) ;
writeFileSync ( path , f . content ) ;
written . push ( f . path ) ;
}
if ( opts . recipient ) {
for ( const s of plan . stores ) {
const path = join ( dir , s . path ) ;
mkdirSync ( dirname ( path ) , { recursive : true } ) ;
encryptSecrets ( opts . recipient , path , s . vars ) ;
written . push ( s . path ) ;
}
}
return written ;
}
// --- The plan, printed -------------------------------------------------------
//
// Names and provenance. NEVER values — same contract as renderCapturePlan, and
// for the same reason: this output is meant to be pasted into a PR discussion.
// The one value-shaped thing here is the GENERATED_PLACEHOLDER literal, which
// carries no information about the box.
export function renderDraftPlan (
plan : DraftPlan ,
ctx : DraftContext ,
opts : { dir : string ; recipient? : string ; written : string [ ] } ,
) : string {
const lines = [
"" ,
` draft — emitted to ${ opts . dir } ` ,
"" ,
` source: instance ${ ctx . instance } ( ${ ctx . baseUrl } ) — read LIVE ` ,
` filed as: environment ${ ctx . env } ` ,
` secrets: ${ opts . recipient ? ` encrypted to ${ opts . recipient } ` : "NOT WRITTEN (--no-secrets)" } ` ,
"" ,
. . . opts . written . map ( ( w ) = > ` + ${ w } ` ) ,
"" ,
] ;
if ( plan . dispositions . length > 0 ) {
const width = Math . max ( . . . plan . dispositions . map ( ( d ) = > d . ref . length ) ) ;
lines . push (
"every value cast read, and what it did with it — names and provenance, never values:" ,
"" ,
) ;
for ( const d of [ . . . plan . dispositions ] . sort ( ( a , b ) = >
a . project === b . project
? a . ref . localeCompare ( b . ref )
: a . project . localeCompare ( b . project ) ,
) ) {
const note =
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
d . provenance === "generated"
? ` → ${ GENERATED_PLACEHOLDER } `
: d . provenance === "suppressed"
? " → NOT COPIED (Coolify injects this itself)"
: "" ;
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
lines . push (
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
` ${ d . ref . padEnd ( width ) } ${ d . provenance . padEnd ( 10 ) } ${ d . sites . join ( ", " ) } ${ note } ` ,
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
) ;
}
const generated = plan . dispositions . filter (
( d ) = > d . provenance === "generated" ,
) . length ;
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
const suppressed = plan . dispositions . filter (
( d ) = > d . provenance === "suppressed" ,
) . length ;
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
lines . push (
"" ,
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
` ${ plan . dispositions . length } name(s): ${ plan . dispositions . length - generated - suppressed } captured, ${ generated } placeheld as provider-generated ${ suppressed > 0 ? ` , ${ suppressed } suppressed (reserved by Coolify — and a live bug on the box you drafted from: see UNCAPTURED.md) ` : "" } . ` ,
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
"" ,
"A placeheld name's LIVE VALUE WAS NOT READ INTO ANY FILE. A DATABASE_URL copied" ,
"off this box points at THIS box's Postgres: a rebuilt box carrying it comes up" ,
"working, against the old box's database, and you find out the day the old box is" ,
"deleted. The rule is by NAME — read the captured list above and decide whether" ,
"anything in it is really a pointer at this box." ,
"" ,
) ;
}
lines . push (
` ${ plan . uncaptured . length } setting(s) cast SAW and could not express — every one is in ${ join ( opts . dir , "UNCAPTURED.md" ) } . ` ,
"Read it before you treat this as a blueprint." ,
"" ,
"This is a PROPOSAL. `apply` does not read it. The path from here:" ,
"" ,
" review it → land the manifest in the product repo as a PR → capture → apply" ,
) ;
return lines . join ( "\n" ) ;
}