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>
117 lines
5.4 KiB
TypeScript
117 lines
5.4 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { parse } from "yaml";
|
|
import { z } from "zod";
|
|
|
|
// The team an environment's token MUST belong to. Give `id` (the true auth
|
|
// identity — names can be renamed or duplicated), `name` (human-readable,
|
|
// greppable), or both; both are checked when both are given. `cast team`
|
|
// prints the current token's id and name so this can be filled in.
|
|
const TeamSchema = z
|
|
.object({
|
|
// Non-negative, NOT positive: team **0 is the Root Team** — the one the
|
|
// first user of an instance gets (`if ($user->id === 0) { $team['id'] = 0;
|
|
// $team['name'] = 'Root Team'; }`, app/Models/User.php @ v4.1.2). On a
|
|
// single-admin Coolify that is the team everything lives in, so rejecting
|
|
// 0 would make the id check — the strong half of the assert — unusable on
|
|
// exactly the topology that most needs it.
|
|
id: z.number().int().nonnegative().optional(),
|
|
name: z.string().min(1).optional(),
|
|
})
|
|
.strict()
|
|
.refine((t) => t.id !== undefined || t.name !== undefined, {
|
|
message: "team must give at least one of `id` or `name`",
|
|
});
|
|
|
|
const BindingsSchema = z
|
|
.object({
|
|
environments: z.record(
|
|
z
|
|
.object({
|
|
server: z.string(),
|
|
// REQUIRED, and deliberately so: this is what makes the team assert
|
|
// fail-closed (see team.ts). An environment with no declared team
|
|
// is an environment cast cannot verify it is pointed at — and an
|
|
// unverifiable target is exactly the silent-duplicate-into-the-
|
|
// wrong-team failure this binding exists to prevent. No team, no
|
|
// apply. Today one state dir holds one token (.coolify.env), so
|
|
// every environment in it normally names the same team; declaring
|
|
// it per environment keeps each one's expectation explicit and
|
|
// survives a future split into per-environment tokens.
|
|
team: TeamSchema,
|
|
// The named Coolify instance this environment lives on
|
|
// (<state>/.coolify/<name>.env). Optional: with no binding and no
|
|
// --instance, cast reads <state>/.coolify.env exactly as it always
|
|
// has. Binding it here is what lets `--env prod` select the right
|
|
// control plane with no flag and no file edit — the connection
|
|
// target stops being implicit in a file's current contents.
|
|
// An explicit --instance still wins, so a one-off read against a
|
|
// legacy box needs no change to this file either.
|
|
instance: z.string().optional(),
|
|
// The age recipient (public key) this environment's secret store is
|
|
// encrypted TO. Only `capture` needs it — decryption resolves an
|
|
// identity per keyFileFor, and the state repo deliberately holds
|
|
// ciphertext but never the identity that opens it. This is the
|
|
// public half, so it is safe to commit here next to the bindings.
|
|
age_recipient: z.string().optional(),
|
|
s3_destination: z.string().optional(),
|
|
// Var-name patterns this environment refuses outright (see
|
|
// assertEnvVarPolicy). Operator-owned guard: prod typically bans
|
|
// whatever family of flags enables destructive tooling.
|
|
forbidden_var_patterns: z.array(z.string()).optional(),
|
|
})
|
|
.strict(),
|
|
),
|
|
// Keyed by the repo the App clones for. Prefer the FULL `<org>/<repo>`
|
|
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
|
|
// existing state files keep working.
|
|
github_apps: z.record(z.string()),
|
|
smoke_target: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export type Bindings = z.infer<typeof BindingsSchema>;
|
|
|
|
// Resolve the Coolify GitHub App name for a repo, full slug first.
|
|
//
|
|
// The short name alone is not a key: `<repo>` is only unique *within* an org,
|
|
// so `heavy-duty/incubator` and `acme/incubator` collapse onto one entry and
|
|
// whichever App is bound there gets used to clone BOTH — silently, because a
|
|
// wrong-but-existing App resolves to a real uuid and the create succeeds. The
|
|
// full slug is the thing that actually identifies a repo, so it wins.
|
|
//
|
|
// The bare-`<repo>` fallback is kept deliberately: it is what every state file
|
|
// written before this used, and dropping it would break them for no gain. A
|
|
// short key is unambiguous right up until a second org shows up, which is
|
|
// precisely when the full-slug key it falls back from starts winning instead.
|
|
export function githubAppNameFor(bindings: Bindings, orgRepo: string): string {
|
|
const repoShort = orgRepo.split("/")[1] ?? orgRepo;
|
|
const name = bindings.github_apps[orgRepo] ?? bindings.github_apps[repoShort];
|
|
if (!name) {
|
|
throw new Error(
|
|
[
|
|
`no GitHub App bound for ${orgRepo}`,
|
|
"",
|
|
` looked for: github_apps["${orgRepo}"], then github_apps["${repoShort}"]`,
|
|
` bound repos: ${Object.keys(bindings.github_apps).join(", ") || "(none)"}`,
|
|
"",
|
|
"Add it to environments.yaml, keyed by the full slug:",
|
|
"",
|
|
" github_apps:",
|
|
` ${orgRepo}: <the App's name in Coolify>`,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
export function loadBindings(
|
|
path: string,
|
|
opts: { overrideText?: string } = {},
|
|
): Bindings {
|
|
const text = opts.overrideText ?? readFileSync(path, "utf8");
|
|
const result = BindingsSchema.safeParse(parse(text));
|
|
if (!result.success) {
|
|
throw new Error(`invalid bindings ${path}: ${result.error.message}`);
|
|
}
|
|
return result.data;
|
|
}
|