Commit graph

2 commits

Author SHA1 Message Date
claude-hdb
cd1864aaad 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
claude-hdb
e96bab5d79 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