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:
commit
95c9befd2a
6 changed files with 561 additions and 5 deletions
10
README.md
10
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_<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
133
src/resolve.ts
133
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: `<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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue