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>
127 lines
4.5 KiB
TypeScript
127 lines
4.5 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { Desired } from "./diff.js";
|
|
import { type ResolvedEnv, resolveTemplate } from "./envtemplate.js";
|
|
import { loadManifest } from "./manifest.js";
|
|
|
|
export function resolveCheckout(
|
|
orgRepo: string,
|
|
opts: { env: string; path?: string },
|
|
): string {
|
|
if (opts.path && opts.env === "prod") {
|
|
throw new Error(
|
|
"apply refuses --path with --env prod: prod always reads the default branch",
|
|
);
|
|
}
|
|
if (opts.path) return opts.path;
|
|
const dir = mkdtempSync(join(tmpdir(), "infra-checkout-"));
|
|
execFileSync(
|
|
"git",
|
|
["clone", "--depth", "1", `https://github.com/${orgRepo}.git`, dir],
|
|
{
|
|
stdio: "pipe",
|
|
},
|
|
);
|
|
return dir;
|
|
}
|
|
|
|
export function desiredFromManifest(
|
|
checkoutDir: string,
|
|
envName: string,
|
|
secrets: Record<string, string>,
|
|
): {
|
|
desired: Desired[];
|
|
resolvedEnvs: Record<string, ResolvedEnv>;
|
|
backupSchedules: Record<string, { frequency: string; retention: number }>;
|
|
} {
|
|
const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml"));
|
|
const envSpec = manifest.environments[envName];
|
|
if (!envSpec) {
|
|
throw new Error(
|
|
`environment ${envName} not in manifest (has: ${Object.keys(manifest.environments).join(", ") || "none"})`,
|
|
);
|
|
}
|
|
const desired: Desired[] = [];
|
|
const resolvedEnvs: Record<string, ResolvedEnv> = {};
|
|
const backupSchedules: Record<
|
|
string,
|
|
{ frequency: string; retention: number }
|
|
> = {};
|
|
const resolveEnvFile = (
|
|
name: string,
|
|
template?: string,
|
|
): ResolvedEnv | undefined => {
|
|
if (!template) return undefined;
|
|
const file = join(checkoutDir, ".infra", "env", template);
|
|
if (!existsSync(file))
|
|
throw new Error(`env template missing: ${file} (referenced by ${name})`);
|
|
const env = resolveTemplate(readFileSync(file, "utf8"), secrets);
|
|
resolvedEnvs[name] = env;
|
|
return env;
|
|
};
|
|
for (const [name, app] of Object.entries(envSpec.applications)) {
|
|
desired.push({
|
|
kind: "application",
|
|
name,
|
|
fields: {
|
|
git_repository: app.source.repo,
|
|
git_branch: app.source.branch,
|
|
build_pack: app.build.pack,
|
|
base_directory: app.build.base_directory,
|
|
...(app.build.publish_directory
|
|
? { publish_directory: app.build.publish_directory }
|
|
: {}),
|
|
...(app.build.pack === "dockercompose"
|
|
? {
|
|
docker_compose_location: app.build.compose_file,
|
|
docker_compose_domains: app.service_domains,
|
|
}
|
|
: {
|
|
...(app.port !== undefined ? { port: app.port } : {}),
|
|
...(app.healthcheck ? { healthcheck: app.healthcheck } : {}),
|
|
domains: app.domains,
|
|
}),
|
|
},
|
|
env: resolveEnvFile(name, app.env_template),
|
|
});
|
|
}
|
|
for (const [name, db] of Object.entries(envSpec.databases ?? {})) {
|
|
desired.push({
|
|
kind: "database",
|
|
name,
|
|
fields: { type: db.type, ...(db.version ? { version: db.version } : {}) },
|
|
});
|
|
if (db.backup)
|
|
backupSchedules[name] = {
|
|
frequency: db.backup.frequency,
|
|
retention: db.backup.retention,
|
|
};
|
|
}
|
|
for (const [name, svc] of Object.entries(envSpec.services ?? {})) {
|
|
if (svc.domains && svc.domains.length > 0) {
|
|
// Coolify 4.1.2's service executor has no flat `domains` concept —
|
|
// hostnames live per-container on `urls` (see serviceApiFields in
|
|
// cli.ts) — so a manifest-declared service `domains` list is silently
|
|
// unhonorable by apply. Warn at build time, once per run, while the
|
|
// service name is still in scope.
|
|
console.warn(
|
|
`service ${name} declares domains (${svc.domains.join(", ")}), but apply cannot set them on Coolify 4.1.2 services — configure hostnames manually in the Coolify UI`,
|
|
);
|
|
}
|
|
desired.push({
|
|
kind: "service",
|
|
name,
|
|
// domains dropped from fields, same as database `backup` above: the
|
|
// live side (projectLiveFields in cli.ts) can't read service domains
|
|
// and the write side (serviceApiFields) drops them, so keeping
|
|
// domains in fields makes every domain-bearing service diff as a
|
|
// perpetual update. Hostnames stay a manual Coolify UI act (warned
|
|
// above).
|
|
fields: { type: svc.type },
|
|
env: resolveEnvFile(name, svc.env_template),
|
|
});
|
|
}
|
|
return { desired, resolvedEnvs, backupSchedules };
|
|
}
|