Merge pull request #67 from claude-hdb/feat/derive-domain-refs

feat(resolve): derive base-URL env vars from manifest domains via ${domain:...} (#66)
This commit is contained in:
Daniel Marin 2026-07-15 13:43:49 +01:00 committed by GitHub
commit 95c9befd2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 561 additions and 5 deletions

View file

@ -341,6 +341,16 @@ standing over nothing is worse than no guard, because it reads like one.
> that genuinely is not derivable. See [semantics.md](docs/semantics.md) →
> *Derived resource URLs*.
> **A declared domain is better *derived* than transcribed.** If the value is a
> base URL an app is told to call itself (or a sibling) at, write
> `LANDING_BASE_URL=${domain:landing}` — or `${domain:core.admin}` for a compose
> app's per-service domain — instead of copying the hostname into the template by
> hand. It resolves to the manifest's own `domains` / `service_domains` (verbatim,
> scheme and all), at plan time, so there is nothing to drift. Unlike a resource
> URL a domain is **public**, not a secret: it is not stored, not captured, and it
> prints in a diff like any literal. Applications only. See
> [semantics.md](docs/semantics.md) → *Derived domains*.
An **`--override`**'s value is read from `$CAST_CAPTURE_<NAME>`, never from the
command line: argv is visible in `ps` to every process on the box. It exists for
values that must not survive the copy — staging and prod sharing a Mailgun

View file

@ -921,6 +921,60 @@ being store names at all, so the placeholder they held, and the `apply` refusal
that guarded it, simply cease to exist for them — there is nothing in the store
to overwrite, and nothing to re-encrypt on a rebuild-from-nothing.
## Derived domains (`${domain:<app>[.<service>]}`)
Symmetric with `${resource:…}`, and for the same reason: a base URL an app is
told to call itself at is not a secret anybody authored — it is a domain the
manifest *already declares*, on the very resource cast is about to create. Hand-
transcribing it into an env template (incubator's `LANDING_BASE_URL`,
`ADMIN_WEB_BASE_URL`) makes a second copy that drifts from the `domains` /
`service_domains` cast parses anyway. So a template can name the domain
directly:
LANDING_BASE_URL=${domain:landing}
ADMIN_WEB_BASE_URL=${domain:core.admin}
- `${domain:<app>}` resolves to `applications.<app>.domains[0]` — the app's
**primary** domain.
- `${domain:<app>.<service>}` resolves to
`applications.<app>.service_domains.<service>[0]` — a compose app's primary
domain for that service.
The value is the domain string **verbatim**, scheme and all
(`https://new.heavyduty.builders`). **Applications only:** cast's service creates
send no domains, and Coolify 4.1.2 cannot set service domains at all (see
*Hostname overlay* and the service loop), so there is no service domain to
derive.
**The one way it differs from a derived resource URL — and it is the whole
design — is that a domain is not read back from a live box; it is PURE MANIFEST
DATA.** A `${resource:…}` URL needs the database Coolify made, so it defers: it
rides through the diff unresolved and the executor fills it after the create. A
domain is known the instant the manifest is parsed, so it resolves at **plan
time**, inside `desiredFromManifest`, against a map built straight from the
manifest — and it **always** resolves there. There is no deferral, no executor
step, no unresolved-sentinel that a diff or an apply can legitimately carry:
`fillDomainEnv` replaces the transient sentinel in the same plan, drops the
derived-domain marker, and what remains is a plain resolved value
indistinguishable from a literal. So the diff needs no domain-awareness, and
apply writes it like any other env var.
**It is public, not a secret.** Unlike a resolved resource URL (secret, never
printed), a derived domain resolves to `secret: false` — it prints in a diff like
any literal, because a public hostname is not a thing to hide. It is not a store
ref either: `capture` never goes looking for a store name called `domain:landing`,
and it never enters `required` / `generated`.
**Validation is at plan time, in the same voice as the other ref checks** — and
refused by every verb that opens a template (`apply`, `diff`, `capture`), because
a ref that resolves against nothing is broken for all of them. A `${domain:…}` is
a hard error, named before any write, when it points at an application the
manifest does not declare, when it omits a service on a compose app whose domains
live per service (it lists them), when it names a service on an app that declares
a plain `domains` list, when it names a service the app's `service_domains` does
not declare, or when the selected list is declared but empty. The sentinel never
escapes: the assert throws before `desiredFromManifest` returns.
## Drafts (`inventory --emit-draft`)
`inventory` with no repo sweeps an instance. `--emit-draft <dir>` writes that

View file

@ -7,6 +7,17 @@
// live box, not decrypted.
export type ResourceRef = { resource: string; attr: string };
// A ${domain:<app>[.<service>]} reference — a public domain THIS manifest
// already declares (`applications.<app>.domains[0]`, or a compose app's
// `service_domains.<service>[0]`). Symmetric with ${resource:…} but simpler in
// the one way that matters: a resource URL is a fact read back from the live box
// (#60), while a domain is PURE MANIFEST DATA, known the instant the manifest is
// parsed. So it needs no live read, no age key, no deferral — it resolves at
// plan time, from the same manifest that declared it. It is also PUBLIC, not a
// secret: it prints in a diff like any literal (unlike the secret-flagged
// resource URL).
export type DomainRef = { app: string; service?: string };
// The value a derived var carries until it is resolved against the live
// resource. NEVER a legal thing to WRITE — the executor refuses it, the same
// fail-closed shape as the generated-secret placeholder — because a database URL
@ -21,6 +32,19 @@ export type ResourceRef = { resource: string; attr: string };
// whole source file as binary and its diff unreviewable.)
export const DERIVED_UNRESOLVED = "cast:unresolved-derived-resource-url";
// The value a domain var carries between resolveTemplate and fillDomainEnv.
// Unlike DERIVED_UNRESOLVED, which is a legitimate transient state a derived var
// can be diffed and applied in (the from-nothing case, resolved later by the
// executor), this sentinel must NEVER survive validation: a domain is manifest
// data, so a ref that does not resolve is a ref that names an app/service the
// manifest does not declare, and `assertDomainRefs` throws on it before the
// desired set is ever returned. It exists for one reason only — so
// resolveTemplate need not know the manifest (the domain map is built later, in
// resolve.ts) — and fillDomainEnv always replaces it in the same plan.
// (A plain string, no NUL byte: a NUL makes git treat the source file as binary
// and its diff unreviewable — same reasoning as DERIVED_UNRESOLVED.)
export const DOMAIN_UNRESOLVED = "cast:unresolved-domain-ref";
// `secret` is true for both a ${SECRET} and a resolved ${resource:…} — both are
// values that must never be printed. `derived` is set (and stays set after
// resolution) so the diff can say "derived from database X" rather than mistaking
@ -29,19 +53,27 @@ export const DERIVED_UNRESOLVED = "cast:unresolved-derived-resource-url";
export type ResolvedEnv = {
vars: Record<
string,
{ value: string; secret: boolean; derived?: ResourceRef }
{
value: string;
secret: boolean;
derived?: ResourceRef;
domain?: DomainRef;
}
>;
};
// A template line, parsed but not resolved: `ref` is set when the whole RHS is a
// single ${NAME} placeholder (a store secret); `resourceRef` when it is a single
// ${resource:<name>.attr} placeholder (a derived value). The two are mutually
// exclusive — a secret name is UPPER_SNAKE, a resource ref starts `resource:`.
// ${resource:<name>.attr} placeholder (a derived value); `domainRef` when it is
// a single ${domain:<app>[.<service>]} placeholder (a declared public domain).
// The three are mutually exclusive — a secret name is UPPER_SNAKE, a resource
// ref starts `resource:`, a domain ref starts `domain:`.
export type TemplateVar = {
key: string;
rhs: string;
ref?: string;
resourceRef?: ResourceRef;
domainRef?: DomainRef;
};
// ONE grammar, shared by both readers of a template — resolveTemplate (which
@ -71,6 +103,15 @@ function parseTemplate(text: string): TemplateVar[] {
const resource = rhs.match(
/^\$\{resource:([a-z0-9][a-z0-9_-]*)\.([a-z_]+)\}$/,
);
// A domain ref names a manifest application (lower-kebab/snake, as the
// manifest spells it) and, optionally, one of that app's compose services.
// Parsing stays dumb — an unknown app or service is not rejected HERE but at
// validation time (resolve.ts), where the message can name the applications
// and services the manifest actually declares, exactly as an undeclared
// resource ref is.
const domain = rhs.match(
/^\$\{domain:([a-z0-9][a-z0-9_-]*)(?:\.([a-z0-9][a-z0-9_-]*))?\}$/,
);
vars.push({
key,
rhs,
@ -78,6 +119,14 @@ function parseTemplate(text: string): TemplateVar[] {
...(resource
? { resourceRef: { resource: resource[1], attr: resource[2] } }
: {}),
...(domain
? {
domainRef: {
app: domain[1],
...(domain[2] ? { service: domain[2] } : {}),
},
}
: {}),
});
}
return vars;
@ -88,7 +137,7 @@ export function resolveTemplate(
secrets: Record<string, string>,
): ResolvedEnv {
const vars: ResolvedEnv["vars"] = {};
for (const { key, rhs, ref, resourceRef } of parseTemplate(text)) {
for (const { key, rhs, ref, resourceRef, domainRef } of parseTemplate(text)) {
// A derived value is resolved from the live resource, not the store, and not
// here — the resource may not exist yet (a from-nothing apply creates it in
// the same run). So it lands UNRESOLVED, carrying the ref; fillDerivedEnv
@ -104,6 +153,22 @@ export function resolveTemplate(
};
continue;
}
// A domain is manifest data, and resolveTemplate is deliberately
// manifest-unaware (it takes secrets, not a domains map — the map is built
// later, in resolve.ts). So the var lands carrying the transient sentinel and
// its ref; fillDomainEnv resolves it against the domain map, and
// assertDomainRefs throws before an unresolved one can escape the plan. Like
// resourceRef, this is BEFORE the ref/literal branch on purpose: a domain
// ref's RHS is not UPPER_SNAKE, so it would otherwise be written through as
// the literal text "${domain:…}". A domain is PUBLIC — secret: false.
if (domainRef) {
vars[key] = {
value: DOMAIN_UNRESOLVED,
secret: false,
domain: domainRef,
};
continue;
}
if (ref === undefined) {
vars[key] = { value: rhs, secret: false };
continue;
@ -146,6 +211,37 @@ export function fillDerivedEnv(
return { vars };
}
// Resolve every domain var against a map of the domains the manifest declares,
// keyed `<app>` or `<app>.<service>` (built in resolve.ts). Pure, and mirrors
// fillDerivedEnv — but where a derived var may legitimately stay unresolved (a
// from-nothing apply has no live database yet), a domain ALWAYS resolves here:
// the map is manifest data, and assertDomainRefs has already thrown on any ref
// that would miss it. So a hit becomes a plain `{ value, secret: false }` and
// DROPS the `domain` marker — a resolved domain is indistinguishable from a
// literal downstream, which is why the diff needs no domain-awareness at all. A
// miss (only reachable if validation was skipped) is left untouched, sentinel
// and marker intact, so nothing silently writes the sentinel as a value.
export function fillDomainEnv(
env: ResolvedEnv,
domains: Record<string, string>,
): ResolvedEnv {
const vars: ResolvedEnv["vars"] = {};
for (const [key, v] of Object.entries(env.vars)) {
const domain = v.domain
? domains[
v.domain.service
? `${v.domain.app}.${v.domain.service}`
: v.domain.app
]
: undefined;
vars[key] =
domain !== undefined && domain !== ""
? { value: domain, secret: false }
: v;
}
return { vars };
}
// The derived vars still holding the unresolved sentinel — what the executor
// must resolve from the live box before it can write this env, and what it
// refuses on if it cannot. Names the env key and the resource it derives from;
@ -187,6 +283,27 @@ export function templateResourceRefs(
);
}
// The ${domain:<app>[.<service>]} refs a template declares — the declared-domain
// edges. NOT returned by templateRefs (a domain is not a secret to capture) and
// NOT part of the required-secret set, exactly like templateResourceRefs.
// resolve.ts reads these to validate each ref against the applications and
// service_domains the manifest actually declares.
export function templateDomainRefs(
text: string,
): Array<{ key: string; app: string; service?: string }> {
return parseTemplate(text).flatMap(({ key, domainRef }) =>
domainRef === undefined
? []
: [
{
key,
app: domainRef.app,
...(domainRef.service ? { service: domainRef.service } : {}),
},
],
);
}
// Every env var key a template declares — refs and literals alike. `capture`
// only cares about the ${...} refs (the secrets); `inventory` needs all of them,
// because the question it answers is "what does the manifest put on this

View file

@ -6,11 +6,14 @@ import type { Desired } from "./diff.js";
import {
type ResolvedEnv,
fillDerivedEnv,
fillDomainEnv,
resolveTemplate,
templateDomainRefs,
templateKeys,
templateRefs,
templateResourceRefs,
} from "./envtemplate.js";
import type { EnvironmentSpec } from "./manifest.js";
import { loadManifest } from "./manifest.js";
import {
type ReservedHit,
@ -252,6 +255,117 @@ function assertResourceRefs(
}
}
// 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
@ -273,6 +387,7 @@ export function requiredSecrets(
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
@ -294,6 +409,7 @@ export function requiredSecrets(
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 });
}
@ -310,6 +426,10 @@ export function requiredSecrets(
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
@ -409,6 +529,12 @@ export function desiredFromManifest(
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,
@ -418,9 +544,10 @@ export function desiredFromManifest(
if (!existsSync(file))
throw new Error(`env template missing: ${file} (referenced by ${name})`);
const text = readFileSync(file, "utf8");
const env = resolveTemplate(text, secrets);
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;
};
@ -575,6 +702,10 @@ export function desiredFromManifest(
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 };
}

View file

@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";
import {
DERIVED_UNRESOLVED,
DOMAIN_UNRESOLVED,
assertEnvVarPolicy,
fillDerivedEnv,
fillDomainEnv,
resolveTemplate,
templateDomainRefs,
templateRefs,
templateResourceRefs,
unresolvedDerived,
@ -114,3 +117,82 @@ describe("derived resource refs (#60)", () => {
]);
});
});
describe("derived domain refs (#66)", () => {
const template =
"PORT=3000\nMAILGUN_KEY=${MAILGUN_KEY}\nDATABASE_URL=${resource:postgres.url}\nLANDING_BASE_URL=${domain:landing}\nADMIN_WEB_BASE_URL=${domain:core.admin}\n";
it("resolveTemplate marks a ${domain:…} var with its ref, secret:false, and the transient sentinel — never the literal", () => {
const r = resolveTemplate(template, { MAILGUN_KEY: "mk" });
// An app ref and an app.service ref, both carrying the sentinel and the ref,
// both public (secret:false) — and NOT written through as the literal text.
expect(r.vars.LANDING_BASE_URL).toEqual({
value: DOMAIN_UNRESOLVED,
secret: false,
domain: { app: "landing" },
});
expect(r.vars.ADMIN_WEB_BASE_URL).toEqual({
value: DOMAIN_UNRESOLVED,
secret: false,
domain: { app: "core", service: "admin" },
});
expect(r.vars.LANDING_BASE_URL.value).not.toContain("${domain:");
// The three ref kinds stay mutually exclusive, and the plain secret/literal
// are untouched by the new branch.
expect(r.vars.DATABASE_URL).toEqual({
value: DERIVED_UNRESOLVED,
secret: true,
derived: { resource: "postgres", attr: "url" },
});
expect(r.vars.MAILGUN_KEY).toEqual({ value: "mk", secret: true });
expect(r.vars.PORT).toEqual({ value: "3000", secret: false });
});
it("fillDomainEnv resolves app and app.service refs verbatim, secret:false, marker dropped", () => {
const env = resolveTemplate(template, { MAILGUN_KEY: "mk" });
const filled = fillDomainEnv(env, {
landing: "https://new.heavyduty.builders",
"core.admin": "https://admin.heavyduty.builders",
});
// Resolved to the verbatim domain (scheme and all), public, and with the
// `domain` marker DROPPED — indistinguishable from a literal downstream.
expect(filled.vars.LANDING_BASE_URL).toEqual({
value: "https://new.heavyduty.builders",
secret: false,
});
expect(filled.vars.ADMIN_WEB_BASE_URL).toEqual({
value: "https://admin.heavyduty.builders",
secret: false,
});
// The non-domain vars ride through untouched.
expect(filled.vars.MAILGUN_KEY).toEqual({ value: "mk", secret: true });
expect(filled.vars.DATABASE_URL.value).toBe(DERIVED_UNRESOLVED);
});
it("fillDomainEnv leaves an unknown key as-is (sentinel and marker intact)", () => {
const env = resolveTemplate("X=${domain:landing}\n", {});
// Absent from the map, and present-but-empty, are both non-resolutions.
expect(fillDomainEnv(env, {}).vars.X).toEqual({
value: DOMAIN_UNRESOLVED,
secret: false,
domain: { app: "landing" },
});
expect(fillDomainEnv(env, { landing: "" }).vars.X).toEqual({
value: DOMAIN_UNRESOLVED,
secret: false,
domain: { app: "landing" },
});
});
it("templateDomainRefs extracts the edges; templateRefs/templateResourceRefs exclude them", () => {
expect(templateDomainRefs(template)).toEqual([
{ key: "LANDING_BASE_URL", app: "landing" },
{ key: "ADMIN_WEB_BASE_URL", app: "core", service: "admin" },
]);
// A domain is not a secret to capture, and not a resource edge either.
expect(templateRefs(template).map((r) => r.key)).toEqual(["MAILGUN_KEY"]);
expect(templateResourceRefs(template).map((r) => r.key)).toEqual([
"DATABASE_URL",
]);
});
});

View file

@ -553,3 +553,165 @@ ${dbBlock}`,
);
});
});
describe("derived domain refs (#66)", () => {
// Assemble a manifest from an applications block plus one env template. The
// env_template line is appended to whichever app comes last in `apps`.
const write = (apps: string, tmpl: string): string => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
${apps}`,
);
writeFileSync(join(dir, ".infra", "env", "refs.env.template"), tmpl);
return dir;
};
// A plain app (a `domains` list) and a compose app (`service_domains`); the
// env_template line appended after either makes that app carry the template.
const LANDING = ` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://new.heavyduty.builders"]
`;
const CORE = ` core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
admin: ["https://admin.heavyduty.builders"]
`;
const TMPL = " env_template: refs.env.template\n";
it("resolves ${domain:<app>} and ${domain:<app>.<service>} to the manifest's domains, secret:false", () => {
const dir = write(
LANDING + CORE + TMPL,
"ADMIN_WEB_BASE_URL=${domain:core.admin}\nLANDING_BASE_URL=${domain:landing}\n",
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired, resolvedEnvs } = desiredFromManifest(dir, "prod", {});
warn.mockRestore();
const core = desired.find((d) => d.name === "core");
// The app.service ref and the app ref both resolve to the verbatim domain
// (scheme and all), public, and with no `domain` marker — a plain literal.
expect(core?.env?.vars.ADMIN_WEB_BASE_URL).toEqual({
value: "https://admin.heavyduty.builders",
secret: false,
});
expect(core?.env?.vars.LANDING_BASE_URL).toEqual({
value: "https://new.heavyduty.builders",
secret: false,
});
// resolvedEnvs is domain-filled too, and no sentinel escapes anywhere.
expect(resolvedEnvs.core.vars.LANDING_BASE_URL.value).toBe(
"https://new.heavyduty.builders",
);
expect(JSON.stringify(desired)).not.toContain("cast:unresolved-domain-ref");
});
it("does not list domain refs as required secrets (capture validates but never captures them)", () => {
const dir = write(
LANDING + CORE + TMPL,
"ADMIN_WEB_BASE_URL=${domain:core.admin}\nLANDING_BASE_URL=${domain:landing}\nMG=${MG}\n",
);
const req = requiredSecrets(dir, "prod");
// Only the real ${MG} secret is required — the two domain refs are not.
expect(req.required.map((r) => r.ref)).toEqual(["MG"]);
});
it("refuses a ref naming an application the manifest does not declare", () => {
const dir = write(LANDING + TMPL, "X=${domain:nope}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(
/no application named nope/,
);
// Every verb that opens a template refuses it, in the same voice.
expect(() => desiredFromManifest(dir, "prod", {})).toThrow(
/no application named nope/,
);
});
it("refuses ${domain:<app>} on a compose app whose domains live per service", () => {
const dir = write(CORE + TMPL, "X=${domain:core}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(
/domains live per service/,
);
});
it("refuses ${domain:<app>.<service>} on an app that declares a plain domains list", () => {
const dir = write(LANDING + TMPL, "X=${domain:landing.admin}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(/plain `domains` list/);
});
it("refuses a service the app's service_domains does not declare", () => {
const dir = write(CORE + TMPL, "X=${domain:core.nope}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(/no service named nope/);
});
it("refuses a ref whose selected domain list is declared but empty", () => {
const dir = write(
` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: []
${TMPL}`,
"X=${domain:landing}\n",
);
expect(() => requiredSecrets(dir, "prod")).toThrow(/domain list is empty/);
});
it('refuses a ref whose selected list has a blank first entry (domains: [""]) — the sentinel must not escape', () => {
const dir = write(
` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: [""]
${TMPL}`,
"X=${domain:landing}\n",
);
// Schema-valid (a non-empty array of strings), so it PASSES manifest load —
// the assert is the gate. buildDomainMap would store "" and fillDomainEnv
// would read "" as unresolved, leaving DOMAIN_UNRESOLVED in a returned env.
expect(() => requiredSecrets(dir, "prod")).toThrow(
/empty or its first entry is blank/,
);
// Every verb that opens a template refuses it — the sentinel never escapes
// into a returned desired set.
expect(() => desiredFromManifest(dir, "prod", {})).toThrow(
/empty or its first entry is blank/,
);
});
it('refuses a service ref whose selected list has a blank first entry (service_domains: {admin: [""]})', () => {
const dir = write(
` core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
admin: [""]
${TMPL}`,
"X=${domain:core.admin}\n",
);
expect(() => requiredSecrets(dir, "prod")).toThrow(
/empty or its first entry is blank/,
);
});
it("gives the domains-app-shape message (not 'no service named') for a service ref against an empty-domains app", () => {
// An empty `domains: []` is still a domains app (shape is by key presence).
// A ${domain:app.svc} ref against it is a spurious-service error, not an
// unknown-service one.
const dir = write(
` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: []
${TMPL}`,
"X=${domain:landing.admin}\n",
);
expect(() => requiredSecrets(dir, "prod")).toThrow(/plain `domains` list/);
});
});