cast/src/resolve.ts

128 lines
4.5 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 { 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 };
}