cast/src/manifest.ts

114 lines
3.3 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(),
})
.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;
}