Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <alias>` 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
Expand Down
199 changes: 199 additions & 0 deletions src/backends/native-compose-interpolation.ts
Original file line number Diff line number Diff line change
@@ -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>;
}): 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<string> = 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<string>,
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>): 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>
): 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>
): 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);
}
}
Loading
Loading