diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index ddfcc1e0c..7d29dac14 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -14,7 +14,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). +- Content revision: `f4120f477794` (version alone is not a freshness guarantee). ## Product boundary @@ -160,7 +160,7 @@ Use `hack` as the primary interface for local-first development. - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Project targeting diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index b339999a8..d80ad06fa 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -9,7 +9,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). +- Content revision: `f4120f477794` (version alone is not a freshness guarantee). ## Product boundary diff --git a/docs/cli.md b/docs/cli.md index f085919a0..8bf7da634 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -390,9 +390,11 @@ deliberately marks that exact Compose volume disposable. Hack deliberately does ### Runtime host metadata -Containers started by `hack up`, `hack restart`, and `hack run` receive the effective instance and -public-route metadata. This keeps server-generated links, OAuth callbacks, webhooks, and other -browser-facing URLs isolated when the same project runs in multiple worktrees. +Containers started by `hack up`, `hack restart`, and `hack run` for a `.hack/docker-compose.yml` +project receive the effective instance and public-route metadata. This applies to the default +Compose backend and to the native backend (`HACK_RUNTIME_BACKEND=native`). It keeps +server-generated links, OAuth callbacks, webhooks, and other browser-facing URLs isolated when the +same project runs in multiple worktrees. Both backends emit the same keys and document shape. | Variable | Value | | --- | --- | @@ -434,11 +436,31 @@ over generated metadata for backward compatibility. Existing containers receive their next `hack up` or `hack restart`; `hack exec` observes the values already stored in the running container. -Hack materializes this contract in generated, machine-local Compose overrides. Do not edit or -commit `.hack/.internal/compose.runtime.override.yml` or +With the Compose backend, Hack materializes this contract in generated, machine-local Compose +overrides. Do not edit or commit `.hack/.internal/compose.runtime.override.yml` or `.hack/.branch/compose..runtime.override.yml`; runtime commands refresh them and Hack's managed `.hack/.gitignore` covers them. +With the native backend, foreground `hack up` and `hack restart` add the same values to the +normalized Compose input that native review checks. They are public container environment, not +private managed values, so `hack exec` and native `hack run` see them too. The service URLs come +from the reviewed route labels, and startup refuses before serving if they differ from the reviewed +route plan. When a native HTTPS frontend is selected with a port other than 443 +(`HACK_NATIVE_HTTPS_PORT`), `HACK_DEV_URL`, `HACK_ALIAS_URL` and every service URL include that +port; host variables never do. Native-specific limits: + +- A graph whose data was retained by an earlier release is restored exactly as it was admitted and + does not gain these variables. It receives them after it is next started without retained data. +- These values are part of the retained graph's reviewed input. A retaining restart that changes + only route hostnames (for example a domain migration) remains supported, because the generated + metadata keys are excluded from the hostname-change fingerprint. Values you declare for these + keys in `.hack/docker-compose.yml` still count. Changing the native HTTPS port, or selecting or + removing the native HTTPS frontend when its port is not 443, changes the metadata without a + hostname change, so the retained graph refuses to start. Start it with its original HTTPS + frontend selection and port. +- Authored `.hack/hack.project.json` configuration does not deliver these variables yet, on either + backend. + Linked worktrees also inherit the project secret key automatically from the primary checkout through the shared git common dir, so you don't need to copy `.hack.secret.key` by hand. Set `HACK_ENV_SECRET_KEY` for CI or fully detached environments. `hack doctor` flags divergent secret diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 9e0b727e7..daae39b9d 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -470,6 +470,17 @@ New normalized shared-source graphs also capture a separate hostname-change contract. A retaining restart can change only the literal hostnames of already routed services: the service set, upstream ports, TLS settings, probes, commands, environment declarations, images, mounts, networks and volumes must stay the same. +The one exception is the reserved +[runtime host metadata](../cli.md#runtime-host-metadata) keys (`HACK_BRANCH`, +`HACK_COMPOSE_PROJECT`, `HACK_DEV_*`, `HACK_ALIAS_*`, `HACK_SERVICE_*` and +`HACK_RUNTIME_METADATA`) that Hack generates into the normalized Compose: their +public values are derived from the hostnames, so the normalized fingerprint ignores +them. A value you declare for one of these keys in `.hack/docker-compose.yml` is an +ordinary literal, so changing it still refuses. A metadata change without a hostname +change also still refuses. This includes changing `HACK_NATIVE_HTTPS_PORT`, and also +selecting or removing the native HTTPS frontend when its port is not 443, because the +advertised URLs gain or lose that port. Start the retained graph with the HTTPS +frontend selection and port it was admitted with. Both original and normalized Compose declarations are checked; only fingerprints are retained. Changed dependency-cache inputs still refuse. The old ownership plan and named data identities remain fixed, while normalized input provenance and diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index 8a8ea6e32..6f94c3479 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). +- Content revision: `f4120f477794` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -136,7 +136,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Project targeting: - From repo root, commands use that project automatically. diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index 8a8ea6e32..6f94c3479 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). +- Content revision: `f4120f477794` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -136,7 +136,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Project targeting: - From repo root, commands use that project automatically. diff --git a/packages/runtime-core/src/project/hostname_change.rs b/packages/runtime-core/src/project/hostname_change.rs index d5d5e8675..fdefac2b8 100644 --- a/packages/runtime-core/src/project/hostname_change.rs +++ b/packages/runtime-core/src/project/hostname_change.rs @@ -4,11 +4,46 @@ use super::{compose, yaml}; use serde_json::Value; use sha2::{Digest, Sha256}; -fn masked(bytes: &[u8]) -> Option { +/// Hack-owned public runtime host metadata keys (the CLI's runtime host metadata +/// contract). Hack generates them only into the normalized document, with values derived +/// from the effective route hostnames, branch and HTTPS ingress port, so a hostname +/// change necessarily changes them. They are excluded from the normalized document's +/// fingerprint only: a value the user declares in the original Compose file is a literal +/// like any other and still counts (Hack never overrides a declared key). +const RUNTIME_METADATA_KEYS: [&str; 10] = [ + "HACK_ALIAS_HOST", + "HACK_ALIAS_URL", + "HACK_BRANCH", + "HACK_COMPOSE_PROJECT", + "HACK_DEV_HOST", + "HACK_DEV_URL", + "HACK_RUNTIME_METADATA", + "HACK_SERVICE_NAME", + "HACK_SERVICE_URL", + "HACK_SERVICE_URLS", +]; + +fn without_runtime_metadata(service: &mut Value) { + let reserved = |key: &str| RUNTIME_METADATA_KEYS.contains(&key); + match service.get_mut("environment") { + Some(Value::Object(environment)) => environment.retain(|key, _| !reserved(key)), + Some(Value::Array(environment)) => environment.retain(|entry| { + !entry + .as_str() + .is_some_and(|text| reserved(text.split_once('=').map_or(text, |(key, _)| key))) + }), + _ => {} + } +} + +fn masked(bytes: &[u8], generated_metadata: bool) -> Option { let mut value = yaml::parse(bytes).ok()?; let services = value.get_mut("services")?.as_object_mut()?; let mut routed = false; for service in services.values_mut() { + if generated_metadata { + without_runtime_metadata(service); + } let Some(labels) = service.get_mut("labels") else { continue; }; @@ -37,8 +72,8 @@ fn masked(bytes: &[u8]) -> Option { /// Unsupported original routing simply cannot opt into future hostname changes. /// Both documents matter: normalization must not conceal unrelated original edits. pub(super) fn fingerprint(original: &[u8], normalized: &[u8]) -> Option { - let original = masked(original)?; - let normalized = masked(normalized)?; + let original = masked(original, false)?; + let normalized = masked(normalized, true)?; let bytes = zeroize::Zeroizing::new( serde_json::to_vec(&("hack-hostname-raw-v1", original, normalized)).ok()?, ); @@ -91,4 +126,107 @@ mod tests { assert_eq!(fingerprint(before, before), fingerprint(after, after)); assert!(fingerprint(b"services: {web: {image: alpine}}", after).is_none()); } + + #[test] + fn runtime_metadata_follows_hostnames_but_other_environment_still_counts() { + let expected = fingerprint(BASE.as_bytes(), BASE.as_bytes()).unwrap(); + let metadata = |host: &str, port: &str| { + BASE.replace("caddy: web.hack", &format!("caddy: {host}")) + .replace( + "environment: {TOKEN: literal}", + &format!( + "environment: {{TOKEN: literal, HACK_DEV_URL: 'https://{host}{port}', HACK_SERVICE_URLS: '[\"https://{host}{port}\"]', HACK_RUNTIME_METADATA: '{{\"version\":1}}', HACK_BRANCH: ''}}" + ), + ) + }; + for normalized in [ + metadata("web.hack", ""), + metadata("web.v5.hack.gy", ""), + metadata("web.hack", ":8443"), + ] { + assert_eq!( + fingerprint(BASE.as_bytes(), normalized.as_bytes()), + Some(expected.clone()) + ); + } + let list = |url: &str| { + format!( + "services: {{web: {{environment: ['TOKEN=literal', 'HACK_DEV_URL={url}', HACK_SERVICE_NAME], labels: ['caddy=a.hack', 'caddy.reverse_proxy={{{{upstreams 80}}}}', 'caddy.tls=internal']}}}}" + ) + }; + let plain = list("https://a.hack"); + assert_eq!( + fingerprint(plain.as_bytes(), plain.as_bytes()), + fingerprint( + plain.as_bytes(), + list("https://b.hack:8443") + .replace("caddy=a.hack", "caddy=b.hack") + .as_bytes() + ) + ); + for changed in [ + metadata("web.hack", "").replace("TOKEN: literal", "TOKEN: different"), + metadata("web.hack", "").replace("HACK_BRANCH", "HACK_OTHER"), + plain.replace("TOKEN=literal", "TOKEN=different"), + ] { + assert_ne!( + fingerprint(BASE.as_bytes(), changed.as_bytes()), + Some(expected.clone()) + ); + assert_ne!( + fingerprint(plain.as_bytes(), changed.as_bytes()), + fingerprint(plain.as_bytes(), plain.as_bytes()) + ); + } + } + + #[test] + fn user_declared_metadata_keys_still_count_in_the_original() { + let declared = |host: &str, url: &str| { + BASE.replace("caddy: web.hack", &format!("caddy: {host}")) + .replace( + "environment: {TOKEN: literal}", + &format!("environment: {{TOKEN: literal, HACK_DEV_URL: '{url}'}}"), + ) + }; + // Hack skips a declared key, so the normalized document carries the user's value. + let before = declared("web.hack", "https://custom.example"); + let expected = fingerprint(before.as_bytes(), before.as_bytes()).unwrap(); + let moved = declared("web.v5.hack.gy", "https://custom.example"); + assert_eq!( + fingerprint(moved.as_bytes(), moved.as_bytes()), + Some(expected.clone()) + ); + let edited = declared("web.v5.hack.gy", "https://other.example"); + assert_ne!( + fingerprint(edited.as_bytes(), edited.as_bytes()), + Some(expected) + ); + let list = |url: &str| { + format!( + "services: {{web: {{environment: ['HACK_DEV_URL={url}'], labels: ['caddy=a.hack', 'caddy.reverse_proxy={{{{upstreams 80}}}}', 'caddy.tls=internal']}}}}" + ) + }; + let plain = list("https://custom.example"); + let edited = list("https://other.example"); + assert_ne!( + fingerprint(plain.as_bytes(), plain.as_bytes()), + fingerprint(edited.as_bytes(), edited.as_bytes()) + ); + // Generated metadata differences in the normalized document still do not count. + let base = fingerprint(BASE.as_bytes(), BASE.as_bytes()).unwrap(); + for (host, port) in [("web.hack", ""), ("web.v5.hack.gy", ":8443")] { + let original = BASE.replace("caddy: web.hack", &format!("caddy: {host}")); + let generated = original.replace( + "environment: {TOKEN: literal}", + &format!( + "environment: {{TOKEN: literal, HACK_DEV_URL: 'https://{host}{port}', HACK_DEV_HOST: {host}}}" + ), + ); + assert_eq!( + fingerprint(original.as_bytes(), generated.as_bytes()), + Some(base.clone()) + ); + } + } } diff --git a/plugins/hack/rules/hack.mdc b/plugins/hack/rules/hack.mdc index 43aed6e83..168845512 100644 --- a/plugins/hack/rules/hack.mdc +++ b/plugins/hack/rules/hack.mdc @@ -14,7 +14,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). +- Content revision: `f4120f477794` (version alone is not a freshness guarantee). ## Product boundary diff --git a/plugins/hack/skills/hack-branches/SKILL.md b/plugins/hack/skills/hack-branches/SKILL.md index 4bcddcf2a..c508938b6 100644 --- a/plugins/hack/skills/hack-branches/SKILL.md +++ b/plugins/hack/skills/hack-branches/SKILL.md @@ -14,7 +14,7 @@ description: > - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Branch aliases @@ -35,4 +35,4 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). +- Content revision: `f4120f477794` (version alone is not a freshness guarantee). diff --git a/plugins/hack/skills/hack-cli/SKILL.md b/plugins/hack/skills/hack-cli/SKILL.md index ccb5b8326..069562770 100644 --- a/plugins/hack/skills/hack-cli/SKILL.md +++ b/plugins/hack/skills/hack-cli/SKILL.md @@ -16,7 +16,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). +- Content revision: `f4120f477794` (version alone is not a freshness guarantee). ## Product boundary @@ -162,7 +162,7 @@ Use `hack` as the primary interface for local-first development. - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Project targeting diff --git a/plugins/hack/skills/hack-diagnose/SKILL.md b/plugins/hack/skills/hack-diagnose/SKILL.md index 1ebb43bf2..5eb98da54 100644 --- a/plugins/hack/skills/hack-diagnose/SKILL.md +++ b/plugins/hack/skills/hack-diagnose/SKILL.md @@ -37,7 +37,7 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). +- Content revision: `f4120f477794` (version alone is not a freshness guarantee). ## References diff --git a/plugins/hack/skills/hack-env/SKILL.md b/plugins/hack/skills/hack-env/SKILL.md index 4eb918b6d..d804b55e1 100644 --- a/plugins/hack/skills/hack-env/SKILL.md +++ b/plugins/hack/skills/hack-env/SKILL.md @@ -34,4 +34,4 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). +- Content revision: `f4120f477794` (version alone is not a freshness guarantee). diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 82b4f7a1f..7ef0b7ff5 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -293,7 +293,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch.", "Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`).", "Linked worktrees pick a branch instance automatically (see Linked git worktrees).", - "Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.", + "Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.", ], }, { diff --git a/src/agents/integration-ownership.ts b/src/agents/integration-ownership.ts index 54f80806f..fa8db2b83 100644 --- a/src/agents/integration-ownership.ts +++ b/src/agents/integration-ownership.ts @@ -107,6 +107,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "6449442a8f230412d4e5f9e7", // v5.0.0-next.4..next "c60f1fc7b1f6579fac892a54", // next: plugin-first agent setup (hack-mcp split) "d83da046cf7541ba30950880", // v5.0.0-next.4..next (offline env-matrix pointer) + "0406c99877ff104fd57b4a14", // v5.0.0-next.4..next (runtime metadata backend scope) "08f0db2d0a1889e5b3ef707f", // v5.0.0-next.4..next (refresh-only setup sync) "257e3019713e8358981fd1af", // next: plugin-first agent setup + offline env-matrix pointer "e620e980660f0443df7732fe", // next: focused plugin skills (HACK-1131) @@ -114,6 +115,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "680a52ef6737d979ed2d384b", // next: focused plugin skills + --value-stdin "eba974f66328605864507beb", // next: focused plugin skills + plugin-first agent setup "cf91ca873a00ad4743198e11", // next: plugin-first agent setup + refresh-only setup sync + "0e99791aa0edfd1895e31748", // next: runtime metadata backend scope + refresh-only setup sync ]), "hack-init-skill": new Set([ "cd15fb6d8ed8e25071d05e75", // v3.1.0..v4.0.3 @@ -143,6 +145,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "cf0a31e197f9034485413674", // v5.0.0-next.4..next "77f4258cf2570443aee021fd", // next: plugin-first agent setup (hack-mcp split) "3a99c9097855f93808e482cb", // v5.0.0-next.4..next (offline env-matrix pointer) + "e449b8956a07989244762940", // v5.0.0-next.4..next (runtime metadata backend scope) "19099625646d196aa0e8a0c0", // v5.0.0-next.4..next (refresh-only setup sync) "d70a44aea4651314956b48f2", // next: plugin-first agent setup + offline env-matrix pointer "ea46c0d85989de79dba95fd4", // next: focused plugin skills (HACK-1131) @@ -150,6 +153,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "afa91b84e719bd416b9dfea3", // next: focused plugin skills + --value-stdin "270cd2b3fdcc325e04c76d43", // next: focused plugin skills + plugin-first agent setup "92ca4aa8f28ba1c93093c04a", // next: plugin-first agent setup + refresh-only setup sync + "0b82281933af514de8a1b8b8", // next: runtime metadata backend scope + refresh-only setup sync ]), }; diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 6915c78d7..5dfd1fdfe 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "5108f34edd26"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "f4120f477794"; diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index 7867dc4a3..cbb5a74d5 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -36,11 +36,19 @@ import { normalizeNativeProfiles, } from "./native-project-run.ts"; import { readNativeSelection } from "./native-project-selection.ts"; -import { prepareNativeProjectServices } from "./native-project-start.ts"; +import { + nativeRuntimeMetadataSelected, + prepareNativeProjectServices, +} from "./native-project-start.ts"; import { invokeNativeRuntime, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; +import { + applyNativeRuntimeMetadata, + type NativeRuntimeMetadataSelection, + verifyNativeRuntimeMetadataRoutes, +} from "./native-runtime-metadata.ts"; const DEFAULTS = { prepare: prepareNativeProjectInput, @@ -312,10 +320,17 @@ export async function preflightNativeRestart(opts: { readonly adaptationFile?: string; readonly dependencyFile?: string; readonly allowedHosts?: readonly string[]; + /** Same public metadata selection the replacement startup will review. */ + readonly runtimeMetadata?: NativeRuntimeMetadataSelection; + readonly httpsPort?: number | null; readonly dependencies?: Partial; }) { const deps = { ...DEFAULTS, ...opts.dependencies }; const selected = nativeRestartSelection({ run: opts.run }); + const runtimeMetadata = nativeRuntimeMetadataSelected({ + selection: opts.runtimeMetadata, + restore: opts.run, + }); const stoppedRetry = await stoppedRestartMode({ ...opts, invoke: deps.invoke, @@ -363,6 +378,15 @@ export async function preflightNativeRestart(opts: { if (selected.aws) { input = (await deps.aws({ input, ...selected.aws })).input; } + const metadata = runtimeMetadata + ? applyNativeRuntimeMetadata({ + input, + selection: runtimeMetadata, + branch: reviewIdentity ? reviewIdentity.branch : opts.scope.branch, + httpsPort: opts.httpsPort ?? null, + }) + : undefined; + input = metadata?.input ?? input; const specs = prepareNativeProjectServices( input, opts.dependencyFile !== undefined @@ -429,6 +453,12 @@ export async function preflightNativeRestart(opts: { "Native restart configuration changed; the current graph was not stopped." ); } + if (metadata) { + verifyNativeRuntimeMetadataRoutes({ + plan: review.report.plan, + routes: metadata.routes, + }); + } if (nativeReviewNeedsCompatibility({ saved: opts.run, review })) { await verifyNativeSourceCompatibility({ runtime: opts.runtime, diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index c2825cbe7..d79af2b4d 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -42,6 +42,11 @@ export type NativeProjectRun = { readonly profiles?: readonly string[]; /** Public startup selector only; null means no AWS adaptation. Never credentials. */ readonly aws?: { readonly profile: string; readonly region?: string } | null; + /** + * Present when startup reviewed public runtime host metadata into this graph. + * Retained restores must add it again; absent legacy graphs never gain it. + */ + readonly runtimeMetadata?: true; }; export type NativeProjectRunScope = { readonly projectRoot: string; @@ -109,7 +114,10 @@ function valid(value: unknown): value is NativeProjectRun { "effectiveEnvName,namespace,owner,planId,run", "aws,effectiveEnvName,namespace,owner,planId,run", "aws,effectiveEnvName,namespace,owner,planId,profiles,run", + "aws,effectiveEnvName,namespace,owner,planId,profiles,run,runtimeMetadata", ].includes(Object.keys(value).sort().join()) && + (!Object.hasOwn(value, "runtimeMetadata") || + value.runtimeMetadata === true) && (!Object.hasOwn(value, "aws") || validAws(value.aws)) && (!Object.hasOwn(value, "profiles") || validProfiles(value.profiles)) && (!Object.hasOwn(value, "effectiveEnvName") || diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index c1413120a..fa346813a 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -74,6 +74,11 @@ import { invokeNativeRuntime, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; +import { + applyNativeRuntimeMetadata, + type NativeRuntimeMetadataSelection, + verifyNativeRuntimeMetadataRoutes, +} from "./native-runtime-metadata.ts"; const SHA = /^[a-f0-9]{64}$/; const OWNER = /^[a-f0-9]{32}$/; @@ -733,6 +738,7 @@ function selectedMapping(opts: { readonly effectiveEnvName: string | null; readonly profiles: readonly string[]; readonly aws?: { readonly profile: string; readonly region?: string }; + readonly runtimeMetadata: boolean; }): NativeProjectRun { return { ...opts.ready, @@ -744,8 +750,28 @@ function selectedMapping(opts: { ...(opts.aws.region ? { region: opts.aws.region } : {}), } : null, + ...(opts.runtimeMetadata ? { runtimeMetadata: true as const } : {}), }; } +/** + * Fresh graphs receive runtime host metadata when the caller selects it. A + * retained graph is restored exactly as admitted: only graphs that recorded + * metadata get it again, so legacy retained data stays restorable. + */ +export function nativeRuntimeMetadataSelected(opts: { + readonly selection?: NativeRuntimeMetadataSelection; + readonly restore?: NativeProjectRun; +}): NativeRuntimeMetadataSelection | undefined { + if (opts.restore && opts.restore.runtimeMetadata !== true) { + return undefined; + } + if (opts.restore && !opts.selection) { + throw new Error( + "Native retained graph requires its runtime metadata selection; no hooks, runtime start or HTTPS owner were requested." + ); + } + return opts.selection; +} async function retireRemovedMapping(opts: { readonly final: unknown; readonly mapping: NativeProjectRun | undefined; @@ -826,6 +852,8 @@ export async function startNativeProject(opts: { readonly preparedBase?: NativePreparedBase; readonly https?: NativeHttpsSelection; readonly aws?: { readonly profile: string; readonly region?: string }; + /** Public instance identity delivered as runtime host metadata; omitted delivers none. */ + readonly runtimeMetadata?: NativeRuntimeMetadataSelection; readonly before: (input: NativeProjectInput) => Promise; readonly signal?: AbortSignal; readonly dependencies?: Partial; @@ -855,6 +883,10 @@ export async function startNativeProject(opts: { signal: opts.signal, }); requireActiveStartup(opts.signal); + const runtimeMetadata = nativeRuntimeMetadataSelected({ + selection: opts.runtimeMetadata, + restore, + }); if (retained) { await requireAcknowledgedRetainedFinalization({ scope: opts.scope, @@ -1033,6 +1065,15 @@ export async function startNativeProject(opts: { }); input = reviewedBranch.input; const reviewIdentity = reviewedBranch.identity; + const metadata = runtimeMetadata + ? applyNativeRuntimeMetadata({ + input, + selection: runtimeMetadata, + branch: reviewIdentity ? reviewIdentity.branch : opts.scope.branch, + httpsPort: opts.https?.httpsPort ?? null, + }) + : undefined; + input = metadata?.input ?? input; requireActiveStartup(controller.signal); specs = prepareNativeProjectServices( input, @@ -1072,6 +1113,12 @@ export async function startNativeProject(opts: { input: pinned, run: async (review) => { requireEnrollmentCompatible(review.report.plan); + if (metadata) { + verifyNativeRuntimeMetadataRoutes({ + plan: review.report.plan, + routes: metadata.routes, + }); + } const routes = reviewedNativeRoutes({ plan: review.report.plan, specs, @@ -1194,6 +1241,7 @@ export async function startNativeProject(opts: { effectiveEnvName: input.effectiveEnvName, profiles, aws: selection.aws, + runtimeMetadata: metadata !== undefined, }); // Enrollment must exist before taking a shared lease: release // requires independent proof of this exact graph's cleanup. diff --git a/src/backends/native-runtime-metadata.ts b/src/backends/native-runtime-metadata.ts new file mode 100644 index 000000000..e110da36b --- /dev/null +++ b/src/backends/native-runtime-metadata.ts @@ -0,0 +1,175 @@ +import { isRecord } from "../lib/guards.ts"; +import { + buildRuntimeHostMetadataEnvironments, + type RuntimeHostMetadataService, + readCaddyLabel, + readDeclaredEnvironmentKeys, + runtimeHostMetadataComposeProject, +} from "../lib/runtime-host-metadata.ts"; +import type { NativeProjectInput } from "./native-project-input.ts"; + +const LIMIT = 256 * 1024; + +/** Public instance identity for native runtime host metadata. Never secret values. */ +export type NativeRuntimeMetadataSelection = { + /** Configured base development host, before branch rewriting. */ + readonly devHost: string; + /** Configured base OAuth alias host, or null when the alias is disabled. */ + readonly aliasHost: string | null; + /** Base Compose project name; branch instances report `--`. */ + readonly projectName: string; +}; + +export type NativeRuntimeMetadata = { + /** Input whose normalized Compose carries the metadata as public environment. */ + readonly input: NativeProjectInput; + /** Normalized route hostnames per routed service; review must report the same set. */ + readonly routes: ReadonlyMap; +}; + +function refused(): Error { + return new Error( + "Native runtime metadata requires reviewable services whose route labels match the reviewed plan; no graph was started." + ); +} + +/** Mirror native review's hostname normalization: trailing dot removed, lowercase. */ +function routeHosts(labels: unknown): string[] { + const caddy = readCaddyLabel({ labels }); + if (caddy === null) { + return []; + } + const hosts: string[] = []; + for (const part of caddy.split(",")) { + const trimmed = part.trim(); + const host = ( + trimmed.endsWith(".") ? trimmed.slice(0, -1) : trimmed + ).toLowerCase(); + if (host && !hosts.includes(host)) { + hosts.push(host); + } + } + return hosts; +} + +/** Compose interpolation treats `$` specially; `$$` is its literal escape. */ +function literal(value: string): string { + return value.replaceAll("$", () => "$$"); +} + +/** + * Add the same runtime host metadata the Compose backend generates to the exact + * normalized Compose native review will see, as public (non-secret) environment. + * + * The values are derived from the route labels native review turns into its + * route plan, the effective branch and the native HTTPS port; callers must check + * the returned routes against the reviewed plan with + * {@link verifyNativeRuntimeMetadataRoutes} before starting anything. Keys a + * service already declares, including Hack-managed project values (bare keys), + * are left untouched, so explicit project values win. Call this after branch + * rewriting and every other normalization so the reviewed bytes are final. + */ +export function applyNativeRuntimeMetadata(opts: { + readonly input: NativeProjectInput; + readonly selection: NativeRuntimeMetadataSelection; + readonly branch: string | null; + /** Native HTTPS frontend port; null when no frontend is selected. */ + readonly httpsPort: number | null; +}): NativeRuntimeMetadata { + const compose: unknown = JSON.parse(opts.input.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw refused(); + } + const services = new Map(); + const routes = new Map(); + const records = new Map>(); + for (const [name, service] of Object.entries(compose.services)) { + if (!isRecord(service)) { + throw refused(); + } + const hosts = routeHosts(service.labels); + if (hosts.length > 0) { + routes.set(name, hosts); + } + records.set(name, service); + services.set(name, { + hosts, + declaredEnvironmentKeys: readDeclaredEnvironmentKeys({ + environment: service.environment, + }), + }); + } + const environments = buildRuntimeHostMetadataEnvironments({ + services, + branch: opts.branch, + devHost: opts.selection.devHost, + aliasHost: opts.selection.aliasHost, + composeProject: runtimeHostMetadataComposeProject({ + projectName: opts.selection.projectName, + branch: opts.branch, + }), + httpsPort: opts.httpsPort, + }); + for (const [name, values] of environments) { + const service = records.get(name); + if (!service) { + throw refused(); + } + const entries = Object.entries(values).map( + ([key, value]) => [key, literal(value)] as const + ); + if (service.environment === undefined || service.environment === null) { + service.environment = Object.fromEntries(entries); + } else if (isRecord(service.environment)) { + Object.assign(service.environment, Object.fromEntries(entries)); + } else if (Array.isArray(service.environment)) { + service.environment.push( + ...entries.map(([key, value]) => `${key}=${value}`) + ); + } else { + throw refused(); + } + } + const normalizedComposeJson = JSON.stringify(compose); + if (Buffer.byteLength(normalizedComposeJson) > LIMIT) { + throw refused(); + } + return { input: { ...opts.input, normalizedComposeJson }, routes }; +} + +/** + * Refuse unless every active service's reviewed route hostnames are exactly the + * hostnames its runtime metadata advertises. This keeps the delivered URLs bound + * to the reviewed route plan rather than to a client-side label reading. + */ +export function verifyNativeRuntimeMetadataRoutes(opts: { + readonly plan: unknown; + readonly routes: ReadonlyMap; +}): void { + if (!(isRecord(opts.plan) && isRecord(opts.plan.services))) { + throw refused(); + } + for (const [name, service] of Object.entries(opts.plan.services)) { + if (!isRecord(service)) { + throw refused(); + } + if (service.active !== true) { + continue; + } + const routing = service.routing; + const reviewed: unknown[] = + isRecord(routing) && Array.isArray(routing.hostnames) + ? routing.hostnames + : []; + if (!reviewed.every((host) => typeof host === "string")) { + throw refused(); + } + const expected = [...(opts.routes.get(name) ?? [])].sort(); + if ( + JSON.stringify([...(reviewed as string[])].sort()) !== + JSON.stringify(expected) + ) { + throw refused(); + } + } +} diff --git a/src/commands/project.ts b/src/commands/project.ts index e79ac15f5..5400d9325 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -4912,6 +4912,7 @@ async function handleNativeUp({ projectName, branch, }); + const devHost = await resolveRuntimeDevHost({ project, branch }); const startup: Parameters[0] = { runtime: native, scope: { @@ -4935,6 +4936,11 @@ async function handleNativeUp({ region: process.env.HACK_NATIVE_AWS_REGION, } : undefined, + runtimeMetadata: { + devHost, + aliasHost: resolveBranchAliasHost({ devHost, cfg }), + projectName, + }, before: async (input) => { const lifecycle = await runLifecycleUpBeforeAndProcesses({ title: "Lifecycle (native up before)", @@ -5028,6 +5034,8 @@ async function handleNativeUp({ adaptationFile: startup.adaptationFile, dependencyFile: startup.dependencyFile, allowedHosts: startup.allowedHosts, + runtimeMetadata: startup.runtimeMetadata, + httpsPort: startup.https?.httpsPort ?? null, run, cleanedRetry, recoverStopped: recovery !== undefined, diff --git a/src/lib/runtime-host-metadata.ts b/src/lib/runtime-host-metadata.ts index 7badd1adc..140447c8a 100644 --- a/src/lib/runtime-host-metadata.ts +++ b/src/lib/runtime-host-metadata.ts @@ -29,6 +29,43 @@ export type RuntimeHostMetadata = { >; }; +/** One service's public route hosts and the environment keys its author already owns. */ +export type RuntimeHostMetadataService = { + /** Effective public hostnames in declaration order; empty when unrouted. */ + readonly hosts: readonly string[]; + readonly declaredEnvironmentKeys: ReadonlySet; +}; + +/** Every environment key Hack reserves for runtime host metadata. */ +export const RUNTIME_HOST_METADATA_KEY_NAMES: readonly string[] = Object.values( + RUNTIME_HOST_METADATA_KEYS +); + +/** Compose project name reported for an instance; branches use `--`. */ +export function runtimeHostMetadataComposeProject(opts: { + readonly projectName: string; + readonly branch: string | null; +}): string { + return opts.branch ? `${opts.projectName}--${opts.branch}` : opts.projectName; +} + +/** + * Browser-facing HTTPS URL for a public host. The default HTTPS port stays + * implicit; any other ingress port (such as a native HTTPS frontend) is explicit. + */ +export function runtimeHostUrl(opts: { + readonly host: string; + readonly httpsPort?: number | null; +}): string { + const port = + opts.httpsPort === undefined || + opts.httpsPort === null || + opts.httpsPort === 443 + ? "" + : `:${opts.httpsPort}`; + return `https://${opts.host}${port}`; +} + /** * Builds the generated Compose fragment that exposes non-secret runtime * identity to every service. Keys already declared by a service are omitted; @@ -48,6 +85,51 @@ export function buildRuntimeHostMetadataOverride(opts: { return null; } + const environments = buildRuntimeHostMetadataEnvironments({ + services: new Map( + [...effectiveServices].map(([serviceName, service]) => [ + serviceName, + { + hosts: service.caddy + ? extractCaddyHosts({ value: service.caddy }) + : [], + declaredEnvironmentKeys: service.declaredEnvironmentKeys, + }, + ]) + ), + branch: opts.branch, + devHost: opts.devHost, + aliasHost: opts.aliasHost, + composeProject: opts.composeProject, + }); + if (environments.size === 0) { + return null; + } + const services: Record }> = {}; + for (const [serviceName, environment] of environments) { + services[serviceName] = { environment }; + } + return ensureTrailingNewline( + cleanupYaml(YAML.stringify({ services }, null, 2)) + ); +} + +/** + * Computes the non-secret runtime identity each service receives. The Compose + * override and the native backend both use it, so both emit the same keys and + * document shape. Keys a service already declares are omitted (explicit project + * values win) and services left without keys are absent from the result. The + * iteration order of `services` defines the metadata document's service order. + */ +export function buildRuntimeHostMetadataEnvironments(opts: { + readonly services: ReadonlyMap; + readonly branch: string | null; + readonly devHost: string; + readonly aliasHost: string | null; + readonly composeProject: string; + /** Public HTTPS ingress port; omitted, null or 443 keeps URLs portless. */ + readonly httpsPort?: number | null; +}): Map> { const baseHosts = [opts.devHost, opts.aliasHost].filter( (host): host is string => typeof host === "string" && host.length > 0 ); @@ -63,12 +145,12 @@ export function buildRuntimeHostMetadataOverride(opts: { baseHosts, }) : null; + const url = (host: string) => + runtimeHostUrl({ host, httpsPort: opts.httpsPort }); const publicServices: Record = {}; - for (const [serviceName, service] of effectiveServices) { - const urls = resolveServiceUrls({ - caddy: service.caddy, - }); + for (const [serviceName, service] of opts.services) { + const urls = resolveServiceUrls({ hosts: service.hosts, url }); if (urls.length > 0) { publicServices[serviceName] = { urls }; } @@ -86,20 +168,19 @@ export function buildRuntimeHostMetadataOverride(opts: { const sharedMetadata: Record = { [RUNTIME_HOST_METADATA_KEYS.branch]: opts.branch ?? "", [RUNTIME_HOST_METADATA_KEYS.devHost]: effectiveDevHost, - [RUNTIME_HOST_METADATA_KEYS.devUrl]: `https://${effectiveDevHost}`, + [RUNTIME_HOST_METADATA_KEYS.devUrl]: url(effectiveDevHost), [RUNTIME_HOST_METADATA_KEYS.composeProject]: opts.composeProject, [RUNTIME_HOST_METADATA_KEYS.runtimeMetadata]: JSON.stringify(runtimeMetadata), ...(effectiveAliasHost ? { [RUNTIME_HOST_METADATA_KEYS.aliasHost]: effectiveAliasHost, - [RUNTIME_HOST_METADATA_KEYS.aliasUrl]: `https://${effectiveAliasHost}`, + [RUNTIME_HOST_METADATA_KEYS.aliasUrl]: url(effectiveAliasHost), } : {}), }; - const services: Record }> = {}; - - for (const [serviceName, service] of effectiveServices) { + const environments = new Map>(); + for (const [serviceName, service] of opts.services) { const urls = publicServices[serviceName]?.urls ?? []; const metadata: Record = { ...sharedMetadata, @@ -113,16 +194,10 @@ export function buildRuntimeHostMetadataOverride(opts: { ) ); if (Object.keys(environment).length > 0) { - services[serviceName] = { environment }; + environments.set(serviceName, environment); } } - - if (Object.keys(services).length === 0) { - return null; - } - return ensureTrailingNewline( - cleanupYaml(YAML.stringify({ services }, null, 2)) - ); + return environments; } function parseComposeYaml(opts: { @@ -188,16 +263,13 @@ function applyBranchIfNeeded(opts: { } function resolveServiceUrls(opts: { - readonly caddy: string | null; + readonly hosts: readonly string[]; + readonly url: (host: string) => string; }): readonly string[] { - if (!opts.caddy) { - return []; - } - const urls: string[] = []; const seen = new Set(); - for (const host of extractCaddyHosts({ value: opts.caddy })) { - const url = `https://${host}`; + for (const host of opts.hosts) { + const url = opts.url(host); if (!seen.has(url)) { seen.add(url); urls.push(url); @@ -206,7 +278,10 @@ function resolveServiceUrls(opts: { return urls; } -function readCaddyLabel(opts: { readonly labels: unknown }): string | null { +/** The public `caddy` route label value from map or list Compose labels. */ +export function readCaddyLabel(opts: { + readonly labels: unknown; +}): string | null { if (isRecord(opts.labels)) { const value = opts.labels.caddy; return typeof value === "string" && value.trim().length > 0 ? value : null; @@ -256,7 +331,8 @@ function extractCaddyHosts(opts: { return hosts; } -function readDeclaredEnvironmentKeys(opts: { +/** Environment keys a Compose service declares, from map or list form. */ +export function readDeclaredEnvironmentKeys(opts: { readonly environment: unknown; }): Set { if (isRecord(opts.environment)) { diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index e34f75ec4..4e67a7ff6 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -2353,6 +2353,60 @@ test("startup prepares mapping storage before input and plan review", async () = expect(await startNativeProject(opts)).toBe(0); }); +/** Read-only native answers for a branch restart preflight of the run 1…1. */ +async function restartPreflightInvoke({ + args, +}: { + readonly args: readonly string[]; +}): Promise { + if (args[0] === "project") { + expect(args).toContain("--branch"); + return { + plan: { namespace: "b".repeat(64), compose_sha256: "d".repeat(64) }, + }; + } + if (args[1] === "status") { + return { network: "internet" }; + } + if (args[1] === "probe") { + return { admitted: true }; + } + if (args[1] === "inspect") { + return { + journal_incomplete: false, + receipt: { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + plan_id: "a".repeat(64), + phase: "ready-observed", + resources: { + "container:web": { + kind: "container", + key: "web", + id: "f".repeat(64), + }, + }, + }, + }; + } + if (args[1] === "run-selection") { + expect(args[args.indexOf("--service") + 1]).toBe("web"); + return { + ok: true, + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + plan: "a".repeat(64), + service: "web", + container: "f".repeat(64), + boot: "owned-boot", + generation: "e".repeat(64), + }; + } + throw new Error("unexpected preflight effect"); +} + test("startup and restart review use identical branch routes after adaptation", async () => { const { opts } = await fixture(false); const prepare = opts.dependencies.prepare!; @@ -2414,54 +2468,7 @@ test("startup and restart review use identical branch routes after adaptation", prepare: opts.dependencies.prepare, review: opts.dependencies.review, dependencies: async () => [], - invoke: async ({ args }) => { - if (args[0] === "project") { - expect(args).toContain("--branch"); - return { - plan: { namespace: "b".repeat(64), compose_sha256: "d".repeat(64) }, - }; - } - if (args[1] === "status") { - return { network: "internet" }; - } - if (args[1] === "probe") { - return { admitted: true }; - } - if (args[1] === "inspect") { - return { - journal_incomplete: false, - receipt: { - run: "1".repeat(32), - owner: "c".repeat(32), - namespace: "b".repeat(64), - plan_id: "a".repeat(64), - phase: "ready-observed", - resources: { - "container:web": { - kind: "container", - key: "web", - id: "f".repeat(64), - }, - }, - }, - }; - } - if (args[1] === "run-selection") { - expect(args[args.indexOf("--service") + 1]).toBe("web"); - return { - ok: true, - run: "1".repeat(32), - owner: "c".repeat(32), - namespace: "b".repeat(64), - plan: "a".repeat(64), - service: "web", - container: "f".repeat(64), - boot: "owned-boot", - generation: "e".repeat(64), - }; - } - throw new Error("unexpected preflight effect"); - }, + invoke: restartPreflightInvoke, }, }); expect(observed).toHaveLength(2); @@ -2474,6 +2481,72 @@ test("startup and restart review use identical branch routes after adaptation", ).toBe("feature-a.app.hack.local, feature-a.app.hack.gy"); }); +test("startup and restart review identical branch runtime metadata; legacy runs get none", async () => { + const { opts } = await fixture(false); + const review = opts.dependencies.review!; + const scope = { ...opts.scope, branch: "feature-a" }; + await writeFile(opts.composeFile, "services: {}\n"); + await writeFile( + join(scope.projectDir, "hack.config.json"), + JSON.stringify({ dev_host: "app.hack.local" }) + ); + const selection = { + devHost: "app.hack.local", + aliasHost: null, + projectName: "app", + }; + const observed: string[] = []; + opts.dependencies.review = async (request) => { + observed.push(request.input.normalizedComposeJson); + return await review(request); + }; + expect( + await startNativeProject({ + ...opts, + scope, + https: httpsSelection, + runtimeMetadata: selection, + }) + ).toBe(0); + const run: NativeProjectRun = { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + }; + for (const recorded of [{ ...run, runtimeMetadata: true as const }, run]) { + await preflightNativeRestart({ + runtime: opts.runtime, + scope, + composeFile: opts.composeFile, + runtimeMetadata: selection, + httpsPort: httpsSelection.httpsPort, + run: recorded, + dependencies: { + prepare: opts.dependencies.prepare, + review: opts.dependencies.review, + dependencies: async () => [], + invoke: restartPreflightInvoke, + }, + }); + } + expect(observed).toHaveLength(3); + expect(observed[1]).toBe(observed[0]); + const web = JSON.parse(observed[0] ?? "{}").services.web.environment; + expect(web).toMatchObject({ + HACK_BRANCH: "feature-a", + HACK_COMPOSE_PROJECT: "app--feature-a", + HACK_DEV_HOST: "feature-a.app.hack.local", + HACK_DEV_URL: "https://feature-a.app.hack.local:8443", + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URLS: "[]", + }); + expect(observed[2]).not.toContain("HACK_"); +}); + test("retained startup selects legacy labels only after runtime admission and before review", async () => { for (const changed of [false, true]) { const { opts } = await fixture(false); @@ -2751,3 +2824,148 @@ test("later graph, temporary, hook and acknowledgement failures cannot mask HTTP expect(events).not.toContain("ready"); } }); + +const runtimeMetadata = { + devHost: "example.com", + aliasHost: null, + projectName: "demo", +}; +function stubHttpsLease( + opts: Awaited>["opts"] +) { + opts.dependencies.https = async (selection) => ({ + identity: leaseIdentity(selection), + caPath: "/synthetic/root.crt", + httpsPort: 8443, + exited: new Promise(() => {}), + verifyHostname: async () => ({ statusCode: 200 }), + releaseAfterCleanup: async () => {}, + }); +} +test("native up reviews runtime host metadata with the HTTPS port and records it", async () => { + const { opts } = await httpsFixture(); + stubHttpsLease(opts); + const review = opts.dependencies.review!; + let reviewed: Record | undefined; + opts.dependencies.review = async (request) => { + reviewed = JSON.parse(request.input.normalizedComposeJson).services.web + .environment; + return await review(request); + }; + let saved: NativeProjectRun | undefined; + opts.dependencies.save = async ({ run }) => { + saved = run; + }; + expect( + await startNativeProject({ + ...opts, + https: httpsSelection, + runtimeMetadata, + }) + ).toBe(0); + expect(reviewed).toMatchObject({ + HACK_BRANCH: "", + HACK_COMPOSE_PROJECT: "demo", + HACK_DEV_HOST: "example.com", + HACK_DEV_URL: "https://example.com:8443", + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URL: "https://web.example.com:8443", + HACK_SERVICE_URLS: JSON.stringify(["https://web.example.com:8443"]), + }); + expect(JSON.parse(String(reviewed?.HACK_RUNTIME_METADATA))).toEqual({ + version: 1, + branch: null, + composeProject: "demo", + hosts: { dev: "example.com", alias: null }, + services: { web: { urls: ["https://web.example.com:8443"] } }, + }); + expect(saved?.runtimeMetadata).toBe(true); +}); +test("runtime host metadata that differs from reviewed routes refuses before serving", async () => { + const { opts, events } = await httpsFixture(); + stubHttpsLease(opts); + const prepare = opts.dependencies.prepare!; + opts.dependencies.prepare = async (request) => { + const input = await prepare(request); + return { + ...input, + normalizedComposeJson: input.normalizedComposeJson.replace( + "web.example.com", + "other.example.com" + ), + }; + }; + opts.dependencies.serve = async () => { + events.push("serve"); + return 0; + }; + await expect( + startNativeProject({ ...opts, https: httpsSelection, runtimeMetadata }) + ).rejects.toThrow("Native runtime metadata"); + expect(events).not.toContain("serve"); + expect(events).toContain("cleanup"); +}); +test("startup without a runtime metadata selection leaves the reviewed graph unchanged", async () => { + const { opts } = await fixture(false); + const review = opts.dependencies.review!; + let reviewed = ""; + opts.dependencies.review = async (request) => { + reviewed = request.input.normalizedComposeJson; + return await review(request); + }; + let saved: NativeProjectRun | undefined; + opts.dependencies.save = async ({ run }) => { + saved = run; + }; + expect(await startNativeProject(opts)).toBe(0); + expect(reviewed).not.toContain("HACK_"); + expect(saved && Object.hasOwn(saved, "runtimeMetadata")).toBe(false); +}); +test("restart preflight refuses runtime host metadata that differs from reviewed routes", async () => { + const { opts } = await httpsFixture(); + const review = opts.dependencies.review!; + const run: NativeProjectRun = { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + runtimeMetadata: true, + }; + const preflight = (hostnames: readonly string[]) => + preflightNativeRestart({ + runtime: opts.runtime, + scope: opts.scope, + composeFile: opts.composeFile, + runtimeMetadata, + httpsPort: httpsSelection.httpsPort, + run, + dependencies: { + prepare: opts.dependencies.prepare, + review: async (request) => + await review({ + ...request, + run: async (checked) => + await request.run({ + ...checked, + report: { + plan: { + enrollment_compatible: true, + services: { + web: { active: true, routing: { port: 3000, hostnames } }, + }, + }, + }, + }), + }), + dependencies: async () => [], + invoke: restartPreflightInvoke, + }, + }); + await preflight(["web.example.com"]); + await expect(preflight(["other.example.com"])).rejects.toThrow( + "Native runtime metadata" + ); +}); diff --git a/tests/native-runtime-metadata.test.ts b/tests/native-runtime-metadata.test.ts new file mode 100644 index 000000000..3022b4508 --- /dev/null +++ b/tests/native-runtime-metadata.test.ts @@ -0,0 +1,348 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { YAML } from "bun"; +import { prepareNativeProjectBranch } from "../src/backends/native-project-branch.ts"; +import { + type NativeProjectInput, + prepareNativeProjectInput, +} from "../src/backends/native-project-input.ts"; +import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; +import { nativeRuntimeMetadataSelected } from "../src/backends/native-project-start.ts"; +import { + applyNativeRuntimeMetadata, + verifyNativeRuntimeMetadataRoutes, +} from "../src/backends/native-runtime-metadata.ts"; +import { isRecord } from "../src/lib/guards.ts"; +import { + buildRuntimeHostMetadataOverride, + type RuntimeHostMetadata, +} from "../src/lib/runtime-host-metadata.ts"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +const SELECTION = { + devHost: "app.hack.local", + aliasHost: "app.hack.gy", + projectName: "app", +} as const; + +function input(services: Record): NativeProjectInput { + return { + originalSha256: "d".repeat(64), + normalizedComposeJson: JSON.stringify({ services }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName: null, + environmentFiles: [], + serviceNames: Object.keys(services), + }; +} + +function environment( + result: NativeProjectInput, + service: string +): Record { + const compose: unknown = JSON.parse(result.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw new Error("missing services"); + } + const selected = compose.services[service]; + if (!(isRecord(selected) && isRecord(selected.environment))) { + throw new Error(`missing ${service} environment`); + } + return selected.environment; +} + +function metadata(env: Record): RuntimeHostMetadata { + return JSON.parse(String(env.HACK_RUNTIME_METADATA)) as RuntimeHostMetadata; +} + +async function project() { + const root = await mkdtemp(join(tmpdir(), "native-runtime-metadata-")); + roots.push(root); + const projectDir = join(root, ".hack"); + await mkdir(projectDir); + const composeFile = join(projectDir, "docker-compose.yml"); + await writeFile( + composeFile, + JSON.stringify({ + services: { + web: { + image: "example", + environment: ["KEEP=public"], + labels: { + caddy: "app.hack.local, app.hack.gy", + "caddy.tls": "internal", + "caddy.reverse_proxy": "{{upstreams 3000}}", + }, + }, + api: { + image: "example", + environment: { HACK_SERVICE_URL: "author-owned" }, + labels: { + caddy: "api.app.hack.local", + "caddy.tls": "internal", + "caddy.reverse_proxy": "{{upstreams 4000}}", + }, + }, + worker: { image: "example" }, + }, + }) + ); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ dev_host: "app.hack.local", oauth: { enabled: true } }) + ); + await writeFile( + join(projectDir, "hack.env.default.yaml"), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { global: { HACK_DEV_URL: "https://project-owned.example" } }, + }) + ); + return { root, projectDir, composeFile }; +} + +test("native up input for a branch instance carries branch metadata with the HTTPS port", async () => { + const f = await project(); + const scope = { + projectRoot: f.root, + projectDir: f.projectDir, + nativeHome: join(f.root, "unused"), + branch: "feature-a", + }; + const branched = await prepareNativeProjectBranch({ + input: await prepareNativeProjectInput({ + projectRoot: f.root, + projectDir: f.projectDir, + composeFile: f.composeFile, + }), + composeFile: f.composeFile, + scope, + }); + const result = applyNativeRuntimeMetadata({ + input: branched, + selection: SELECTION, + branch: "feature-a", + httpsPort: 8443, + }); + + const web = environment(result.input, "web"); + expect(web).toMatchObject({ + KEEP: "public", + HACK_BRANCH: "feature-a", + HACK_COMPOSE_PROJECT: "app--feature-a", + HACK_DEV_HOST: "feature-a.app.hack.local", + HACK_ALIAS_HOST: "feature-a.app.hack.gy", + HACK_ALIAS_URL: "https://feature-a.app.hack.gy:8443", + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URL: "https://feature-a.app.hack.local:8443", + HACK_SERVICE_URLS: JSON.stringify([ + "https://feature-a.app.hack.local:8443", + "https://feature-a.app.hack.gy:8443", + ]), + }); + // Explicit project values win: the managed key stays a private declaration. + expect(web.HACK_DEV_URL).toBeNull(); + expect(result.input.managedEnvironment.web?.HACK_DEV_URL).toBe( + "https://project-owned.example" + ); + expect(result.input.managedEnvironment).toBe(branched.managedEnvironment); + expect(result.input.normalizedComposeJson).not.toContain( + "project-owned.example" + ); + expect(result.input.originalSha256).toBe(branched.originalSha256); + + const api = environment(result.input, "api"); + expect(api.HACK_SERVICE_URL).toBe("author-owned"); + expect(api.HACK_SERVICE_URLS).toBe( + JSON.stringify(["https://api.feature-a.app.hack.local:8443"]) + ); + const worker = environment(result.input, "worker"); + expect(worker.HACK_SERVICE_URL).toBeUndefined(); + expect(worker.HACK_SERVICE_URLS).toBe("[]"); + expect(metadata(worker)).toEqual({ + version: 1, + branch: "feature-a", + composeProject: "app--feature-a", + hosts: { dev: "feature-a.app.hack.local", alias: "feature-a.app.hack.gy" }, + services: { + web: { + urls: [ + "https://feature-a.app.hack.local:8443", + "https://feature-a.app.hack.gy:8443", + ], + }, + api: { urls: ["https://api.feature-a.app.hack.local:8443"] }, + }, + }); + expect(result.routes).toEqual( + new Map([ + ["web", ["feature-a.app.hack.local", "feature-a.app.hack.gy"]], + ["api", ["api.feature-a.app.hack.local"]], + ]) + ); +}); + +test("base instance metadata has an empty branch and portless default HTTPS URLs", () => { + for (const httpsPort of [null, 443]) { + const result = applyNativeRuntimeMetadata({ + input: input({ + web: { + image: "example", + labels: ["caddy=App.Hack.Local.", "caddy.tls=internal"], + }, + }), + selection: { ...SELECTION, aliasHost: null }, + branch: null, + httpsPort, + }); + const web = environment(result.input, "web"); + expect(web).toEqual({ + HACK_BRANCH: "", + HACK_DEV_HOST: "app.hack.local", + HACK_DEV_URL: "https://app.hack.local", + HACK_COMPOSE_PROJECT: "app", + HACK_RUNTIME_METADATA: JSON.stringify({ + version: 1, + branch: null, + composeProject: "app", + hosts: { dev: "app.hack.local", alias: null }, + services: { web: { urls: ["https://app.hack.local"] } }, + }), + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URLS: JSON.stringify(["https://app.hack.local"]), + HACK_SERVICE_URL: "https://app.hack.local", + }); + expect(web.HACK_ALIAS_HOST).toBeUndefined(); + } +}); + +test("list environments keep their shape and literal values are escaped for interpolation", () => { + const result = applyNativeRuntimeMetadata({ + input: input({ + web: { image: "example", environment: ["KEEP=public", "HACK_BRANCH"] }, + }), + selection: { + devHost: "app.hack.local", + aliasHost: null, + projectName: "a$b", + }, + branch: null, + httpsPort: null, + }); + const compose = JSON.parse(result.input.normalizedComposeJson); + const entries: string[] = compose.services.web.environment; + expect(entries.slice(0, 2)).toEqual(["KEEP=public", "HACK_BRANCH"]); + expect(entries).toContain("HACK_COMPOSE_PROJECT=a$$b"); + expect(entries.filter((entry) => entry.startsWith("HACK_BRANCH"))).toEqual([ + "HACK_BRANCH", + ]); +}); + +test("native metadata matches the Compose backend's keys and values", () => { + const services = { + web: { + image: "example", + environment: { HACK_SERVICE_URL: "author-owned" }, + labels: { caddy: "app.hack.local, app.hack.gy" }, + }, + worker: { image: "example", environment: ["KEEP=public"] }, + }; + const compose = buildRuntimeHostMetadataOverride({ + composeYamls: [JSON.stringify({ services })], + branch: null, + devHost: SELECTION.devHost, + aliasHost: SELECTION.aliasHost, + composeProject: SELECTION.projectName, + }); + const parsed = YAML.parse(compose ?? "") as { + services: Record }>; + }; + const native = applyNativeRuntimeMetadata({ + input: input(services), + selection: SELECTION, + branch: null, + httpsPort: null, + }); + expect(environment(native.input, "web")).toEqual({ + HACK_SERVICE_URL: "author-owned", + ...parsed.services.web?.environment, + }); + const worker = JSON.parse(native.input.normalizedComposeJson).services.worker + .environment as string[]; + expect(worker).toEqual([ + "KEEP=public", + ...Object.entries(parsed.services.worker?.environment ?? {}).map( + ([key, value]) => `${key}=${value}` + ), + ]); +}); + +test("reviewed routes must match the advertised hostnames for active services", () => { + const routes = new Map([["web", ["b.hack", "a.hack"]]]); + const plan = (hostnames: unknown, active = true) => ({ + services: { + web: { active, routing: { hostnames, port: 3000 } }, + worker: { active: true, routing: null }, + }, + }); + verifyNativeRuntimeMetadataRoutes({ + plan: plan(["a.hack", "b.hack"]), + routes, + }); + verifyNativeRuntimeMetadataRoutes({ + plan: plan(["other.hack"], false), + routes, + }); + for (const changed of [["a.hack"], ["a.hack", "c.hack"], [1], undefined]) { + expect(() => + verifyNativeRuntimeMetadataRoutes({ plan: plan(changed), routes }) + ).toThrow("Native runtime metadata"); + } + expect(() => + verifyNativeRuntimeMetadataRoutes({ + plan: { + services: { + worker: { active: true, routing: { hostnames: ["w.hack"] } }, + }, + }, + routes, + }) + ).toThrow("Native runtime metadata"); +}); + +test("retained graphs reproduce exactly the metadata selection they were admitted with", () => { + const legacy: NativeProjectRun = { + run: "1".repeat(32), + owner: "2".repeat(32), + namespace: "3".repeat(64), + planId: "4".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + }; + const current: NativeProjectRun = { ...legacy, runtimeMetadata: true }; + expect(nativeRuntimeMetadataSelected({ selection: SELECTION })).toBe( + SELECTION + ); + expect(nativeRuntimeMetadataSelected({})).toBeUndefined(); + expect( + nativeRuntimeMetadataSelected({ selection: SELECTION, restore: legacy }) + ).toBeUndefined(); + expect( + nativeRuntimeMetadataSelected({ selection: SELECTION, restore: current }) + ).toBe(SELECTION); + expect(() => nativeRuntimeMetadataSelected({ restore: current })).toThrow( + "runtime metadata selection" + ); +}); diff --git a/tests/native-up-runtime-metadata-selection.test.ts b/tests/native-up-runtime-metadata-selection.test.ts new file mode 100644 index 000000000..a053c22a3 --- /dev/null +++ b/tests/native-up-runtime-metadata-selection.test.ts @@ -0,0 +1,147 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, +} from "../src/constants.ts"; +import { restoreEnv } from "./helpers/env.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +// `hack up` on the native backend must hand startNativeProject the public runtime +// metadata selection; without it native containers silently lose HACK_DEV_URL etc. +const startups: Record[] = []; +const startMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/backends/native-project-start.ts", + overrides: { + startNativeProject: async (opts: Record) => { + startups.push(opts); + return 0; + }, + }, +}); +// Keep this a legacy Compose project test: no authored or adopted dispatch. +const authoredMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/native-compose-command.ts", + overrides: { tryNativeComposeCommand: async () => null }, +}); +const branchesMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/branches.ts", + overrides: { + resolveEffectiveBranch: async () => ({ + branch: null, + source: "none", + gitBranch: null, + }), + }, +}); +const { upCommand } = await import("../src/commands/project.ts"); + +const ENV_KEYS = [ + "HACK_HOME", + "HACK_RUNTIME_BACKEND", + "HACK_NATIVE_BINARY", + "HACK_NATIVE_HOME", + "HACK_NATIVE_HTTPS_PORT", + "HACK_NATIVE_CADDY_BINARY", + "HACK_NATIVE_CADDY_SHA256", +] as const; +const savedEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); +const roots: string[] = []; + +beforeAll(() => { + startMock.activate(); + authoredMock.activate(); + branchesMock.activate(); +}); +afterEach(async () => { + startups.length = 0; + for (const key of ENV_KEYS) { + restoreEnv(key, savedEnv.get(key)); + } + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); +afterAll(() => { + startMock.deactivate(); + authoredMock.deactivate(); + branchesMock.deactivate(); +}); + +async function nativeProject(config: Record) { + const root = await mkdtemp(join(tmpdir(), "native-up-metadata-")); + roots.push(root); + await mkdir(join(root, ".hack")); + await writeFile( + join(root, ".hack", PROJECT_COMPOSE_FILENAME), + "name: metadata-compose\nservices:\n web:\n image: alpine:3.20\n" + ); + await writeFile( + join(root, ".hack", PROJECT_CONFIG_FILENAME), + JSON.stringify(config) + ); + for (const key of ENV_KEYS) { + Reflect.deleteProperty(process.env, key); + } + process.env.HACK_HOME = join(root, ".global-hack"); + process.env.HACK_RUNTIME_BACKEND = "native"; + process.env.HACK_NATIVE_BINARY = join(root, "hack-local"); + process.env.HACK_NATIVE_HOME = join(root, "native-home"); + return root; +} + +async function up(root: string, branch?: string): Promise { + const argv = ["--path", root, ...(branch ? ["--branch", branch] : [])]; + const input = { + ctx: { cwd: root, cli: CLI_SPEC }, + args: { + options: { + path: root, + project: undefined, + env: undefined, + branch, + detach: false, + profile: undefined, + target: undefined, + json: false, + }, + positionals: { services: [] }, + raw: { argv, positionals: [] }, + }, + } as unknown as Parameters[0]; + return await upCommand.handler(input); +} + +test("native up passes the base dev host, OAuth alias and Compose project name", async () => { + const root = await nativeProject({ + name: "metadata-config", + dev_host: "meta.hack", + oauth: { enabled: true, tld: "gy" }, + }); + expect(await up(root, "feature-a")).toBe(0); + expect(startups).toHaveLength(1); + const startup = startups[0]; + expect(startup?.scope).toMatchObject({ branch: "feature-a" }); + expect(startup?.runtimeMetadata).toEqual({ + devHost: "meta.hack", + aliasHost: "meta.hack.gy", + projectName: "metadata-compose", + }); +}); + +test("native up without an OAuth alias passes a null alias host", async () => { + const root = await nativeProject({ dev_host: "plain.hack" }); + expect(await up(root)).toBe(0); + expect(startups[0]?.scope).toMatchObject({ branch: null }); + expect(startups[0]?.runtimeMetadata).toEqual({ + devHost: "plain.hack", + aliasHost: null, + projectName: "metadata-compose", + }); +}); diff --git a/tests/runtime-host-metadata.test.ts b/tests/runtime-host-metadata.test.ts index b125a498a..30cdba45a 100644 --- a/tests/runtime-host-metadata.test.ts +++ b/tests/runtime-host-metadata.test.ts @@ -3,6 +3,7 @@ import { YAML } from "bun"; import { isRecord } from "../src/lib/guards.ts"; import { + buildRuntimeHostMetadataEnvironments, buildRuntimeHostMetadataOverride, type RuntimeHostMetadata, } from "../src/lib/runtime-host-metadata.ts"; @@ -110,6 +111,94 @@ test("runtime metadata uses every effective public URL for a branch instance", ( ]); }); +test("Compose runtime metadata override bytes stay portless and unchanged", () => { + const override = buildRuntimeHostMetadataOverride({ + composeYamls: [ + [ + "services:", + " web:", + " image: app", + " labels:", + ' caddy: "demo.hack"', + " worker:", + " image: app", + " environment: [HACK_BRANCH]", + "", + ].join("\n"), + ], + branch: null, + devHost: "demo.hack", + aliasHost: null, + composeProject: "demo", + }); + const document = + '"{\\"version\\":1,\\"branch\\":null,\\"composeProject\\":\\"demo\\",\\"hosts\\":{\\"dev\\":\\"demo.hack\\",\\"alias\\":null},\\"services\\":{\\"web\\":{\\"urls\\":[\\"https://demo.hack\\"]}}}"'; + expect(override).toBe( + [ + "services:", + " web:", + " environment:", + ' HACK_BRANCH: ""', + " HACK_DEV_HOST: demo.hack", + " HACK_DEV_URL: https://demo.hack", + " HACK_COMPOSE_PROJECT: demo", + ` HACK_RUNTIME_METADATA: ${document}`, + " HACK_SERVICE_NAME: web", + ' HACK_SERVICE_URLS: "[\\"https://demo.hack\\"]"', + " HACK_SERVICE_URL: https://demo.hack", + " worker:", + " environment:", + " HACK_DEV_HOST: demo.hack", + " HACK_DEV_URL: https://demo.hack", + " HACK_COMPOSE_PROJECT: demo", + ` HACK_RUNTIME_METADATA: ${document}`, + " HACK_SERVICE_NAME: worker", + ' HACK_SERVICE_URLS: "[]"', + "", + ].join("\n") + ); +}); + +test("shared metadata URLs carry a non-default HTTPS port", () => { + const services = new Map([ + [ + "web", + { + hosts: ["feature-x.demo.hack", "feature-x.demo.hack"], + declaredEnvironmentKeys: new Set(), + }, + ], + ]); + for (const [httpsPort, suffix] of [ + [undefined, ""], + [null, ""], + [443, ""], + [8443, ":8443"], + ] as const) { + const web = buildRuntimeHostMetadataEnvironments({ + services, + branch: "feature-x", + devHost: "demo.hack", + aliasHost: "demo.hack.gy", + composeProject: "demo--feature-x", + httpsPort, + }).get("web"); + expect(web).toMatchObject({ + HACK_DEV_HOST: "feature-x.demo.hack", + HACK_DEV_URL: `https://feature-x.demo.hack${suffix}`, + HACK_ALIAS_HOST: "feature-x.demo.hack.gy", + HACK_ALIAS_URL: `https://feature-x.demo.hack.gy${suffix}`, + HACK_SERVICE_URL: `https://feature-x.demo.hack${suffix}`, + HACK_SERVICE_URLS: JSON.stringify([ + `https://feature-x.demo.hack${suffix}`, + ]), + }); + expect( + readRuntimeMetadata({ environment: web ?? {} }).services.web?.urls + ).toEqual([`https://feature-x.demo.hack${suffix}`]); + } +}); + function readServiceEnvironment(opts: { readonly override: string | null; readonly service: string;