diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 6d3263668..25cc9811a 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -339,6 +339,12 @@ Native `up` and `restart` also stay in the foreground in every project: `--detach` refuses with one `E_USAGE` line. Run `hack up` in its own terminal and keep it open, use `hack ps` and `hack logs` from another terminal, and press Ctrl-C to stop it. For detached startup, use the Compose backend. +Once the graph is ready, image-project `up` prints the run id, each routed +service's reviewed hostnames as HTTPS URLs (with the `HACK_NATIVE_HTTPS_PORT` +port unless it is 443) and the published loopback ports. Without the native +HTTPS frontend it says that routes are not served on this host. After Ctrl-C +and confirmed cleanup it prints one line saying whether named volumes and the +stopped run mapping were kept. ### Network aliases and the Compose project name @@ -998,7 +1004,11 @@ run mapping before exiting, so `hack ps`, `hack down` and `hack doctor --native-cleanup inspect` select that run instead of reporting the project as not started; nothing is stopped or replayed to do so. When the receipt itself cannot be authenticated, nothing is recorded and the runtime -journal keeps the graph for explicit recovery. +journal keeps the graph for explicit recovery. When the graph owner exited +before reserving any runtime state (for example a configuration refusal during +execution compilation), inspection finds no journal for the run: startup then +reports the failure code and that nothing was created, instead of an +unconfirmed cleanup. Managed values are declared on every authored service, so the reviewed plan is the same for every profile selection, but they are delivered only to the services the @@ -1717,12 +1727,27 @@ must then have a selected binding for each alias it declares. A missing binding refuses with `E_NATIVE_PROJECT_UNSUPPORTED` ("Native host alias has no reviewed host dependency binding; configuration was not dropped."), before any runtime work; with no dependency selection at all it refuses before lifecycle hooks. +The refusal lists every unbound alias with the services that declare it and +where it comes from: the service's Compose `extra_hosts`, `internal.extra_hosts` +in project configuration, this checkout's `.hack/.internal/extra-hosts.json`, or +the main checkout's map that a linked worktree inherits. It then names the +`aliases` a binding needs and how to remove the alias instead +(`hack internal extra-hosts unset ` hides an inherited entry in this +worktree). Listener values are never printed. An entry whose target is not `host-gateway` (for example a fixed address), a name that is not an exact lowercase DNS name, or a service that already maps the name to a different target also refuses. Service-declared names are matched in the -form native review compiles them to (one trailing dot removed, lowercased), so a -service that declares `API.Example.` is covered by a binding for `api.example` and -does not receive a second spelling. Earlier native builds ignored these entries, +form native execution compiles them to: Compose interpolation resolved as the +native runtime resolves it (no ambient environment, so `${UNSET:-word}` selects +`word` and `${UNSET:+word}` selects nothing), then one trailing dot removed and +lowercased. So a service that declares `API.Example.` or +`${UNSET_PREFIX:+x-}api.example` is covered by a binding for `api.example` and +does not receive a second copy of a project-wide `api.example`. A service whose +declarations compile to the same alias twice refuses with `E_CONFIG_INVALID`, +naming the service and alias, before any runtime work; native execution would +refuse it only after the VM started. A name that refers to a required variable, +a Hack-managed value or `COMPOSE_PROJECT_NAME` is left to native review and +execution. Earlier native builds ignored these entries, which let a container resolve the public name instead of the local listener. Native reads these aliases before lifecycle hooks, because they are part of the reviewed configuration; unlike the Compose backend, a hook that changes them makes diff --git a/src/backends/native-compose-interpolation.ts b/src/backends/native-compose-interpolation.ts new file mode 100644 index 000000000..f09feda33 --- /dev/null +++ b/src/backends/native-compose-interpolation.ts @@ -0,0 +1,199 @@ +import { COMPOSE_PROJECT_NAME_VARIABLE } from "./native-compose-project-name.ts"; + +/** + * Predict the value native execution compiles one Compose string to, before + * any runtime work. + * + * Native execution resolves Compose interpolation itself (runtime-core + * `project::inputs::Resolver`) from an explicit map: no process environment or + * dotenv file is consulted, and the only bound name is `COMPOSE_PROJECT_NAME` + * (the reviewed Compose project name). Every other reference is therefore + * unset: `${NAME:-word}` selects `word`, `${NAME:+word}` selects nothing, and + * `$NAME` or `${NAME}` refuses at execution. + * + * This mirrors that grammar exactly: `$$` escapes, braced `:-` and `:+` + * operators whose words hold literal text, `$$` and nested braced expressions + * (at most four levels), and unbraced references. It returns null whenever + * native execution would refuse the text or the value is not knowable yet: a + * required reference, unsupported syntax, a Hack-managed name (delivered + * privately and never interpolated), or `COMPOSE_PROJECT_NAME`, which is bound + * only after review. Callers must treat null as "unknown" and leave the final + * decision to native review and execution, which stay the authority. + */ +export function resolveNativeComposeText(opts: { + readonly text: string; + /** Names Hack delivers privately to the service; a reference refuses. */ + readonly managed?: ReadonlySet; +}): string | null { + if (opts.text.length > MAX_TEXT) { + return null; + } + try { + return resolve(opts.text, opts.managed ?? EMPTY); + } catch (error) { + if (error === UNKNOWN) { + return null; + } + throw error; + } +} + +const MAX_TEXT = 64 * 1024; +/** Compose nesting such as `${A:-${B:-${C:-${D:-word}}}}` is bounded at four levels. */ +const MAX_DEPTH = 4; +const EMPTY: ReadonlySet = new Set(); +const VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const NAME_BYTE = /[A-Za-z0-9_]/; +/** Thrown internally for any text whose native value is not knowable here. */ +const UNKNOWN = Symbol("unknown"); + +function unknown(): never { + throw UNKNOWN; +} + +/** + * Validate a referenced name exactly as the runtime does before lookup. A live + * reference to the project name is unknown here; elsewhere it is irrelevant. + */ +function checked( + name: string, + managed: ReadonlySet, + live: boolean +): void { + if ( + !VARIABLE.test(name) || + managed.has(name) || + (live && name === COMPOSE_PROJECT_NAME_VARIABLE) + ) { + unknown(); + } +} + +/** Index of the `}` closing a body that follows `${`, as the runtime counts it. */ +function bracedEnd(text: string): number { + let depth = 1; + let index = 0; + while (index < text.length) { + if (text.startsWith("$$", index)) { + index += 2; + continue; + } + if (text.startsWith("${", index)) { + depth += 1; + index += 2; + continue; + } + if (text[index] === "}") { + depth -= 1; + if (depth === 0) { + return index; + } + } + index += 1; + } + return unknown(); +} + +function resolve(text: string, managed: ReadonlySet): string { + let result = ""; + let rest = text; + for (;;) { + const index = rest.indexOf("$"); + if (index < 0) { + return result + rest; + } + result += rest.slice(0, index); + rest = rest.slice(index + 1); + if (rest.startsWith("$")) { + result += "$"; + rest = rest.slice(1); + continue; + } + if (rest.startsWith("{")) { + const body = rest.slice(1); + const end = bracedEnd(body); + result += expression(body.slice(0, end), 1, true, managed); + rest = body.slice(end + 1); + continue; + } + // An unbraced reference is always required, and no supplied value exists. + let end = 0; + while (end < rest.length && NAME_BYTE.test(rest[end] ?? "")) { + end += 1; + } + checked(rest.slice(0, end), managed, true); + return unknown(); + } +} + +/** + * One braced body. `live` is false inside a word the operator does not select: + * it is still validated, but a missing input there is not required. + */ +function expression( + body: string, + depth: number, + live: boolean, + managed: ReadonlySet +): string { + const colon = body.indexOf(":"); + if (colon < 0) { + checked(body, managed, live); + // A live bare reference is required; nothing is supplied. + return live ? unknown() : ""; + } + const name = body.slice(0, colon); + const suffix = body.slice(colon + 1); + if (suffix === "") { + return unknown(); + } + checked(name, managed, live); + const operator = suffix[0]; + if (operator !== "+" && operator !== "-") { + return unknown(); + } + // Every checked name is unset, so `:-` selects its word and `:+` does not. + const selected = operator === "-"; + const value = word(suffix.slice(1), depth, live && selected, managed); + return selected ? value : ""; +} + +/** Literal text, `$$` escapes and nested braced expressions only. */ +function word( + text: string, + depth: number, + live: boolean, + managed: ReadonlySet +): string { + let result = ""; + let rest = text; + for (;;) { + const found = rest.indexOf("$"); + const index = found < 0 ? rest.length : found; + const literal = rest.slice(0, index); + if (literal.includes("{") || literal.includes("}")) { + return unknown(); + } + if (live) { + result += literal; + } + if (index === rest.length) { + return result; + } + rest = rest.slice(index + 1); + if (rest.startsWith("$")) { + if (live) { + result += "$"; + } + rest = rest.slice(1); + continue; + } + if (!rest.startsWith("{") || depth >= MAX_DEPTH) { + return unknown(); + } + const body = rest.slice(1); + const end = bracedEnd(body); + result += expression(body.slice(0, end), depth + 1, live, managed); + rest = body.slice(end + 1); + } +} diff --git a/src/backends/native-project-internal-hosts.ts b/src/backends/native-project-internal-hosts.ts index 2330a18cc..5e2504abb 100644 --- a/src/backends/native-project-internal-hosts.ts +++ b/src/backends/native-project-internal-hosts.ts @@ -1,11 +1,15 @@ import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; -import { resolveInternalExtraHosts } from "../lib/internal-extra-hosts.ts"; +import { + resolveExtraHostsPath, + resolveInternalExtraHosts, +} from "../lib/internal-extra-hosts.ts"; import { type ProjectConfig, type ProjectContext, readProjectConfig, } from "../lib/project.ts"; +import { resolveNativeComposeText } from "./native-compose-interpolation.ts"; import type { NativeHostDependency } from "./native-project-dependencies.ts"; import type { NativeProjectInput } from "./native-project-input.ts"; @@ -24,21 +28,98 @@ import type { NativeProjectInput } from "./native-project-input.ts"; const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const NUMERIC_HOST = /^[0-9.]+$/; -const SEPARATOR = /[:=]/; const MAX_ALIASES = 32; -/** Fixed text when a declared host alias has no reviewed dependency binding. */ +/** Leading text when a declared host alias has no reviewed dependency binding. */ export const NATIVE_UNBOUND_HOST_ALIAS_REFUSAL = - "Native host alias has no reviewed host dependency binding; configuration was not dropped. Select a host dependency binding for every service that declares the alias."; + "Native host alias has no reviewed host dependency binding; configuration was not dropped."; /** Fixed text when a project-wide host alias cannot be expressed natively. */ export const NATIVE_INTERNAL_HOST_ALIAS_REFUSAL = "Native up supports internal extra_hosts only as exact host-gateway aliases with reviewed host dependency bindings; configuration was not dropped."; -function unbound(): Error { +/** Leading text when a service's declared host aliases cannot be compiled natively. */ +export const NATIVE_HOST_ALIAS_CONFLICT_REFUSAL = + "Native host alias selection conflicts with declared configuration"; + +/** + * Where a project-wide alias comes from, so a refusal can point at the file to + * change. `inherited` is the main checkout's checkout-local map, which a linked + * worktree inherits unless it hides the entry. + */ +export type NativeHostAliasSource = "project" | "checkout" | "inherited"; + +const PRINTABLE_SERVICE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +/** Compose service names are printed only in their validated form. */ +function serviceLabel(name: string): string { + return PRINTABLE_SERVICE.test(name) ? `service ${name}` : "a service"; +} + +function describeSource(source: NativeHostAliasSource | undefined): string { + switch (source) { + case "project": + return "internal.extra_hosts in the project configuration"; + case "checkout": + return "this checkout's .hack/.internal/extra-hosts.json"; + case "inherited": + return "the main checkout's .hack/.internal/extra-hosts.json, which this linked worktree inherits"; + default: + return "the service's Compose extra_hosts"; + } +} + +function removalHint( + alias: string, + source: NativeHostAliasSource | undefined +): string { + switch (source) { + case "project": + return `remove ${alias} from internal.extra_hosts in the project configuration`; + case "checkout": + return `run \`hack internal extra-hosts unset ${alias}\``; + case "inherited": + return `run \`hack internal extra-hosts unset ${alias}\` in this worktree to stop inheriting it`; + default: + return `remove ${alias} from the service's extra_hosts`; + } +} + +type UnboundAlias = { + readonly alias: string; + readonly services: readonly string[]; + readonly source: NativeHostAliasSource | undefined; +}; + +/** At most this many aliases are listed; the rest are counted. */ +const MAX_LISTED = 8; + +/** + * Name every unbound (service, alias) pair, where each alias comes from, and + * the binding that would admit it. Aliases are validated DNS names and service + * names are printed only when they match Compose's name rule, so the line + * carries configuration names only: never targets, ports or listener values. + */ +function unbound(missing: readonly UnboundAlias[]): Error { + const listed = missing + .slice(0, MAX_LISTED) + .map( + (entry) => + `${entry.alias} for ${entry.services.map(serviceLabel).join(", ")} (from ${describeSource(entry.source)})` + ); + const more = + missing.length > MAX_LISTED + ? `; and ${missing.length - MAX_LISTED} more aliases` + : ""; + const first = missing[0]; + const aliases = JSON.stringify( + missing.slice(0, MAX_LISTED).map((entry) => entry.alias) + ); + const hint = first + ? ` Add a binding with "aliases": ${aliases} to the HACK_NATIVE_DEPENDENCIES selection (a version 2 entry without "services" applies to every service that declares them), or ${removalHint(first.alias, first.source)}.` + : ""; return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: NATIVE_UNBOUND_HOST_ALIAS_REFUSAL, + message: `${NATIVE_UNBOUND_HOST_ALIAS_REFUSAL} Unbound: ${listed.join("; ")}${more}.${hint}`, }); } function unsupported(): Error { @@ -50,8 +131,21 @@ function unsupported(): Error { function conflict(): Error { return new HackCliError({ code: "E_CONFIG_INVALID", - message: - "Native host alias selection conflicts with declared configuration; values omitted.", + message: `${NATIVE_HOST_ALIAS_CONFLICT_REFUSAL}; values omitted.`, + }); +} +/** A target other than `host-gateway` for a project-wide alias; the target is omitted. */ +function conflictingTarget(service: string, alias: string): Error { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: `${NATIVE_HOST_ALIAS_CONFLICT_REFUSAL}: ${serviceLabel(service)} maps project-wide host alias ${alias} to another target; native supports only host-gateway for it. Target value omitted.`, + }); +} +/** Two declarations compile to one runtime alias, which native execution refuses. */ +function duplicateAlias(service: string, alias: string): Error { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: `${NATIVE_HOST_ALIAS_CONFLICT_REFUSAL}: ${serviceLabel(service)} declares host alias ${alias} more than once after Compose interpolation and name normalization; keep one declaration. Nothing was started.`, }); } @@ -70,12 +164,11 @@ function exactAlias(value: string): boolean { const ASCII = /^[\x20-\x7e]*$/; /** - * The name native review compiles a service-declared extra host to, with the + * The name native review compiles a resolved extra host name to, with the * runtime's `normalize_hostname` semantics: strip one trailing dot, lowercase * ASCII, and require an exact DNS name (not an address). Returns null for a - * name this check cannot canonicalize (for example an `${...}` reference the - * runtime resolves later); native admission still enforces exact alias and - * binding equality for those. + * name this check cannot canonicalize; native admission still enforces exact + * alias and binding equality for those. */ function canonicalHostName(value: string): string | null { if (!ASCII.test(value)) { @@ -93,10 +186,87 @@ function canonicalHostName(value: string): string | null { return name; } +/** + * The runtime alias one declared `extra_hosts` entry compiles to: Compose + * interpolation as native execution resolves it (see + * {@link resolveNativeComposeText}), then name canonicalization. Null when + * the value is not knowable before execution or is not an exact DNS name. + */ +function compiledHostName( + value: string, + managed: ReadonlySet +): string | null { + const resolved = resolveNativeComposeText({ text: value, managed }); + return resolved === null ? null : canonicalHostName(resolved); +} + +/** A declared target as native execution resolves it; null when unknown. */ +function compiledTarget( + value: string, + managed: ReadonlySet +): string | null { + return resolveNativeComposeText({ text: value, managed }); +} + +/** + * The (compiled name, compiled target) of each declaration that resolves + * before execution, refusing a service that declares one alias twice: the + * runtime refuses that only after the VM boots. Unknown declarations are left + * to review and execution, keyed by their literal text. + */ +function compiledEntries(opts: { + readonly service: string; + readonly entries: readonly (readonly [string, string])[]; + readonly managed: ReadonlySet; +}): Map { + const declared = new Map(); + for (const [name, target] of opts.entries) { + const compiled = compiledHostName(name, opts.managed); + if (compiled !== null && declared.has(compiled)) { + throw duplicateAlias(opts.service, compiled); + } + declared.set(compiled ?? name, compiledTarget(target, opts.managed)); + } + return declared; +} + +function managedNames( + managedEnvironment: + | Readonly>>> + | undefined, + service: string +): ReadonlySet { + const values = managedEnvironment + ? Object.getOwnPropertyDescriptor(managedEnvironment, service)?.value + : undefined; + return new Set(isRecord(values) ? Object.keys(values) : []); +} + +/** + * The first `:` or `=` outside `{...}`, exactly as native review splits a list + * entry, so the `:` of an interpolation such as `${PREFIX:+x-}` stays in the + * name. -1 when there is none. + */ +function separatorIndex(entry: string): number { + let depth = 0; + for (let index = 0; index < entry.length; index += 1) { + const c = entry[index]; + if (c === "{") { + depth += 1; + } else if (c === "}") { + depth = Math.max(0, depth - 1); + } else if ((c === ":" || c === "=") && depth === 0) { + return index; + } + } + return -1; +} + /** * Split one Compose `extra_hosts` value into (name, target) pairs, using the - * first `:` or `=` like the native review does. Returns undefined for a shape - * native review would refuse; callers leave that refusal to review. + * first `:` or `=` outside braces like the native review does. Returns + * undefined for a shape native review would refuse; callers leave that + * refusal to review. */ function extraHostEntries( value: unknown @@ -122,11 +292,11 @@ function extraHostEntries( if (typeof entry !== "string") { return undefined; } - const match = SEPARATOR.exec(entry); - if (!match) { + const index = separatorIndex(entry); + if (index < 0) { return undefined; } - entries.push([entry.slice(0, match.index), entry.slice(match.index + 1)]); + entries.push([entry.slice(0, index), entry.slice(index + 1)]); } return entries; } @@ -145,27 +315,31 @@ function internalAliasNames(hosts: Readonly>): string[] { return names.sort(); } -function declareOnService( - service: Record, - aliases: readonly string[] -): void { +function declareOnService(opts: { + readonly name: string; + readonly service: Record; + readonly aliases: readonly string[]; + readonly managed: ReadonlySet; +}): void { + const { service, aliases } = opts; const entries = extraHostEntries(service.extra_hosts); if (!entries) { throw conflict(); } // Compare by the compiled name: `API.Example.` and `api.example` are one - // runtime alias, so adding the second spelling would duplicate it. - const declared = new Map(); - for (const [name, target] of entries) { - declared.set(canonicalHostName(name) ?? name, target); - } + // runtime alias, and so is `${UNSET:+x-}api.example` once execution + // resolves it; adding a second spelling would duplicate the alias. + const declared = compiledEntries({ + service: opts.name, + entries, + managed: opts.managed, + }); const added: string[] = []; for (const alias of aliases) { - const target = declared.get(alias); - if (target === undefined) { + if (!declared.has(alias)) { added.push(alias); - } else if (target !== "host-gateway") { - throw conflict(); + } else if (declared.get(alias) !== "host-gateway") { + throw conflictingTarget(opts.name, alias); } } if (added.length === 0) { @@ -204,28 +378,54 @@ function declareOnService( export function applyNativeInternalHostAliases(opts: { readonly input: NativeProjectInput; readonly hosts?: Readonly>; + /** Where each project-wide alias comes from, for refusal text only. */ + readonly sources?: Readonly>; readonly hasDependencySelection: boolean; }): NativeProjectInput { if (opts.hosts === undefined || Object.keys(opts.hosts).length === 0) { return opts.input; } const aliases = internalAliasNames(opts.hosts); - if (!opts.hasDependencySelection) { - throw unbound(); - } const compose: unknown = JSON.parse(opts.input.normalizedComposeJson); if (!(isRecord(compose) && isRecord(compose.services))) { throw conflict(); } - for (const service of Object.values(compose.services)) { + if (!opts.hasDependencySelection) { + const services = Object.keys(compose.services).sort(); + throw unbound( + aliases.map((alias) => ({ + alias, + services, + source: sourceOf(opts.sources, alias), + })) + ); + } + for (const [name, service] of Object.entries(compose.services)) { if (!isRecord(service)) { throw conflict(); } - declareOnService(service, aliases); + declareOnService({ + name, + service, + aliases, + managed: managedNames(opts.input.managedEnvironment, name), + }); } return { ...opts.input, normalizedComposeJson: JSON.stringify(compose) }; } +function sourceOf( + sources: Readonly> | undefined, + alias: string +): NativeHostAliasSource | undefined { + const value: unknown = sources + ? Object.getOwnPropertyDescriptor(sources, alias)?.value + : undefined; + return value === "project" || value === "checkout" || value === "inherited" + ? value + : undefined; +} + function activeService( spec: Readonly>, profiles: readonly string[] @@ -245,17 +445,23 @@ function activeService( /** * The `host-gateway` aliases each profile-active service declares, keyed by - * service, in the canonical form native review compiles them to (see - * {@link canonicalHostName}), so a declared `API.Example.` matches a binding - * for `api.example`. Inactive services are never scheduled and receive no - * bindings, so they need none. Malformed or not-yet-resolved declarations are - * left to review and admission. + * service, in the form native execution compiles them to: Compose + * interpolation resolved as the runtime resolves it, then canonicalized (see + * {@link compiledHostName}), so a declared `API.Example.` or + * `${UNSET:-api.example}` matches a binding for `api.example`. A service that + * declares one alias twice refuses here, before any runtime work. Inactive + * services are never scheduled and receive no bindings, so they need none. + * Malformed or not-yet-knowable declarations are left to review and admission. */ export function nativeServiceHostAliases(opts: { readonly services: Readonly< Record>> >; readonly profiles: readonly string[]; + /** Names delivered privately per service; references to them are unknown. */ + readonly managedEnvironment?: Readonly< + Record>> + >; }): Record { const result: Record = {}; for (const [name, spec] of Object.entries(opts.services)) { @@ -263,9 +469,12 @@ export function nativeServiceHostAliases(opts: { continue; } const aliases = new Set(); - for (const [alias, target] of extraHostEntries(spec.extra_hosts) ?? []) { + const entries = extraHostEntries(spec.extra_hosts) ?? []; + const managed = managedNames(opts.managedEnvironment, name); + const declared = compiledEntries({ service: name, entries, managed }); + for (const [alias, target] of declared) { const canonical = canonicalHostName(alias); - if (target === "host-gateway" && canonical !== null) { + if (target === "host-gateway" && canonical === alias) { aliases.add(canonical); } } @@ -282,25 +491,38 @@ export function nativeServiceHostAliases(opts: { /** * Fail closed when an active service declares a host-gateway alias that no * selected binding of that service covers. Native admission later enforces - * exact equality as well; this refuses earlier, before runtime effects, with - * a value-free line. + * exact equality as well; this refuses earlier, before runtime effects. The + * refusal names every unbound (service, alias) pair and where each alias + * comes from, but never listener values. */ export function requireNativeHostAliasBindings(opts: { readonly dependencies: readonly NativeHostDependency[]; readonly hostAliases: Readonly>; + /** Where each project-wide alias comes from, for refusal text only. */ + readonly sources?: Readonly>; }): void { const bound = new Set( opts.dependencies.flatMap((dependency) => dependency.aliases.map((alias) => `${dependency.service}\0${alias}`) ) ); + const missing = new Map(); for (const [service, aliases] of Object.entries(opts.hostAliases)) { for (const alias of aliases) { if (!bound.has(`${service}\0${alias}`)) { - throw unbound(); + missing.set(alias, [...(missing.get(alias) ?? []), service]); } } } + if (missing.size > 0) { + throw unbound( + [...missing.keys()].sort().map((alias) => ({ + alias, + services: (missing.get(alias) ?? []).sort(), + source: sourceOf(opts.sources, alias), + })) + ); + } } /** Fixed text when lifecycle hooks changed project-wide aliases after review input. */ @@ -335,18 +557,49 @@ export async function resolveNativeInternalExtraHosts(opts: { readonly project: ProjectContext; readonly cfg?: ProjectConfig; }): Promise> { + return (await resolveNativeInternalExtraHostSelection(opts)).hosts; +} + +/** + * {@link resolveNativeInternalExtraHosts} plus where each effective alias + * comes from: `internal.extra_hosts` in project configuration, this + * checkout's local map, or the main checkout's local map a linked worktree + * inherits. Sources are used only to explain refusals. + */ +export async function resolveNativeInternalExtraHostSelection(opts: { + readonly project: ProjectContext; + readonly cfg?: ProjectConfig; +}): Promise<{ + readonly hosts: Record; + readonly sources: Record; +}> { try { const cfg = opts.cfg ?? (await readProjectConfig(opts.project)); if (cfg.parseError) { throw new Error("invalid project configuration"); } - return ( - await resolveInternalExtraHosts({ - projectRoot: opts.project.projectRoot, - projectDir: opts.project.projectDir, - staticHosts: cfg.internal?.extraHosts, - }) - ).hosts; + const resolved = await resolveInternalExtraHosts({ + projectRoot: opts.project.projectRoot, + projectDir: opts.project.projectDir, + staticHosts: cfg.internal?.extraHosts, + }); + const local = resolveExtraHostsPath(opts.project.projectDir); + const sources: Record = {}; + for (const [alias, origin] of Object.entries(resolved.origins)) { + let source: NativeHostAliasSource = "inherited"; + if (origin === "internal.extra_hosts") { + source = "project"; + } else if (origin === local) { + source = "checkout"; + } + Object.defineProperty(sources, alias, { + value: source, + enumerable: true, + writable: true, + configurable: true, + }); + } + return { hosts: resolved.hosts, sources }; } catch { throw new HackCliError({ code: "E_CONFIG_INVALID", diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index d654ffb45..a06616231 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -19,6 +19,7 @@ import { prepareNativeProjectInput } from "./native-project-input.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { applyNativeInternalHostAliases, + type NativeHostAliasSource, nativeServiceHostAliases, requireNativeHostAliasBindings, } from "./native-project-internal-hosts.ts"; @@ -337,6 +338,10 @@ export async function preflightNativeRestart(opts: { readonly dependencyFile?: string; /** Same project-wide host aliases the replacement startup declares. */ readonly internalExtraHosts?: Readonly>; + /** Where each project-wide alias comes from, for refusal text only. */ + readonly internalExtraHostSources?: Readonly< + Record + >; readonly allowedHosts?: readonly string[]; /** Same public metadata selection the replacement startup will review. */ readonly runtimeMetadata?: NativeRuntimeMetadataSelection; @@ -383,6 +388,7 @@ export async function preflightNativeRestart(opts: { input = applyNativeInternalHostAliases({ input, hosts: opts.internalExtraHosts, + sources: opts.internalExtraHostSources, hasDependencySelection: opts.dependencyFile !== undefined, }); const reviewedBranch = await prepareNativeReviewBranch({ @@ -420,6 +426,7 @@ export async function preflightNativeRestart(opts: { const hostAliases = nativeServiceHostAliases({ services: specs, profiles: selected.profiles, + managedEnvironment: input.managedEnvironment, }); const dependencies = stoppedRetry ? await readNativeRestartDependencyIntent({ @@ -440,7 +447,11 @@ export async function preflightNativeRestart(opts: { invoke: deps.invoke, }), }); - requireNativeHostAliasBindings({ dependencies, hostAliases }); + requireNativeHostAliasBindings({ + dependencies, + hostAliases, + sources: opts.internalExtraHostSources, + }); prepareNativeDependencyServices({ dependencies, services: specs }); const activeImages = stoppedRetry ? undefined diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 2d3c6f579..6debd0d44 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -60,6 +60,7 @@ import { import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { applyNativeInternalHostAliases, + type NativeHostAliasSource, nativeServiceHostAliases, requireNativeHostAliasBindings, } from "./native-project-internal-hosts.ts"; @@ -106,6 +107,8 @@ import { import { preflightNativeProjectSource } from "./native-project-source-preflight.ts"; import { invokeNativeRuntime, + NATIVE_GRAPH_UNRESERVED, + NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; import { @@ -289,6 +292,8 @@ type Dependencies = { adaptGcloud: typeof adaptNativeGcloudEnvironment; /** Value-free operator notices (for example credential expiry); stderr by default. */ warn: (line: string) => void; + /** Foreground progress lines (readiness, confirmed stop); stderr by default. */ + notice: (line: string) => void; review: typeof withNativeProjectReview; selectReview: typeof selectNativeProjectReviewIdentity; retainedImages: typeof selectNativeRetainedImages; @@ -316,6 +321,9 @@ const DEFAULTS: Dependencies = { warn: (line) => { process.stderr.write(`${line}\n`); }, + notice: (line) => { + process.stderr.write(`${line}\n`); + }, review: withNativeProjectReview, selectReview: selectNativeProjectReviewIdentity, retainedImages: selectNativeRetainedImages, @@ -623,6 +631,77 @@ export async function verifyHttpsRoutes( } } +const PRINTABLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const PRINTABLE_HOST = /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/; +/** + * What a ready foreground graph serves on this host, from the reviewed plan + * only: each routed service's reviewed hostnames as HTTPS URLs when the + * native HTTPS frontend runs (with its port unless 443), and the published + * loopback ports. Names that fail their closed patterns are skipped. + */ +export function nativeReadySummary(opts: { + readonly run: string; + readonly plan: unknown; + readonly routed: ReadonlySet; + readonly httpsPort: number | null; +}): string[] { + const lines = [ + `Native project ready (run ${RUN.test(opts.run) ? opts.run : "unknown"}). Press Ctrl-C to stop; use hack ps and hack logs from another terminal.`, + ]; + const services = + isRecord(opts.plan) && isRecord(opts.plan.services) + ? opts.plan.services + : {}; + const suffix = + opts.httpsPort === null || opts.httpsPort === 443 + ? "" + : `:${opts.httpsPort}`; + let unserved = false; + for (const name of [...opts.routed].sort()) { + const service = services[name]; + const hostnames = + isRecord(service) && + isRecord(service.routing) && + Array.isArray(service.routing.hostnames) + ? service.routing.hostnames.filter( + (host): host is string => + typeof host === "string" && PRINTABLE_HOST.test(host) + ) + : []; + if (!PRINTABLE_NAME.test(name) || hostnames.length === 0) { + continue; + } + if (opts.httpsPort === null) { + unserved = true; + continue; + } + lines.push( + ` ${name}: ${hostnames.map((host) => `https://${host}${suffix}`).join(" ")}` + ); + } + if (unserved) { + lines.push( + " Routes are not served on this host: the native HTTPS frontend is not selected (HACK_NATIVE_CADDY_BINARY, HACK_NATIVE_CADDY_SHA256 and HACK_NATIVE_HTTPS_PORT)." + ); + } + const ports = reviewedNativeLoopbackPorts(opts.plan); + if (ports.length > 0) { + lines.push( + ` Published ports: ${ports.map((port) => `127.0.0.1:${port}`).join(" ")}` + ); + } + return lines; +} +/** One confirmed-stop line for an interrupted ready graph, from its final receipt. */ +export function nativeStoppedNotice(final: unknown): string { + const removed = + isRecord(final) && + isRecord(final.receipt) && + final.receipt.phase === "removed"; + return removed + ? "Native project stopped; owned cleanup confirmed and no runtime data was retained." + : "Native project stopped; owned cleanup confirmed. Named volumes and the stopped run mapping are kept for the next hack up."; +} function foregroundExitCode(opts: { aborted: boolean; interruptedExit: number; @@ -787,6 +866,80 @@ function cleanupUnconfirmed( `Native foreground exit cleanup is unconfirmed; runtime and bridge state may be retained. Any published run mapping is retained; inspect owned state before retrying.${startup}${nativeCode ? ` Native exit diagnostic: ${nativeCode}.` : ""}${recorded}` ); } +const NATIVE_CODE = /^[A-Za-z0-9_]{1,80}$/; +/** + * A fresh startup whose graph owner exited before reserving any runtime state: + * inspection reports the run's journal exactly absent. Nothing was created, so + * no cleanup is pending and the startup diagnostic is the whole story. + */ +function nothingReserved(startupFailure: unknown, nativeCode?: string): Error { + const message = startupFailure instanceof Error ? startupFailure.message : ""; + const diagnostic = SAFE_STARTUP_DIAGNOSTIC.exec(message); + const code = + nativeCode && NATIVE_CODE.test(nativeCode) ? nativeCode : diagnostic?.[2]; + return new ClosedCliError( + `Native graph startup failed${code ? ` (${code})` : ""} before any runtime state was reserved; nothing was created and no cleanup is needed.` + ); +} +/** + * The report when final inspection of a settled startup fails. The graph + * owner has exited (serve settles only after its exit), so for a fresh run + * that never attempted a mapping, an exactly absent journal proves nothing was + * reserved ({@link nothingReserved}). Every other case keeps the + * unconfirmed-cleanup report. + */ +function uninspectableStartupFailure(opts: { + readonly serveFailure: unknown; + readonly inspectionFailure: unknown; + readonly restored: boolean; + readonly mappingAttempted: boolean; + readonly nativeCode: string | undefined; +}): { readonly error: Error; readonly nothingReserved: boolean } { + if ( + opts.serveFailure === undefined || + opts.restored || + opts.mappingAttempted || + !(opts.inspectionFailure instanceof NativeRuntimeRequestError) || + opts.inspectionFailure.nativeCode !== NATIVE_GRAPH_UNRESERVED + ) { + return { + error: cleanupUnconfirmed( + opts.serveFailure ?? opts.inspectionFailure, + opts.nativeCode + ), + nothingReserved: false, + }; + } + return { + error: nothingReserved(opts.serveFailure, opts.nativeCode), + nothingReserved: true, + }; +} +/** The HTTPS port routes are served on: only while the frontend lease runs. */ +function servedHttpsPort( + lease: unknown, + selection: NativeHttpsSelection | undefined +): number | null { + return lease === undefined ? null : (selection?.httpsPort ?? null); +} +/** The confirmed-stop line, only for an interrupted graph that published a mapping. */ +function stoppedLines(opts: { + readonly aborted: boolean; + readonly published: boolean; + readonly final: unknown; +}): string[] { + return opts.aborted && opts.published + ? [nativeStoppedNotice(opts.final)] + : []; +} +function reportLines( + notice: (line: string) => void, + lines: readonly string[] +): void { + for (const line of lines) { + notice(line); + } +} function requireConfirmedCleanup( final: unknown, startupFailure?: unknown, @@ -1186,6 +1339,10 @@ export async function startNativeProject(opts: { * override; each active pair then requires a reviewed dependency binding. */ readonly internalExtraHosts?: Readonly>; + /** Where each project-wide alias comes from, for refusal text only. */ + readonly internalExtraHostSources?: Readonly< + Record + >; readonly allowedHosts?: readonly string[]; readonly preparedBase?: NativePreparedBase; readonly https?: NativeHttpsSelection; @@ -1296,6 +1453,7 @@ export async function startNativeProject(opts: { input = applyNativeInternalHostAliases({ input, hosts: opts.internalExtraHosts, + sources: opts.internalExtraHostSources, hasDependencySelection: opts.dependencyFile !== undefined, }); input = ( @@ -1372,6 +1530,7 @@ export async function startNativeProject(opts: { const hostAliases = nativeServiceHostAliases({ services: specs, profiles, + managedEnvironment: input.managedEnvironment, }); const hostDependencies = await readNativeHostDependencies({ path: opts.dependencyFile, @@ -1394,6 +1553,7 @@ export async function startNativeProject(opts: { requireNativeHostAliasBindings({ dependencies: hostDependencies, hostAliases, + sources: opts.internalExtraHostSources, }); prepareNativeDependencyServices({ dependencies: hostDependencies, @@ -1748,6 +1908,15 @@ export async function startNativeProject(opts: { } await hooks?.ready?.(); await opts.onReady?.(); + reportLines( + deps.notice, + nativeReadySummary({ + run, + plan: review.report.plan, + routed: routes.services, + httpsPort: servedHttpsPort(https, opts.https), + }) + ); // Last: only microtasks separate this from readiness. await stop?.owe(() => controller.signal.aborted); }, @@ -1762,10 +1931,15 @@ export async function startNativeProject(opts: { try { final = await invokeInspect(); } catch (inspectionFailure) { - throw cleanupUnconfirmed( - serveFailure ?? inspectionFailure, - nativeExitCode - ); + const settled = uninspectableStartupFailure({ + serveFailure, + inspectionFailure, + restored: restore !== undefined, + mappingAttempted, + nativeCode: nativeExitCode, + }); + graphCleanupConfirmed = settled.nothingReserved; + throw settled.error; } const confirmed = await confirmCleanupOrRecordOwner({ confirm: { @@ -1814,13 +1988,22 @@ export async function startNativeProject(opts: { mappingAttempted, invoke: deps.invoke, }); - return foregroundExitCode({ + const exit = foregroundExitCode({ aborted: controller.signal.aborted, interruptedExit: stopSignals.interruptedExit(), failure: serveFailure, infrastructureFailure: httpsFailure, code, }); + reportLines( + deps.notice, + stoppedLines({ + aborted: controller.signal.aborted, + published: mapping !== undefined, + final, + }) + ); + return exit; } catch (error) { throw withStartupLogReport(error, startupLogReport); } diff --git a/src/backends/native-startup-logs.ts b/src/backends/native-startup-logs.ts index 2c8f58ce4..347b58954 100644 --- a/src/backends/native-startup-logs.ts +++ b/src/backends/native-startup-logs.ts @@ -128,8 +128,10 @@ function needles(values: Iterable): { * JSON-escaped form and each line of a multi-line value that is long enough, * with and without surrounding whitespace. Longer values are replaced first. * - A shorter value (one to three characters) is replaced only where it stands as - * a whole token, not next to a letter, digit or underscore, so a value such as - * `1` or `on` cannot shred unrelated output. + * a whole token: not next to a letter, digit or underscore, and not joined to + * one by a `.` or `-`. So a value such as `1`, `0` or `on` cannot shred + * unrelated output such as a version string (`8.0.46`), a decimal or a + * hyphenated word, while a value ending a sentence (`mode on.`) still matches. * - Remaining control characters except tab and newline become U+FFFD. */ export function redactNativeStartupLog( @@ -143,7 +145,7 @@ export function redactNativeStartupLog( } for (const value of tokens) { const pattern = new RegExp( - `(?[0] = { runtime: native, scope: { @@ -5906,6 +5904,7 @@ async function handleNativeUp({ dependencyFile: process.env.HACK_NATIVE_DEPENDENCIES, adaptationFile: process.env.HACK_NATIVE_ADAPTATION, internalExtraHosts, + internalExtraHostSources, allowedHosts: parseNativeAllowedHosts(process.env.HACK_NATIVE_ALLOW_HOSTS), preparedBase: parseNativePreparedBase(process.env), https: parseNativeHttpsSelection(process.env), @@ -6108,6 +6107,7 @@ async function restartNativeImageProject({ adaptationFile: startup.adaptationFile, dependencyFile: startup.dependencyFile, internalExtraHosts: startup.internalExtraHosts, + internalExtraHostSources: startup.internalExtraHostSources, allowedHosts: startup.allowedHosts, runtimeMetadata: startup.runtimeMetadata, httpsPort: startup.https?.httpsPort ?? null, diff --git a/tests/internal-extra-hosts-worktree.test.ts b/tests/internal-extra-hosts-worktree.test.ts index 4ee15c06c..02f075a5b 100644 --- a/tests/internal-extra-hosts-worktree.test.ts +++ b/tests/internal-extra-hosts-worktree.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { resolveNativeInternalExtraHostSelection } from "../src/backends/native-project-internal-hosts.ts"; import { resolveInternalExtraHosts } from "../src/lib/internal-extra-hosts.ts"; import { resolvePrimaryLocalProjectDir } from "../src/lib/worktree-local-config.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; @@ -255,3 +256,38 @@ test("explicit local static aliases override inherited aliases; local dynamic wi "search.test": "127.0.0.3", }); }); + +test("native alias sources distinguish project config, this checkout and the inherited main checkout", async () => { + const f = await fixture(); + await f.write(f.primary, { "inherited.example.test": "host-gateway" }); + await f.write(f.branch, { "local.example.test": "host-gateway" }); + const configFile = join(f.context.projectDir, "hack.config.json"); + await Bun.write( + configFile, + JSON.stringify({ + name: "sources-fixture", + internal: { extra_hosts: { "project.example.test": "host-gateway" } }, + }) + ); + const selection = await resolveNativeInternalExtraHostSelection({ + project: { + ...f.context, + projectDirName: ".hack", + composeFile: join(f.context.projectDir, "docker-compose.yml"), + envFile: join(f.context.projectDir, ".env"), + configFile, + }, + }); + expect(selection).toEqual({ + hosts: { + "inherited.example.test": "host-gateway", + "local.example.test": "host-gateway", + "project.example.test": "host-gateway", + }, + sources: { + "inherited.example.test": "inherited", + "local.example.test": "checkout", + "project.example.test": "project", + }, + }); +}); diff --git a/tests/native-compose-interpolation.test.ts b/tests/native-compose-interpolation.test.ts new file mode 100644 index 000000000..5bdc0c889 --- /dev/null +++ b/tests/native-compose-interpolation.test.ts @@ -0,0 +1,49 @@ +/* biome-ignore-all lint/suspicious/noTemplateCurlyInString: literal Compose interpolation fixtures. */ +import { expect, test } from "bun:test"; +import { resolveNativeComposeText } from "../src/backends/native-compose-interpolation.ts"; + +function resolved(text: string, managed: readonly string[] = []) { + return resolveNativeComposeText({ text, managed: new Set(managed) }); +} + +test("unset references select defaults and drop alternatives, as native execution does", () => { + expect(resolved("api.example.test")).toBe("api.example.test"); + expect(resolved("${UNSET_PREFIX:+x-}api.example.test")).toBe( + "api.example.test" + ); + expect(resolved("${API_HOST:-api.example.test}")).toBe("api.example.test"); + expect(resolved("${A:-${B:-${C:-${D:-deep}}}}.example.test")).toBe( + "deep.example.test" + ); + // An unselected word is validated but its references are not required. + expect(resolved("${A:+${B}}api.example.test")).toBe("api.example.test"); + expect(resolved("cost$$5")).toBe("cost$5"); + expect(resolved("${A:-a$$b}")).toBe("a$b"); +}); + +test("text native execution would refuse, or cannot know yet, is unknown", () => { + for (const text of [ + "${REQUIRED}", + "$REQUIRED.example.test", + "${A:-${REQUIRED}}", + "${A:?message}", + "${A-word}", + "${A:}", + "${1BAD:-x}", + "${A:-{literal}}", + "${A:-$B}", + "${unclosed", + "trailing$", + "${A:-${B:-${C:-${D:-${E:-x}}}}}", + // Bound to the reviewed project name only after review. + "${COMPOSE_PROJECT_NAME:-api}.example.test", + "${COMPOSE_PROJECT_NAME}", + ]) { + expect(resolved(text)).toBeNull(); + } + // A Hack-managed name is delivered privately and never interpolated. + expect(resolved("${SECRET:-api.example.test}", ["SECRET"])).toBeNull(); + expect(resolved("${A:+${SECRET}}", ["SECRET"])).toBeNull(); + // The project name in an unselected word does not matter. + expect(resolved("${A:+${COMPOSE_PROJECT_NAME}}x")).toBe("x"); +}); diff --git a/tests/native-project-internal-hosts.test.ts b/tests/native-project-internal-hosts.test.ts index d9e67ca41..68b3ffd0b 100644 --- a/tests/native-project-internal-hosts.test.ts +++ b/tests/native-project-internal-hosts.test.ts @@ -122,16 +122,59 @@ test("project-wide aliases are declared on every service like the Compose overri }); test("project-wide aliases without any dependency selection refuse instead of being ignored", () => { - expectCodedThrow( + const error = expectCodedThrow( () => applyNativeInternalHostAliases({ - input: input({ web: { image: "nginx" } }), + input: input({ web: { image: "nginx" }, worker: { image: "nginx" } }), hosts: { "api.example.test": "host-gateway" }, + sources: { "api.example.test": "inherited" }, hasDependencySelection: false, }), "E_NATIVE_PROJECT_UNSUPPORTED", NATIVE_UNBOUND_HOST_ALIAS_REFUSAL ); + expect(error.message).toContain( + "Unbound: api.example.test for service web, service worker (from the main checkout's .hack/.internal/extra-hosts.json, which this linked worktree inherits)." + ); + expect(error.message).toContain( + 'Add a binding with "aliases": ["api.example.test"] to the HACK_NATIVE_DEPENDENCIES selection' + ); + expect(error.message).toContain( + "run `hack internal extra-hosts unset api.example.test` in this worktree" + ); +}); + +test("an unbound refusal names each service, alias and where the alias comes from", () => { + const hostAliases = { + web: ["api.example.test", "auth.example.test", "own.example.test"], + worker: ["api.example.test", "local.example.test"], + }; + const error = expectCodedThrow( + () => + requireNativeHostAliasBindings({ + dependencies: [binding("web", ["auth.example.test"])], + hostAliases, + sources: { + "api.example.test": "project", + "auth.example.test": "project", + "local.example.test": "checkout", + }, + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + NATIVE_UNBOUND_HOST_ALIAS_REFUSAL + ); + expect(error.message).toContain( + "Unbound: api.example.test for service web, service worker (from internal.extra_hosts in the project configuration); local.example.test for service worker (from this checkout's .hack/.internal/extra-hosts.json); own.example.test for service web (from the service's Compose extra_hosts)." + ); + expect(error.message).toContain( + '"aliases": ["api.example.test","local.example.test","own.example.test"]' + ); + expect(error.message).toContain( + "remove api.example.test from internal.extra_hosts in the project configuration" + ); + // Listener values are never echoed. + expect(error.message).not.toContain("8443"); + expect(error.message).not.toContain("123"); }); test("project-wide aliases native cannot honor refuse value-free", () => { @@ -170,6 +213,21 @@ test("a service that maps the alias to another target conflicts", () => { ["api.example.test:10.0.0.5"], ["api.example.test=10.0.0.5"], { "api.example.test": "10.0.0.5" }, + ["${UNSET:-api.example.test}:10.0.0.5"], + ]) { + const error = expectCodedThrow( + () => + applyNativeInternalHostAliases({ + input: input({ web: { image: "nginx", extra_hosts } }), + hosts: { "api.example.test": "host-gateway" }, + hasDependencySelection: true, + }), + "E_CONFIG_INVALID", + "Native host alias selection conflicts with declared configuration: service web maps project-wide host alias api.example.test to another target" + ); + expect(error.message).not.toContain("10.0.0.5"); + } + for (const extra_hosts of [ "api.example.test:host-gateway", Array.from({ length: 32 }, (_, i) => `h${i}.example.test:host-gateway`), ]) { @@ -224,10 +282,7 @@ test("service aliases are canonicalized like native review before matching bindi services: { web: { extra_hosts: ["API.Example.:host-gateway"] }, map: { extra_hosts: { "Search.EXAMPLE.test.": "host-gateway" } }, - both: { - extra_hosts: ["api.example:host-gateway", "API.EXAMPLE.=host-gateway"], - }, - // Resolved by the runtime later; admission checks these exactly. + // A required reference refuses at execution; admission checks it there. later: { extra_hosts: ["${ALIAS}:host-gateway", "1.2.3.4:host-gateway"] }, }, profiles: [], @@ -235,14 +290,12 @@ test("service aliases are canonicalized like native review before matching bindi expect(hostAliases).toEqual({ web: ["api.example"], map: ["search.example.test"], - both: ["api.example"], later: [], }); requireNativeHostAliasBindings({ dependencies: [ binding("web", ["api.example"]), binding("map", ["search.example.test"]), - binding("both", ["api.example"]), ], hostAliases, }); @@ -258,6 +311,119 @@ test("service aliases are canonicalized like native review before matching bindi ); }); +test("an interpolated service alias is compiled like native execution before matching", () => { + // Native execution supplies no ambient environment: `${UNSET:+x-}` selects + // nothing and `${UNSET:-word}` selects its word. + const hostAliases = nativeServiceHostAliases({ + services: { + plus: { + extra_hosts: ["${UNSET_PREFIX:+x-}api.example.test:host-gateway"], + }, + dash: { + extra_hosts: { "${API_HOST:-API.example.test.}": "host-gateway" }, + }, + target: { + extra_hosts: ["api.example.test:${GATEWAY:-host-gateway}"], + }, + // Managed values are delivered privately and never interpolated. + managed: { extra_hosts: ["${SECRET:-api.example.test}:host-gateway"] }, + // The project name is bound only after review. + named: { + extra_hosts: ["${COMPOSE_PROJECT_NAME:-api}.example.test:host-gateway"], + }, + }, + profiles: [], + managedEnvironment: { managed: { SECRET: "synthetic" } }, + }); + expect(hostAliases).toEqual({ + plus: ["api.example.test"], + dash: ["api.example.test"], + target: ["api.example.test"], + managed: [], + named: [], + }); +}); + +test("one alias declared twice after interpolation refuses before runtime work, naming it", () => { + for (const extra_hosts of [ + ["api.example:host-gateway", "API.EXAMPLE.=host-gateway"], + [ + "api.example.test:host-gateway", + "${UNSET_PREFIX:+x-}api.example.test:host-gateway", + ], + { + "api.example.test": "host-gateway", + "${A:-api.example.test}": "host-gateway", + }, + ]) { + const alias = + Array.isArray(extra_hosts) && extra_hosts[0]?.startsWith("api.example:") + ? "api.example" + : "api.example.test"; + const error = expectCodedThrow( + () => + nativeServiceHostAliases({ + services: { web: { extra_hosts } }, + profiles: [], + }), + "E_CONFIG_INVALID", + `Native host alias selection conflicts with declared configuration: service web declares host alias ${alias} more than once` + ); + expect(error.message).not.toContain("host-gateway"); + } +}); + +test("HACK-1938: an interpolated alias equal to a project-wide alias is not added twice", () => { + const hosts = { "api.example.test": "host-gateway" }; + for (const declared of [ + "${UNSET_PREFIX:+x-}api.example.test:host-gateway", + "${API_HOST:-api.example.test}:host-gateway", + // Literal control: the same name without interpolation. + "api.example.test:host-gateway", + ]) { + const result = applyNativeInternalHostAliases({ + input: input({ app: { image: "alpine", extra_hosts: [declared] } }), + hosts, + hasDependencySelection: true, + }); + const app = services(result).app; + expect(app.extra_hosts).toEqual([declared]); + // What native execution then compiles is exactly one alias. + expect( + nativeServiceHostAliases({ services: { app }, profiles: [] }) + ).toEqual({ app: ["api.example.test"] }); + } + // The map form is compared the same way. + const map = applyNativeInternalHostAliases({ + input: input({ + app: { + image: "alpine", + extra_hosts: { "${UNSET_PREFIX:+x-}api.example.test": "host-gateway" }, + }, + }), + hosts, + hasDependencySelection: true, + }); + expect(services(map).app.extra_hosts).toEqual({ + "${UNSET_PREFIX:+x-}api.example.test": "host-gateway", + }); + // A name that interpolates to something else still receives the alias. + const other = applyNativeInternalHostAliases({ + input: input({ + app: { + image: "alpine", + extra_hosts: ["${UNSET_PREFIX:-x-}api.example.test:host-gateway"], + }, + }), + hosts, + hasDependencySelection: true, + }); + expect(services(other).app.extra_hosts).toEqual([ + "${UNSET_PREFIX:-x-}api.example.test:host-gateway", + "api.example.test:host-gateway", + ]); +}); + test("a project-wide alias another spelling already declares is not duplicated", () => { const result = applyNativeInternalHostAliases({ input: input({ @@ -287,7 +453,7 @@ test("a project-wide alias another spelling already declares is not duplicated", hasDependencySelection: true, }), "E_CONFIG_INVALID", - "Native host alias selection conflicts with declared configuration; values omitted." + "Native host alias selection conflicts with declared configuration: service web maps project-wide host alias api.example.test to another target" ); }); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 2b12778a7..7ddc106dc 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -37,13 +37,18 @@ import { NATIVE_CONFIGURATION_REFUSAL, NATIVE_SHARED_SOURCE_REFUSAL, nativeReadinessArgs, + nativeReadySummary, + nativeStoppedNotice, parseNativeHttpsSelection, prepareNativeProjectServices, reviewedNativeHttpsRoutes, startNativeProject, verifyHttpsRoutes, } from "../src/backends/native-project-start.ts"; -import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { + NATIVE_GRAPH_UNRESERVED, + NativeRuntimeRequestError, +} from "../src/backends/native-runtime-client.ts"; import { ClosedCliError, HackCliError } from "../src/lib/cli-result.ts"; import { type ComposeEnvFingerprintRecord, @@ -4289,13 +4294,18 @@ test("a project-wide alias without a binding for every active service refuses be ...opts, dependencyFile: path, internalExtraHosts, + internalExtraHostSources: { "api.example.test": "inherited" }, }).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(HackCliError); expect(error).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); expect(String((error as Error).message)).toContain( "Native host alias has no reviewed host dependency binding" ); - expect(String((error as Error).message)).not.toContain("example"); + // HACK-1939: the refusal names the service, the alias and its source. + expect(String((error as Error).message)).toContain( + "Unbound: api.example.test for service worker (from the main checkout's .hack/.internal/extra-hosts.json, which this linked worktree inherits)." + ); + expect(String((error as Error).message)).not.toContain("8443"); expect(events).toContain("before"); expect(events).toContain("cleanup"); expect(events).not.toContain("runtime up"); @@ -4318,3 +4328,136 @@ test("project-wide aliases without a dependency selection refuse before hooks in // Without project-wide aliases the same project still starts unchanged. expect(await startNativeProject({ ...opts, internalExtraHosts: {} })).toBe(0); }); + +test("HACK-1939: a startup that failed before reserving any runtime state does not report unconfirmed cleanup", async () => { + const { opts, events } = await fixture(); + const invoke = opts.dependencies.invoke!; + opts.dependencies.serve = async () => { + throw new Error( + "Native graph startup was interrupted or failed (invalid_extra_hosts); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "inspect") { + events.push("graph inspect"); + throw new NativeRuntimeRequestError({ + message: "Native graph run is not reserved.", + nativeCode: NATIVE_GRAPH_UNRESERVED, + }); + } + return await invoke(request); + }; + const error = await startNativeProject(opts).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(ClosedCliError); + expect((error as Error).message).toBe( + "Native graph startup failed (invalid_extra_hosts) before any runtime state was reserved; nothing was created and no cleanup is needed." + ); + expect(events).toContain("graph inspect"); + expect(events).not.toContain("save"); + expect(events.at(-1)).toBe("cleanup"); +}); + +test("an unreserved run after a startup that published a mapping still reports unconfirmed cleanup", async () => { + const { opts } = await fixture(); + const invoke = opts.dependencies.invoke!; + const serve = opts.dependencies.serve!; + let served = false; + opts.dependencies.serve = async (request) => { + await serve({ + ...request, + onReady: async () => { + await request.onReady(); + served = true; + }, + }); + throw new Error( + "Native graph startup was interrupted or failed (runtime_not_running); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => { + if (served && request.args[1] === "inspect") { + throw new NativeRuntimeRequestError({ + message: "Native graph run is not reserved.", + nativeCode: NATIVE_GRAPH_UNRESERVED, + }); + } + return await invoke(request); + }; + await expect(startNativeProject(opts)).rejects.toThrow( + "cleanup is unconfirmed" + ); +}); + +test("HACK-1939: foreground up prints a ready summary and a confirmed-stop line", async () => { + const { opts } = await fixture(); + const lines: string[] = []; + opts.dependencies.notice = (line) => { + lines.push(line); + }; + const controller = new AbortController(); + expect( + await startNativeProject({ + ...opts, + signal: controller.signal, + onReady: async () => { + controller.abort(); + }, + }) + ).toBe(130); + expect(lines[0]).toMatch( + /^Native project ready \(run [a-f0-9]{32}\)\. Press Ctrl-C to stop; use hack ps and hack logs from another terminal\.$/ + ); + expect(lines.at(-1)).toBe( + "Native project stopped; owned cleanup confirmed. Named volumes and the stopped run mapping are kept for the next hack up." + ); +}); + +test("the ready summary lists reviewed HTTPS routes and published loopback ports only", () => { + const plan = { + services: { + web: { + active: true, + routing: { hostnames: ["app.example.test", "www.example.test"] }, + ports: [ + { protocol: "tcp", proposed_host_ip: "127.0.0.1", published: 5173 }, + ], + }, + api: { active: true, routing: { hostnames: ["api.example.test"] } }, + odd: { active: true, routing: { hostnames: ["Not A Host"] } }, + db: { + active: true, + ports: [ + { protocol: "tcp", proposed_host_ip: "127.0.0.1", published: 5432 }, + { protocol: "tcp", proposed_host_ip: "0.0.0.0", published: 6000 }, + ], + }, + }, + }; + const run = "a".repeat(32); + const routed = new Set(["web", "api", "odd"]); + expect(nativeReadySummary({ run, plan, routed, httpsPort: 18_443 })).toEqual([ + `Native project ready (run ${run}). Press Ctrl-C to stop; use hack ps and hack logs from another terminal.`, + " api: https://api.example.test:18443", + " web: https://app.example.test:18443 https://www.example.test:18443", + " Published ports: 127.0.0.1:5173 127.0.0.1:5432", + ]); + expect(nativeReadySummary({ run, plan, routed, httpsPort: 443 })[1]).toBe( + " api: https://api.example.test" + ); + expect(nativeReadySummary({ run, plan, routed, httpsPort: null })).toEqual([ + `Native project ready (run ${run}). Press Ctrl-C to stop; use hack ps and hack logs from another terminal.`, + " Routes are not served on this host: the native HTTPS frontend is not selected (HACK_NATIVE_CADDY_BINARY, HACK_NATIVE_CADDY_SHA256 and HACK_NATIVE_HTTPS_PORT).", + " Published ports: 127.0.0.1:5173 127.0.0.1:5432", + ]); +}); + +test("the confirmed-stop line reflects whether data was retained", () => { + expect(nativeStoppedNotice({ receipt: { phase: "removed" } })).toBe( + "Native project stopped; owned cleanup confirmed and no runtime data was retained." + ); + expect( + nativeStoppedNotice({ receipt: { phase: "stopped-data-retained" } }) + ).toContain("Named volumes and the stopped run mapping are kept"); +}); diff --git a/tests/native-startup-logs.test.ts b/tests/native-startup-logs.test.ts index 0a7ab3606..b3e25ceea 100644 --- a/tests/native-startup-logs.test.ts +++ b/tests/native-startup-logs.test.ts @@ -76,6 +76,19 @@ test("values shorter than four characters are redacted only as whole tokens", () ); }); +test("HACK-1939: short values never shred version strings, decimals or hyphenated words", () => { + expect( + redactNativeStartupLog( + "mysqld 8.0.46 ready; ratio 0.5; build 1-2; flag v-1; DEBUG=0; exit -1; mode 0.", + ["0", "1"] + ) + ).toBe( + "mysqld 8.0.46 ready; ratio 0.5; build 1-2; flag v-1; DEBUG=[REDACTED]; exit -[REDACTED]; mode [REDACTED]." + ); + // Long values are still redacted wherever they occur. + expect(redactNativeStartupLog("v8.0.46-x", ["8.0.46"])).toBe("v[REDACTED]-x"); +}); + test("remaining control characters cannot reach the terminal", () => { expect(redactNativeStartupLog("bell\u0007 tab\tback\b", [])).toBe( "bell� tab\tback�"