cast/src/resolve.ts
Daniel Marin da5f6f2738
Merge pull request #120 from dan-claude-bot/fix/test-tmpdir-leak
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
2026-07-21 14:21:59 +01:00

867 lines
38 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Desired } from "./diff.js";
import {
type ResolvedEnv,
fillDerivedEnv,
fillDomainEnv,
resolveTemplate,
templateDomainRefs,
templateKeys,
templateRefs,
templateResourceRefs,
} from "./envtemplate.js";
import type { AppSpec, EnvironmentSpec } from "./manifest.js";
import { loadManifest, storeRefName } from "./manifest.js";
import {
type ReservedHit,
assertNoReservedEnvNames,
reservedHits,
} from "./reserved.js";
// How cast authenticated (or failed to authenticate) a clone.
//
// gh — `gh` is installed and holds a token; borrowed as a credential
// helper for this invocation only
// token — GITHUB_TOKEN / GH_TOKEN in the environment (the CI path)
// ambient — neither; whatever git's own credential helper does, if anything
export type GitAuth = {
source: "gh" | "token" | "ambient";
configArgs: string[];
env: Record<string, string>;
};
// A credential helper reads the token from the ENVIRONMENT at run time. The
// alternatives both leak it: a token in the clone URL shows up in `ps` and in
// git's own error messages, and `http.extraheader` additionally persists into
// the clone's .git/config. What lands in argv here is the literal text
// `$CAST_GIT_TOKEN`, never its value.
const TOKEN_HELPER =
'!f() { test "$1" = get || exit 0; echo username=x-access-token; echo "password=$CAST_GIT_TOKEN"; }; f';
// `gh auth login` alone does NOT wire git's credential helper — that is
// `gh auth setup-git`, a separate act most people never run. So being logged
// into `gh` does not make `git clone` work, which is exactly the trap #13
// fell into. Borrowing gh as a helper for this one invocation closes that gap
// without mutating the operator's global git config.
const GH_HELPER = "!gh auth git-credential";
function ghHasToken(): boolean {
try {
// A local keyring/config read, not a network call. We never keep the
// value — the helper re-reads it inside git.
execFileSync("gh", ["auth", "token"], { stdio: "pipe" });
return true;
} catch {
return false;
}
}
// Resolve clone credentials INSIDE cast, in a fixed order, rather than leaving
// it to whatever the ambient git config happens to do. `credential.helper=`
// (empty) first RESETS the inherited helper list — otherwise a helper
// configured globally is consulted before ours and silently decides the
// outcome, which is the same "the connection target is implicit in a file's
// contents" problem #14 is about.
export function resolveGitAuth(
env: NodeJS.ProcessEnv = process.env,
hasGh: () => boolean = ghHasToken,
): GitAuth {
if (hasGh()) {
return {
source: "gh",
configArgs: [
"-c",
"credential.helper=",
"-c",
`credential.helper=${GH_HELPER}`,
],
env: {},
};
}
const token = env.GITHUB_TOKEN || env.GH_TOKEN;
if (token) {
return {
source: "token",
configArgs: [
"-c",
"credential.helper=",
"-c",
`credential.helper=${TOKEN_HELPER}`,
],
env: { CAST_GIT_TOKEN: token },
};
}
return { source: "ambient", configArgs: [], env: {} };
}
// GitHub answers "you cannot see this" with a 404, not a 403 — so a private
// repo you lack access to and a repo that does not exist are the same message
// on the wire. The failure text must not pick one; it has to name both, and
// name the credential cast actually used, or the operator debugs the wrong
// half. (The original bug reported *the repository* when the real fault was
// cast's missing credentials.)
export function cloneFailureMessage(
orgRepo: string,
auth: GitAuth,
stderr: string,
): string {
const detail = stderr.trim();
const tail = detail
? ["", "git said:", ...detail.split("\n").map((l) => ` ${l}`)]
: [];
if (auth.source === "ambient") {
return [
`cannot clone ${orgRepo}: no GitHub credentials.`,
"",
"cast looked for, in order:",
" 1. `gh` — not installed, or not logged in (`gh auth token` failed)",
" 2. GITHUB_TOKEN / GH_TOKEN — not set in the environment",
" 3. git's own credential helper — did not supply credentials either",
"",
"Run `gh auth login`, or set GITHUB_TOKEN. (`gh auth setup-git` also works,",
"but cast borrows `gh` as a credential helper on its own, so logging in is",
"enough — you do not need to change your global git config.)",
...tail,
].join("\n");
}
const used =
auth.source === "gh"
? "`gh` (borrowed as a credential helper for this clone)"
: "GITHUB_TOKEN / GH_TOKEN from the environment";
return [
`cannot clone ${orgRepo}: authenticated with ${used}, and GitHub still refused.`,
"",
"GitHub answers 'you cannot see this' with a 404, so this is one of:",
` - ${orgRepo} does not exist (check the slug)`,
" - it is private and this credential has no access to it",
" - the credential is expired, or lacks the `repo` scope",
...tail,
].join("\n");
}
// Holds for every verb that reads a manifest (apply, diff, capture, inventory):
// a feature-branch checkout must not be able to decide what prod runs, nor which
// secret names land in prod's store.
//
// The rule is a value, not only a throw inside resolveCheckout, because the CLI
// refuses this combination UP FRONT — before it opens a state file, a store or a
// Coolify. A flag pairing that can never be honored must not need the rest of the
// invocation to be well-formed in order to be caught (it used to be caught late,
// and only by accident of resolveCheckout running before the bindings load). One
// rule, one string, two call sites — never two spellings of the same refusal.
export const PATH_IN_PROD_REFUSAL =
"refuses --path with --env prod: prod always reads the default branch";
export function refusesPathInProd(opts: {
env: string;
path?: string;
}): boolean {
return opts.path !== undefined && opts.env === "prod";
}
// Checkouts this process cloned, to be removed when it exits.
const ephemeralCheckouts: string[] = [];
let reaperArmed = false;
// A checkout resolved WITHOUT `--path` is ours: we made the directory, we cloned
// into it, and nothing outside this process refers to it. It has to outlive
// resolveCheckout's return — every caller reads the tree afterwards — so the
// lifetime that actually fits is the process, not the call. Hence an exit hook
// rather than a `finally`, which would delete the checkout out from under the
// command that just asked for it.
//
// A `--path` checkout is the operator's own working tree and is never registered
// here; deleting that would be catastrophic and is the reason this wraps the
// mkdtemp result specifically, not the function's return value.
//
// Without this, every `cast apply` / `diff` / `capture` run without `--path`
// left a full shallow clone in the temp dir forever (#117).
function reapOnExit(dir: string): string {
ephemeralCheckouts.push(dir);
if (!reaperArmed) {
reaperArmed = true;
process.once("exit", () => {
for (const d of ephemeralCheckouts) {
// Best-effort: failing to clean up must never change a command's exit
// status. The work is already done by the time we get here.
try {
rmSync(d, { recursive: true, force: true });
} catch {
// ignore
}
}
});
}
return dir;
}
export function resolveCheckout(
orgRepo: string,
opts: { env: string; path?: string },
): string {
if (refusesPathInProd(opts)) {
throw new Error(PATH_IN_PROD_REFUSAL);
}
if (opts.path) return opts.path;
const dir = reapOnExit(mkdtempSync(join(tmpdir(), "infra-checkout-")));
const auth = resolveGitAuth();
try {
execFileSync(
"git",
[
...auth.configArgs,
"clone",
"--depth",
"1",
`https://github.com/${orgRepo}.git`,
dir,
],
{
stdio: "pipe",
env: {
...process.env,
...auth.env,
// Belt and braces: whatever credential path we took, git may NEVER
// fall through to its interactive username/password prompt. GitHub
// stopped accepting passwords there years ago, so it cannot succeed
// — it can only hang cast, or (in the original report) hand back an
// error about the repository that hides the real fault.
GIT_TERMINAL_PROMPT: "0",
},
},
);
} catch (err) {
const stderr = String((err as { stderr?: Buffer | string })?.stderr ?? "");
throw new Error(cloneFailureMessage(orgRepo, auth, stderr));
}
return dir;
}
// One secret the manifest requires: the ${REF} a template names (the key it
// gets in the age store), the resource that needs it, and the env var it lands
// on there. That last pair is what `capture` reads the live value from — the
// store is keyed by REF, but the live box knows it as `resource.key`.
export type RequiredSecret = { ref: string; resource: string; key: string };
// The dead-reference check, pointed at derived edges instead of generated
// secrets (#60). The same failure the generated_secrets check below catches — a
// name that resolves against nothing, dressed up as config that guards or
// derives something — and refused in the same voice, wherever a template is
// opened: `apply`, `diff`, and `capture` all validate before they act, because a
// `${resource:X.url}` naming a database the manifest does not declare is broken
// for all three, not just the verb about to write.
//
// - an attr other than `.url`: nothing else is derivable, so it can only be a
// mistake — named here rather than resolved to `undefined` and written blank.
// - a resource the manifest does not declare: the URL would resolve against
// nothing on every box, forever. The likeliest cause is a typo.
function assertResourceRefs(
envName: string,
databases: Set<string>,
refs: Array<{ key: string; resource: string; attr: string }>,
): void {
for (const r of refs) {
if (r.attr !== "url") {
throw new Error(
[
`manifest environment ${envName}: ${r.key} refers to \${resource:${r.resource}.${r.attr}}, an unknown resource attribute`,
"",
"Only `.url` is derivable — the internal URL of a database the manifest",
"declares. Fix the attribute, or make it a plain ${SECRET} the store holds.",
].join("\n"),
);
}
if (!databases.has(r.resource)) {
throw new Error(
[
`manifest environment ${envName}: ${r.key} refers to \${resource:${r.resource}.url}, but the manifest declares no database named ${r.resource}`,
"",
` declares: ${[...databases].sort().join(", ") || "(no databases)"}`,
"",
"A ${resource:…} ref derives the URL of a database this manifest creates. One",
"that names a database the manifest does not declare derives nothing, on every",
"box, forever — the likeliest cause is a typo. Fix the name, or declare the",
"database.",
].join("\n"),
);
}
}
}
// The domains an environment's manifest declares, flattened to the map keys a
// ${domain:…} ref resolves against: `<app>` → `applications.<app>.domains[0]`,
// and `<app>.<service>` → `applications.<app>.service_domains.<service>[0]`. The
// [0] is the PRIMARY domain — a domain list may carry several, and a ref names
// the app, not an index. A malformed (missing/empty) array is simply not added:
// the lookup then misses and assertDomainRefs reports it, rather than this
// helper throwing far from the ref that caused it. Applications only — Coolify
// 4.1.2 cannot set service domains, and a service's own domains are unhonorable
// by apply anyway (see the service loop).
function buildDomainMap(envSpec: EnvironmentSpec): Record<string, string> {
const map: Record<string, string> = {};
for (const [name, app] of Object.entries(envSpec.applications)) {
if (app.domains && app.domains.length > 0) map[name] = app.domains[0];
for (const [svc, arr] of Object.entries(app.service_domains ?? {})) {
if (arr.length > 0) map[`${name}.${svc}`] = arr[0];
}
}
return map;
}
// The dead-reference check for domain refs, in the same voice as
// assertResourceRefs. A ${domain:…} ref is pure manifest data, so a ref that
// does not resolve is a ref that names something the manifest does not declare —
// caught at plan time, before the sentinel can escape, and refused by every verb
// that opens a template (`apply`, `diff`, `capture`). Each branch names what IS
// declared, so the fix is one edit away.
function assertDomainRefs(
envName: string,
applications: EnvironmentSpec["applications"],
refs: Array<{ key: string; app: string; service?: string }>,
): void {
const appNames = Object.keys(applications).sort();
for (const r of refs) {
const app = applications[r.app];
if (!app) {
throw new Error(
[
`manifest environment ${envName}: ${r.key} refers to \${domain:${r.app}${r.service ? `.${r.service}` : ""}}, but the manifest declares no application named ${r.app}`,
"",
` declares: ${appNames.join(", ") || "(no applications)"}`,
"",
"A ${domain:…} ref resolves to a public domain this manifest declares. One",
"that names an application the manifest does not declare resolves to nothing,",
"on every box, forever — the likeliest cause is a typo. Fix the name, or",
"declare the application.",
].join("\n"),
);
}
// Which SHAPE the app is, by KEY PRESENCE — not by array non-emptiness. The
// manifest schema makes `domains` and `service_domains` mutually exclusive
// and requires exactly one (AppSpecSchema superRefine: a non-compose app
// requires `domains` and forbids `service_domains`; a compose app the
// reverse), so a present-but-empty `domains: []` is still a domains app —
// asking about its array length here would mis-route a `${domain:app.svc}`
// ref into the unknown-service branch below with a misleading message.
const hasDomains = app.domains !== undefined;
const hasServiceDomains = app.service_domains !== undefined;
const svcNames = Object.keys(app.service_domains ?? {}).sort();
if (!r.service && hasServiceDomains && !hasDomains) {
throw new Error(
[
`manifest environment ${envName}: ${r.key} refers to \${domain:${r.app}}, but ${r.app} is a compose app whose domains live per service`,
"",
` services: ${svcNames.join(", ")}`,
"",
`Name one: write \${domain:${r.app}.<service>}.`,
].join("\n"),
);
}
if (r.service && hasDomains && !hasServiceDomains) {
throw new Error(
[
`manifest environment ${envName}: ${r.key} refers to \${domain:${r.app}.${r.service}}, but ${r.app} declares a plain \`domains\` list, not per-service domains`,
"",
`Drop the service: write \${domain:${r.app}}.`,
].join("\n"),
);
}
if (r.service && !(r.service in (app.service_domains ?? {}))) {
throw new Error(
[
`manifest environment ${envName}: ${r.key} refers to \${domain:${r.app}.${r.service}}, but ${r.app} declares no service named ${r.service}`,
"",
` services: ${svcNames.join(", ") || "(none)"}`,
"",
"Fix the service name, or declare it under the app's service_domains.",
].join("\n"),
);
}
// The selected array — the exact list this ref resolves against — must
// actually hold a domain. Missing, empty (`domains: []`), or a blank first
// entry (`domains: [""]`, which is schema-valid: a non-empty array of
// strings) all resolve to nothing, and the last would slip past buildDomainMap
// (it stores `""`) and past fillDomainEnv (whose `!== ""` guard reads `""` as
// unresolved) to leave the sentinel in a returned env. Caught here, so this
// assert stays the single gate and the sentinel can never escape.
const arr = r.service ? app.service_domains?.[r.service] : app.domains;
if (!arr || arr.length === 0 || arr[0] === "") {
throw new Error(
[
`manifest environment ${envName}: ${r.key} refers to \${domain:${r.app}${r.service ? `.${r.service}` : ""}}, but that domain list is empty or its first entry is blank`,
"",
"A ${domain:…} ref resolves to the FIRST domain in the list. An empty list,",
"or one whose first entry is an empty string, has none to resolve to —",
"declare a real domain, or drop the ref.",
].join("\n"),
);
}
}
}
// Exactly the set of secret names an environment's manifest demands — the same
// set `apply` will later insist on, read from the same templates by the same
// parser. `capture` uses this to know what to go and fetch; nothing else has to
// be told, and nothing can be silently missed.
//
// Deliberately does NOT take a secrets map: at capture time the store does not
// exist yet. That is the whole point of the verb.
export function requiredSecrets(
checkoutDir: string,
envName: string,
): {
required: RequiredSecret[];
generated: string[];
manifestRefs: string[];
} {
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 required: RequiredSecret[] = [];
const resourceRefs: Array<{ key: string; resource: string; attr: string }> =
[];
const domainRefs: Array<{ key: string; app: string; service?: string }> = [];
// Reserved names are checked HERE, and in manifestResources, and in
// desiredFromManifest — every function in this file that opens an env
// template, rather than once in the verb that writes. The rule is a property
// of cast, not of `apply`: a template that declares SOURCE_COMMIT is broken
// whether the verb about to run is going to write it (`apply`), store its live
// value (`capture`), or merely compare it (`diff`, `inventory`). Refusing in
// one place and reporting in another would leave `capture` writing a store for
// a manifest `apply` will refuse — a green run that guarantees a red one. See
// reserved.ts. (Reads ALL template keys, not just the ${…} refs: a bare
// `SOURCE_COMMIT=` literal suppresses the injection exactly as well.)
const reserved: ReservedHit[] = [];
const collect = (resource: string, template?: string) => {
if (!template) return;
const file = join(checkoutDir, ".infra", "env", template);
if (!existsSync(file))
throw new Error(
`env template missing: ${file} (referenced by ${resource})`,
);
const text = readFileSync(file, "utf8");
reserved.push(...reservedHits(resource, templateKeys(text)));
resourceRefs.push(...templateResourceRefs(text));
domainRefs.push(...templateDomainRefs(text));
for (const { key, ref } of templateRefs(text)) {
required.push({ ref, resource, key });
}
};
for (const [name, app] of Object.entries(envSpec.applications)) {
collect(name, app.env_template);
}
for (const [name, svc] of Object.entries(envSpec.services ?? {})) {
collect(name, svc.env_template);
}
assertNoReservedEnvNames(reserved);
assertResourceRefs(
envName,
new Set(Object.keys(envSpec.databases ?? {})),
resourceRefs,
);
// Domain refs are validated even by capture — a ref that names an undeclared
// app/service is broken for every verb — but they never enter `required`: a
// domain is manifest data, not a secret the store must hold.
assertDomainRefs(envName, envSpec.applications, domainRefs);
const generated = envSpec.generated_secrets ?? [];
// A generated_secrets entry naming something no template refs is dead
// config — and dead config in THIS list is not merely untidy, it is
// dangerous: it reads like a guard standing over a name while standing over
// nothing. The likeliest cause is a typo, and the consequence of the typo is
// that the real name gets CAPTURED from the source box instead of placeheld.
const refs = new Set(required.map((r) => r.ref));
const dead = generated.filter((g) => !refs.has(g));
if (dead.length > 0) {
throw new Error(
[
`manifest environment ${envName}: generated_secrets names ${dead.join(", ")}, which no env template refers to`,
"",
` declared: ${generated.join(", ")}`,
` templates: ${[...refs].sort().join(", ") || "(no ${...} refs at all)"}`,
"",
"A generated name that matches nothing guards nothing — and if this is a",
"typo, the name it was meant to guard is being captured from the source",
"box instead of placeheld. Fix the spelling, or drop the entry.",
].join("\n"),
);
}
// Store refs the MANIFEST itself carries, as opposed to the ones its env
// templates carry. Today that is exactly `basic_auth.password`.
//
// Kept OUT of `required`, deliberately. A RequiredSecret is `{ref, resource,
// key}` where `key` is a live ENV VAR name — that triple is what `capture`
// reads the source box's value from — and a basic-auth password is not an env
// var on any resource. Putting it in `required` would have capture look for an
// env var named after a field, fail to find it, and refuse the whole run as
// "missing". It is returned separately so the one caller that asks a different
// question — "would anything at all be read from the store?", the gate on
// whether a missing store is fatal (cli.ts, #104) — gets the right answer for
// a manifest whose only secret is a basic-auth password.
const manifestRefs = Object.values(envSpec.applications).flatMap((app) => {
const ref =
app.basic_auth?.enabled && app.basic_auth.password
? storeRefName(app.basic_auth.password)
: undefined;
return ref ? [ref] : [];
});
return { required, generated, manifestRefs };
}
// What the manifest declares for an environment, as names only — no secrets, no
// age key, no store. `inventory` runs BEFORE any of those exist (that is the
// point of it: you read the box before you can possibly have adopted it), so it
// must be able to describe the manifest side without resolving a single value.
export type ManifestResource = {
kind: "application" | "database" | "service";
name: string;
envKeys: string[];
};
export function manifestResources(
checkoutDir: string,
envName: string,
): ManifestResource[] {
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 reserved: ReservedHit[] = [];
const keysOf = (resource: string, template?: string): string[] => {
if (!template) return [];
const file = join(checkoutDir, ".infra", "env", template);
if (!existsSync(file))
throw new Error(
`env template missing: ${file} (referenced by ${resource})`,
);
const keys = templateKeys(readFileSync(file, "utf8"));
reserved.push(...reservedHits(resource, keys));
return keys;
};
const resources = [
...Object.entries(envSpec.applications).map(([name, app]) => ({
kind: "application" as const,
name,
envKeys: keysOf(name, app.env_template),
})),
...Object.entries(envSpec.databases ?? {}).map(([name]) => ({
kind: "database" as const,
name,
envKeys: [],
})),
...Object.entries(envSpec.services ?? {}).map(([name, svc]) => ({
kind: "service" as const,
name,
envKeys: keysOf(name, svc.env_template),
})),
];
assertNoReservedEnvNames(reserved);
return resources;
}
// Sort the keys and each URL array of a service's `service_domains` map, so the
// same set of per-container hostnames compares equal whatever order the manifest
// authored them in or Coolify returns them in. Both the desired side
// (desiredFromManifest) and the live read-back (attachServiceDomains in cli.ts)
// run this before computeDiff compares by JSON.stringify — without it, a service
// whose containers Coolify lists in a different order than the manifest would
// diff forever. Order is meaningless for hostnames (Coolify matches `urls[].name`
// to a container and treats the URLs as a set), so canonicalizing loses nothing.
export function canonicalizeServiceDomains(
map: Record<string, string[]>,
): Record<string, string[]> {
return Object.fromEntries(
Object.keys(map)
.sort()
.map((k) => [k, [...map[k]].sort()]),
);
}
// The Coolify fields an application's `basic_auth:` block becomes, with the
// password resolved out of the age store.
//
// Empty when the manifest declares nothing: managing basic auth is OPT-IN, the
// same rule as `is_static` (see the comment on that field below) and for a
// sharper reason — an unconditional `is_http_basic_auth_enabled: false` would
// have the first apply after this ships REMOVE the protection from every
// application somebody enabled by hand in the UI. A tool that silently
// unprotects an admin panel during a routine apply is worse than one that cannot
// protect it at all.
//
// The password is resolved HERE, at plan time, from the same store every
// `${REF}` in an env template resolves against — so a missing ref fails before
// anything is written, naming the ref and the store, rather than 422ing
// mid-apply or (worse) writing an empty password over a working one.
export function basicAuthFields(
envName: string,
appName: string,
app: Pick<AppSpec, "basic_auth">,
secrets: Record<string, string>,
): Record<string, unknown> {
const auth = app.basic_auth;
if (!auth) return {};
if (!auth.enabled) return { is_http_basic_auth_enabled: false };
// Both are guaranteed present by the schema's superRefine; the checks are
// repeated at the value level because THIS is where an empty store entry
// becomes an empty password, which the schema cannot see.
const ref = storeRefName(String(auth.password));
if (ref === undefined) {
throw new Error(
`manifest environment ${envName}: application ${appName} basic_auth.password is not a store ref (\${NAME})`,
);
}
const value = secrets[ref];
if (value === undefined || value === "") {
throw new Error(
[
`manifest environment ${envName}: application ${appName} declares basic_auth.password \${${ref}}, and the age store ${value === "" ? "holds an EMPTY value for it" : "does not hold it"}`,
"",
" the store is the environment's `secrets/<repo>.<env>.env.age` — the same one",
" every env-template ${REF} resolves against. Add the name to it (a store is a",
" KEY=value file, encrypted to the environment's age recipient) and re-run.",
"",
"Writing an empty password would enable basic auth on a public URL and protect",
"nothing, and Coolify would accept it — so cast refuses before it writes anything.",
].join("\n"),
);
}
return {
is_http_basic_auth_enabled: true,
http_basic_auth_username: auth.username,
// The PLAINTEXT, in the desired field bag — the only place it exists in this
// process besides the decrypted store. It is never printed: renderDiff
// redacts this field name by name (see REDACTED_FIELDS in diff.ts), which is
// the same contract every secret env var already has.
http_basic_auth_password: value,
};
}
export function desiredFromManifest(
checkoutDir: string,
envName: string,
secrets: Record<string, string>,
): {
desired: Desired[];
resolvedEnvs: Record<string, ResolvedEnv>;
} {
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 reserved: ReservedHit[] = [];
const resourceRefs: Array<{ key: string; resource: string; attr: string }> =
[];
const domainRefs: Array<{ key: string; app: string; service?: string }> = [];
// A domain is pure manifest data, so its map is built once from the manifest
// itself — independent of any template — and every resolved env is filled
// against it at plan time. assertDomainRefs at the end throws on any ref that
// did not resolve, so the sentinel never escapes into a returned env.
const domainMap = buildDomainMap(envSpec);
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 text = readFileSync(file, "utf8");
const env = fillDomainEnv(resolveTemplate(text, secrets), domainMap);
reserved.push(...reservedHits(name, Object.keys(env.vars)));
resourceRefs.push(...templateResourceRefs(text));
domainRefs.push(...templateDomainRefs(text));
resolvedEnvs[name] = env;
return env;
};
for (const [name, app] of Object.entries(envSpec.applications)) {
if (app.build.pack === "dockercompose") {
// Coolify gates the SOURCE_COMMIT *build arg* behind a per-application
// setting — `ApplicationSetting.include_source_commit_in_build`, default
// false — and in 4.1.2 that setting has NO API surface. Verified against
// the v4.1.2 source: it appears in zero API controllers, and both the
// create and the PATCH allowlists in ApplicationsController.php (l.914,
// l.2368) reject unrecognized keys outright ("This field is not
// allowed."), so sending it would fail the whole request rather than be
// quietly ignored. Its only writer is the Livewire Advanced tab
// (app/Livewire/Project/Application/Advanced.php:128) — i.e. a human, in
// the UI. Do NOT add it to `fields` below expecting apply to set it the
// way it sets `connect_to_docker_network` (which *is* in the allowlist,
// which is why that one works): apply would 422 on every run. Warning is
// the only honest move — a manual step the tool knows about and does not
// mention is one that gets forgotten, and this one fails green.
//
// Scope: the toggle gates the BUILD-time arg only. Coolify's *runtime*
// injection of SOURCE_COMMIT is unconditional with respect to it
// (ApplicationDeploymentJob.php:2949 — `if (! $forBuildTime || ...)`,
// which short-circuits true at runtime), so a service that reads
// process.env.SOURCE_COMMIT per request does not need this toggle at all.
// What *does* silently suppress that runtime value is an application-level
// env var of the same name (ApplicationDeploymentJob.php:2950) — a
// different bug, tracked separately.
console.warn(
`application ${name} builds with dockercompose, but apply cannot enable "Include Source Commit in Build" on Coolify 4.1.2 — the setting is absent from the API's field allowlist. If the build consumes SOURCE_COMMIT as a build arg, enable it in the Coolify UI and redeploy; Coolify injects SOURCE_COMMIT at runtime regardless.`,
);
}
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,
// Emitted only when the manifest DECLARES `static:` — like the
// three commands, not unconditionally. Emitting `is_static:false`
// on every non-compose app would make the first apply after this
// ships PATCH `is_static=false` onto any static/SPA app configured
// in the UI whose manifest has not yet been migrated — silently
// disabling static serving and re-creating the #63 crash, now
// caused by cast. And a `pack: static` app that Coolify couples to
// is_static=true would drift-and-revert forever. So managing
// is_static is opt-in: declare `static: true` to serve, `static:
// false` to actively guard against a UI flip to true, or omit it to
// leave the field alone. (Coolify keeps pack and is_static
// independent, which is why this stays an explicit field, not a
// heuristic off `pack`.)
...(app.build.static !== undefined
? { is_static: app.build.static }
: {}),
...(app.build.install_command !== undefined
? { install_command: app.build.install_command }
: {}),
...(app.build.build_command !== undefined
? { build_command: app.build.build_command }
: {}),
...(app.build.start_command !== undefined
? { start_command: app.build.start_command }
: {}),
}),
// Outside the pack branch: basic auth is a property of the APPLICATION
// (Coolify sets it on the app's proxy labels, not on anything the build
// pack decides), so it is equally declarable on a compose app and a
// nixpacks one. The three keys are already Coolify's own names, so
// applicationApiFields passes them through untranslated.
...basicAuthFields(envName, name, app, secrets),
},
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 } : {}),
// `backup` is a DIFFED FIELD, like any other.
//
// It used to be routed around `fields` into a side channel, on the
// stated grounds that "live Coolify state doesn't expose it back" — so
// diffing it would flag spurious drift forever. That premise was false:
// it is not on the database's own GET, but GET /databases/{uuid}/backups
// is a route (cast has always POSTed to it), and frequency/retention
// round-trip verbatim through it. The side channel is what made a
// `backup:` block added to an EXISTING database do nothing, silently,
// and made `diff --full` pass on a production database with no backups.
//
// Key order matters: computeDiff compares by JSON.stringify, and the
// live side (fetchLive in cli.ts) builds this same object in this same
// order. Do not reorder one without the other.
...(db.backup
? {
backup: {
frequency: db.backup.frequency,
retention: db.backup.retention,
},
}
: {}),
},
});
}
for (const [name, svc] of Object.entries(envSpec.services ?? {})) {
desired.push({
kind: "service",
name,
// service_domains is a DIFFED FIELD (cast#72). Coolify 4.1.2 sets a
// service's per-container hostnames via `urls` on create/PATCH and returns
// them on `service.applications[].fqdn` — so a declared hostname is now
// WRITTEN by apply and VERIFIED by diff, not a manual Coolify UI act.
//
// Canonicalized (see canonicalizeServiceDomains) so container order in the
// manifest never diffs against Coolify's own ordering on read-back. The
// live side (attachServiceDomains in cli.ts) canonicalizes identically.
//
// This block USED to warn that a service's domains were unhonorable and
// drop them, citing a re-checked "no flat `domains` on a 4.1.2 service, on
// any route". That was true of the FLAT shape and false of the capability:
// the per-container `urls` route was there at 4.1.2 all along — the same
// arc as `backup` (#51), which was dropped on the same "can't read it back"
// reasoning that also turned out false.
fields: {
type: svc.type,
...(svc.service_domains
? { service_domains: canonicalizeServiceDomains(svc.service_domains) }
: {}),
},
env: resolveEnvFile(name, svc.env_template),
});
}
// Before the caller can diff it, and long before apply can write it: a
// resolved env that carries a reserved name is not desired state, it is a
// suppression of the platform's own value dressed up as one. See reserved.ts.
assertNoReservedEnvNames(reserved);
assertResourceRefs(
envName,
new Set(Object.keys(envSpec.databases ?? {})),
resourceRefs,
);
// Throws BEFORE the desired set is returned, so an invalid domain ref never
// ships the sentinel: every env in `resolvedEnvs` and every `desired[].env` is
// already domain-filled by resolveEnvFile above.
assertDomainRefs(envName, envSpec.applications, domainRefs);
return { desired, resolvedEnvs };
}
// Fill the derived vars in a desired set against a URL map keyed by MANIFEST
// resource name. Used twice, with two different maps, and that is the whole
// point of it being one function: diff/apply fills first against the resources
// that already exist on the box (so an app whose DATABASE_URL already equals the
// live database's URL shows no drift — the "differs on every plan" noise #60
// deletes), and the executor fills again against a database it has just created
// (the from-nothing case, where nothing existed to resolve against at plan
// time). A ref whose resource is in neither map stays unresolved; the executor
// is the one place that refuses to WRITE one that never resolved.
export function fillDesiredDerived(
desired: Desired[],
urls: Record<string, string>,
): Desired[] {
return desired.map((d) =>
d.env ? { ...d, env: fillDerivedEnv(d.env, urls) } : d,
);
}