cast/src/manifest.ts

127 lines
4.2 KiB
TypeScript
Raw Normal View History

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 { readFileSync } from "node:fs";
import { parse } from "yaml";
import { z } from "zod";
const AppSpecSchema = z
.object({
source: z.object({ repo: z.string(), branch: z.string() }).strict(),
build: z
.object({
pack: z.enum(["nixpacks", "static", "dockerfile", "dockercompose"]),
base_directory: z.string(),
publish_directory: z.string().optional(),
compose_file: z.string().optional(),
})
.strict(),
port: z.number().int().optional(),
healthcheck: z.string().optional(),
domains: z.array(z.string()).optional(),
service_domains: z.record(z.array(z.string())).optional(),
env_template: z.string().optional(),
})
.strict()
.superRefine((app, ctx) => {
if (app.build.pack === "dockercompose") {
if (!app.build.compose_file)
ctx.addIssue({
code: "custom",
message: "dockercompose apps require build.compose_file",
});
if (!app.service_domains)
ctx.addIssue({
code: "custom",
message: "dockercompose apps require service_domains",
});
for (const k of ["port", "healthcheck", "domains"] as const)
if (app[k] !== undefined)
ctx.addIssue({
code: "custom",
message: `${k} not allowed on a dockercompose app (lives in the compose file)`,
});
if (app.build.publish_directory)
ctx.addIssue({
code: "custom",
message: "publish_directory not allowed on a dockercompose app",
});
} else {
if (!app.domains)
ctx.addIssue({
code: "custom",
message: "domains required (non-compose app)",
});
if (app.service_domains || app.build.compose_file)
ctx.addIssue({
code: "custom",
message:
"service_domains/compose_file only allowed with pack dockercompose",
});
}
});
const DatabaseSpecSchema = z
.object({
type: z.enum(["postgresql", "redis"]),
version: z.string().optional(),
backup: z
.object({ frequency: z.string(), retention: z.number().int() })
.strict()
.optional(),
})
.strict();
const ServiceSpecSchema = z
.object({
type: z.string(),
domains: z.array(z.string()).optional(),
env_template: z.string().optional(),
})
.strict();
const EnvironmentSpecSchema = z
.object({
applications: z.record(AppSpecSchema),
databases: z.record(DatabaseSpecSchema).optional(),
services: z.record(ServiceSpecSchema).optional(),
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
// Secret names whose values the PROVIDER generates — a Coolify-created
// Postgres/Redis URL, a service's own generated credentials. `capture`
// writes these as the literal `pending-coolify-generated` and never copies
// the source box's live value: that value points at the SOURCE box's
// database, so carrying it over would be confidently wrong in a way that
// looks entirely plausible, and the target's real URL does not exist until
// Coolify creates the resource.
//
// It is a manifest property rather than a flag the operator has to
// remember, because the manifest is what knows DATABASE_URL comes from a
// database it declares. Optional: a manifest that names none simply has no
// generated secrets, and `capture` will say so in its plan.
generated_secrets: z.array(z.string()).optional(),
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
})
.strict();
const ManifestSchema = z
.object({
project: z.string(),
environments: z.record(EnvironmentSpecSchema),
})
.strict();
export type AppSpec = z.infer<typeof AppSpecSchema>;
export type DatabaseSpec = z.infer<typeof DatabaseSpecSchema>;
export type ServiceSpec = z.infer<typeof ServiceSpecSchema>;
export type EnvironmentSpec = z.infer<typeof EnvironmentSpecSchema>;
export type Manifest = z.infer<typeof ManifestSchema>;
export function loadManifest(
path: string,
opts: { overrideText?: string } = {},
): Manifest {
const text = opts.overrideText ?? readFileSync(path, "utf8");
const result = ManifestSchema.safeParse(parse(text));
if (!result.success) {
throw new Error(
`invalid manifest ${path}: ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
);
}
return result.data;
}