feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
|
|
|
import { execFileSync } from "node:child_process";
|
2026-07-13 22:43:00 +00:00
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
|
|
|
import { homedir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
|
2026-07-13 22:43:00 +00:00
|
|
|
// The identity is read here and handed to age on stdin (`-i -`), never as a
|
|
|
|
|
// path: keyFile may be a process substitution (`CAST_AGE_KEY_FILE_PROD=<(pm
|
|
|
|
|
// read …)` → /proc/self/fd/N), and that path resolves only inside the process
|
|
|
|
|
// holding the fd — this one. A freshly spawned age has no such fd and fails
|
|
|
|
|
// with ENOENT. Not `-i /dev/stdin` either: node closes the pipe before age
|
|
|
|
|
// re-opens it by path (ENXIO); `-` makes age read the inherited fd directly.
|
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
|
|
|
export function decryptSecrets(
|
|
|
|
|
file: string,
|
|
|
|
|
keyFile: string,
|
|
|
|
|
): Record<string, string> {
|
2026-07-13 22:43:00 +00:00
|
|
|
const out = execFileSync("age", ["-d", "-i", "-", file], {
|
|
|
|
|
input: readFileSync(keyFile),
|
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
|
|
|
encoding: "utf8",
|
|
|
|
|
});
|
|
|
|
|
const secrets: Record<string, string> = {};
|
|
|
|
|
for (const raw of out.split("\n")) {
|
|
|
|
|
const line = raw.trim();
|
|
|
|
|
if (line === "" || line.startsWith("#")) continue;
|
|
|
|
|
const eq = line.indexOf("=");
|
|
|
|
|
if (eq === -1)
|
|
|
|
|
throw new Error("age store: malformed line (expected KEY=value)");
|
|
|
|
|
secrets[line.slice(0, eq)] = line.slice(eq + 1);
|
|
|
|
|
}
|
|
|
|
|
return secrets;
|
|
|
|
|
}
|
|
|
|
|
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
// Write an environment's secret store, encrypted to its recipient.
|
|
|
|
|
//
|
|
|
|
|
// The plaintext goes to age on STDIN and the ciphertext straight to `file`: it
|
|
|
|
|
// is never a temp file, never reaches the terminal, and never lands in shell
|
|
|
|
|
// history. The hand-run recipe this replaces assembled /dev/shm/prod.env and
|
|
|
|
|
// relied on remembering to `shred -u` it afterwards — a step that is invisible
|
|
|
|
|
// when it is skipped.
|
|
|
|
|
export function encryptSecrets(
|
|
|
|
|
recipient: string,
|
|
|
|
|
file: string,
|
|
|
|
|
vars: Record<string, string>,
|
|
|
|
|
): void {
|
|
|
|
|
const plaintext = `${Object.entries(vars)
|
|
|
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
|
|
|
.join("\n")}\n`;
|
|
|
|
|
execFileSync("age", ["-r", recipient, "-o", file], {
|
|
|
|
|
input: plaintext,
|
|
|
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
|
|
|
// The age identity for an environment, resolved without cast knowing anything
|
|
|
|
|
// about your environment names:
|
|
|
|
|
//
|
|
|
|
|
// 1. $CAST_AGE_KEY_FILE_<ENV> — injected for this invocation
|
|
|
|
|
// 2. ~/.config/cast/age-<env>.key — a standing key on this machine
|
|
|
|
|
//
|
|
|
|
|
// This is the whole mechanism behind attended vs unattended applies: an
|
|
|
|
|
// environment whose key you never leave on disk can only be applied by an
|
|
|
|
|
// operator who injects it. Keep the key OUT of the state repo — the state repo
|
|
|
|
|
// holds ciphertext, never the identity that opens it.
|
|
|
|
|
export function keyFileFor(envName: string): string {
|
|
|
|
|
const injected = process.env[`CAST_AGE_KEY_FILE_${envName.toUpperCase()}`];
|
|
|
|
|
if (injected) return injected;
|
|
|
|
|
const standing = join(homedir(), ".config", "cast", `age-${envName}.key`);
|
|
|
|
|
if (existsSync(standing)) return standing;
|
|
|
|
|
throw new Error(
|
|
|
|
|
`no age key for ${envName}: set CAST_AGE_KEY_FILE_${envName.toUpperCase()} (attended apply) or place a standing key at ${standing}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function secretsFileFor(
|
|
|
|
|
stateDir: string,
|
|
|
|
|
repoShortName: string,
|
|
|
|
|
envName: string,
|
|
|
|
|
): string {
|
|
|
|
|
return join(stateDir, "secrets", `${repoShortName}.${envName}.env.age`);
|
|
|
|
|
}
|