From 6f0b0f6fc10d2b4e81a81d2c6454562ad3ef6f56 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Wed, 15 Jul 2026 12:02:33 +0000 Subject: [PATCH] feat(resolve): derive base-URL env vars from manifest domains via ${domain:...} (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A public base URL an app reads (LANDING_BASE_URL, ADMIN_WEB_BASE_URL) is a fact the manifest already states in `domains`/`service_domains` — the same fields cast parses to reconcile Coolify domains. Hand-transcribing it into an env template is a second copy that drifts (incubator's prod LANDING_BASE_URL silently kept a pre-apex host). So a template can now say it directly: LANDING_BASE_URL=${domain:landing} ADMIN_WEB_BASE_URL=${domain:core.admin} - ${domain:} -> applications..domains[0] - ${domain:.} -> applications..service_domains.[0] Symmetric with ${resource:...} (#60) — parse -> sentinel -> validate -> fill — but a domain is PURE MANIFEST DATA, known at plan time, so it resolves fully in desiredFromManifest against a map built from the manifest: no live read, no executor deferral, no unresolved-at-write path. Domains are PUBLIC, so they resolve to secret:false (printed in diffs) and read as plain literals downstream (no diff.ts change). Not secrets: excluded from templateRefs, never captured. assertDomainRefs is the single validation gate (apply/diff/capture), refusing an undeclared app/service, a wrong-shape ref, or an empty/blank domain list before the sentinel can escape. Applications only (Coolify 4.1.2 can't set service domains). REPORTING_TZ-style operator literals stay literal. Closes #66. Co-Authored-By: Claude Opus 4.8 --- README.md | 10 +++ docs/semantics.md | 54 +++++++++++++ src/envtemplate.ts | 125 +++++++++++++++++++++++++++++- src/resolve.ts | 133 +++++++++++++++++++++++++++++++- test/envtemplate.test.ts | 82 ++++++++++++++++++++ test/resolve.test.ts | 162 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 561 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 06ff3a4..3b8565e 100644 --- a/README.md +++ b/README.md @@ -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_`, 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 diff --git a/docs/semantics.md b/docs/semantics.md index b4cc609..075613b 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -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:[.]}`) + +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:}` resolves to `applications..domains[0]` — the app's + **primary** domain. +- `${domain:.}` resolves to + `applications..service_domains.[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 ` writes that diff --git a/src/envtemplate.ts b/src/envtemplate.ts index e4fa47a..298446e 100644 --- a/src/envtemplate.ts +++ b/src/envtemplate.ts @@ -7,6 +7,17 @@ // live box, not decrypted. export type ResourceRef = { resource: string; attr: string }; +// A ${domain:[.]} reference — a public domain THIS manifest +// already declares (`applications..domains[0]`, or a compose app's +// `service_domains.[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:.attr} placeholder (a derived value). The two are mutually -// exclusive — a secret name is UPPER_SNAKE, a resource ref starts `resource:`. +// ${resource:.attr} placeholder (a derived value); `domainRef` when it is +// a single ${domain:[.]} 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, ): 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 `` or `.` (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, +): 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:[.]} 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 diff --git a/src/resolve.ts b/src/resolve.ts index c71dd64..e891c85 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -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: `` → `applications..domains[0]`, +// and `.` → `applications..service_domains.[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 { + const map: Record = {}; + 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}.}.`, + ].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 }; } diff --git a/test/envtemplate.test.ts b/test/envtemplate.test.ts index 78d9af4..3af9c8e 100644 --- a/test/envtemplate.test.ts +++ b/test/envtemplate.test.ts @@ -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", + ]); + }); +}); diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 9b292a0..0e1c48f 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -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:} and ${domain:.} 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:} 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:.} 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/); + }); +});