diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index 19cacd84b..f417fa166 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -30,8 +30,10 @@ latest-version lookup or automatic updater. By default installation creates a private, current-user-owned mode-`0700` `~/.hack-next` directory. Each immutable version directory contains a complete bundle, its own `native-home`, and its own `cli-home`. An installation receipt binds -the bundle hashes and both homes' filesystem identities. Stable Homebrew `hack`, -its links, and `~/.hack` are untouched. No shell profile or PATH is edited. +the bundle hashes and both homes' filesystem identities. Installation leaves stable +Homebrew `hack`, its links, and `~/.hack` untouched; only an explicit +[Compose test](#test-both-backends) uses your own Hack home. No shell profile or +PATH is edited. Run the candidate explicitly: @@ -41,9 +43,13 @@ Run the candidate explicitly: python3 "$HOME/.hack-next/manager.py" --root "$HOME/.hack-next" status ``` -`hack-next` selects the adjacent verified native executor and the selected version's -private native and CLI homes. These settings override inherited installation -selectors, including `HACK_GLOBAL_CONFIG_PATH`. Explicit native project adaptation, +By default `hack-next` uses the native backend. It selects the adjacent verified +native executor and the selected version's private native and CLI homes. These +settings override inherited installation selectors, including `HACK_NATIVE_BINARY`, +`HACK_NATIVE_HOME`, `HACK_HOME` and `HACK_GLOBAL_CONFIG_PATH`. To run the same +verified candidate on the Compose backend, set `HACK_RUNTIME_BACKEND=compose`; see +[Test both backends](#test-both-backends). Any other non-empty value refuses before +the candidate starts. Explicit native project adaptation, dependency, AWS profile, routing, and source-sharing settings still reach the CLI. Arguments, standard input and output, exit codes, and signals reach the selected candidate. Concurrent observation and stop commands can accompany a foreground @@ -63,7 +69,54 @@ Provider setup and actual application acceptance remain separate steps. Installa does not install the provider, boot a VM, enroll a project, move volumes, or activate system DNS or trust. Follow [native candidate preparation](native-candidate.md) using the selected bundle and its `native-home`. Candidate `hack-next update` -retains the existing refusal; use the channel manager's explicit upgrade command. +retains the existing refusal on both backends; use the channel manager's explicit +upgrade command. + +## Test both backends + +The same selected candidate can run on either backend. You do not need to call the +bundled `hack-cli` directly or create a private `HACK_HOME` to test Compose. + +| Backend | Command | Hack home | Routing, DNS and TLS | +| --- | --- | --- | --- | +| Native (default) | `"$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | The version's private `cli-home` and `native-home` | The candidate's native routing, set up through [native candidate preparation](native-candidate.md) | +| Compose | `HACK_RUNTIME_BACKEND=compose "$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | Your own `HACK_HOME` (default `~/.hack`) and `HACK_GLOBAL_CONFIG_PATH` | Your existing global Caddy, CoreDNS and local CA | + +With `HACK_RUNTIME_BACKEND=compose`, `hack-next` verifies the selected bundle as +usual and then runs its `hack-cli` on the Compose backend. It keeps your Hack home, +so `https://.hack` routes, container DNS and CA trust use the global +services that `hack global install` already set up. It removes `HACK_NATIVE_BINARY` +and `HACK_NATIVE_HOME` so native selections cannot reach Compose children. The +candidate's private homes are not used. Compose `up` still uses the configurable +Compose startup budget; a cold image build can need a larger +`HACK_COMPOSE_STARTUP_TIMEOUT_MS`. + +Compose testing shares state with stable Hack: + +- The candidate reads and writes the same project registry, global configuration + and project `.hack/` files as stable `hack`. Stable v4 uses an older registry + lock protocol, so do not run stable `hack` and the candidate against the same + home at the same time. Stop one before you start the other. +- `hack-next` does not set up the shared global services for you. Install or + change them with stable `hack global` commands. +- To test Compose in isolation instead, set `HACK_HOME` to a separate private + directory. That home has no global services, so routing, container DNS and CA + trust are not tested. + +Leave `HACK_RUNTIME_BACKEND` unset, or set it to `native`, to return to the native +default. A shell that exported `HACK_RUNTIME_BACKEND=compose` for other work also +selects Compose for `hack-next`; unset it before native testing. + +Backend selection needs the current channel manager. The manager released with +`5.0.0-next.3` through `5.0.0-next.5` always selects native, and ignores +`HACK_RUNTIME_BACKEND=compose`. Run the reviewed `upgrade-manager` operation below +once to receive backend selection. This works for every retained bundle, including +`5.0.0-next.5`. Candidate CLIs from `5.0.0-next.5` and earlier refuse `update` only +on the native backend; do not run `hack-next update` on Compose with those bundles. + +The bundled `hack-v5` entrypoint follows the same rule for manual bundles: +unset selects native and requires `HACK_NATIVE_HOME`; `compose` needs no native +home and uses your own Hack home. ## Upgrade and return to a retained candidate diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index b81fb7190..6ab6d4a40 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -110,8 +110,14 @@ export HACK_NATIVE_HOME=/absolute/private/candidate-home ``` Keep the bundle together and invoke this entrypoint by its full path (do not copy -or symlink only the script). It selects the adjacent executor even if inherited -backend variables select another binary. It preserves arguments, exit codes and +or symlink only the script). With `HACK_RUNTIME_BACKEND` unset (or `native`) it +selects the native backend and the adjacent executor, even if an inherited +`HACK_NATIVE_BINARY` selects another binary. An explicit +`HACK_RUNTIME_BACKEND=compose` runs the same bundled CLI on the Compose backend +with your own Hack home and existing global routing services. It needs no +`HACK_NATIVE_HOME` and removes the native selectors. Other values refuse with exit +code 64. The [candidate installer guide](https://github.com/hack-dance/hack/blob/next/docs/guides/candidate-install.md#test-both-backends) +describes Compose testing and its shared-state limits. It preserves arguments, exit codes and signals through `exec`, and never replaces the installed `hack`. Project-specific native adaptation, dependency and routing selections remain explicit; this entrypoint does not prepare a provider or migrate existing projects. An unversioned local diff --git a/docs/guides/v5-dogfooding.md b/docs/guides/v5-dogfooding.md index bae09b2e1..461b72311 100644 --- a/docs/guides/v5-dogfooding.md +++ b/docs/guides/v5-dogfooding.md @@ -9,8 +9,9 @@ compatibility, lower resource use or whole-product readiness. The [version-pinned candidate installer](candidate-install.md) provides `hack-next` alongside stable `hack`. It does not replace Homebrew Hack, edit PATH, convert -projects, move volumes, enable DNS or trust, or start a VM. Candidate homes and -stable state are separate. Do not alias `hack` to `hack-next` as a rollout step. +projects, move volumes, enable DNS or trust, or start a VM. On the default native +backend, candidate homes and stable state are separate; an explicit Compose test +uses your own Hack home. Do not alias `hack` to `hack-next` as a rollout step. Each candidate upgrade starts fresh native and CLI homes. Retained bundles keep their original homes; selecting another version does not migrate application data. @@ -35,6 +36,11 @@ These commands do not establish that an arbitrary v4 project can run natively. the [candidate guide](native-candidate.md), with admitted storage and an explicitly approved source share and network policy. A refused guard is a result to inspect, not permission to change host security, erase evidence or raise resource limits. + Test the backend the project will use. `hack-next` runs native by default; + `HACK_RUNTIME_BACKEND=compose hack-next` runs the same candidate on Compose with + your own Hack home, so global routing, DNS and CA trust are checked too. See + [Test both backends](candidate-install.md#test-both-backends), including why + stable `hack` must not run against the same home at the same time. 3. Exercise the project's supported command form: foreground start, readiness, status, finite logs, noninteractive exec, retaining stop and restart. Exercise one-off run only where admitted. For an authored project, check generated public diff --git a/scripts/hack-v5.sh b/scripts/hack-v5.sh index beda8d690..56089d824 100644 --- a/scripts/hack-v5.sh +++ b/scripts/hack-v5.sh @@ -1,15 +1,36 @@ #!/bin/sh -# Relocatable frontend for an explicitly selected native candidate home. +# Relocatable frontend for an explicitly selected candidate bundle. +# +# Backend selection: an unset or empty HACK_RUNTIME_BACKEND selects the native +# runtime with this bundle's adjacent executor and the explicit private +# HACK_NATIVE_HOME. An explicit HACK_RUNTIME_BACKEND=compose runs this bundle's +# CLI on the Compose backend with the caller's own Hack home (HACK_HOME, or +# ~/.hack when unset); native selectors are removed so they cannot leak into +# Compose children. Any other value refuses rather than guessing. set -eu bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) -case "${HACK_NATIVE_HOME:-}" in - /*) ;; - *) echo "Set HACK_NATIVE_HOME to an existing private candidate home (absolute path)." >&2; exit 64 ;; +backend=${HACK_RUNTIME_BACKEND:-native} +case "$backend" in + native|compose) ;; + *) echo "HACK_RUNTIME_BACKEND must be native or compose (unset selects native)." >&2; exit 64 ;; esac +if [ "$backend" = native ]; then + case "${HACK_NATIVE_HOME:-}" in + /*) ;; + *) echo "Set HACK_NATIVE_HOME to an existing private candidate home (absolute path)." >&2; exit 64 ;; + esac +fi if [ ! -x "$bundle/hack-cli" ] || [ ! -x "$bundle/hack-native" ]; then echo "Incomplete candidate bundle: hack-cli and hack-native are required." >&2 exit 69 fi -export HACK_RUNTIME_BACKEND=native -export HACK_NATIVE_BINARY="$bundle/hack-native" +# Candidate CLIs refuse self-update on either backend. +export HACK_CANDIDATE_ENTRYPOINT=hack-v5 +if [ "$backend" = native ]; then + export HACK_RUNTIME_BACKEND=native + export HACK_NATIVE_BINARY="$bundle/hack-native" +else + unset HACK_NATIVE_BINARY HACK_NATIVE_HOME + export HACK_RUNTIME_BACKEND=compose +fi exec "$bundle/hack-cli" "$@" diff --git a/scripts/install-prerelease.py b/scripts/install-prerelease.py index c85b4a7a8..010fd8a1a 100644 --- a/scripts/install-prerelease.py +++ b/scripts/install-prerelease.py @@ -67,6 +67,8 @@ "d39e77623be1876567e9db66c468e8da96067aeac0d88089df28efc11f850ef8", "e2e2bcbacf58b21d1086047c94727e5e814776384439700adcf5aabe5c49c2fa", }) +# Launch backends. Unset or empty selects native; anything else refuses. +BACKENDS = frozenset(("native", "compose")) MANAGER_UPGRADE = ".manager-upgrade.json" @@ -782,9 +784,24 @@ def verify_version(self, version, checksum): "Candidate home identity changed.") return directory - def environment(self, version): + def environment(self, version, backend="native"): + """Child environment for one launch. + + Native pins this version's executor and both private homes, overriding + inherited installation selectors. Compose is an explicit caller choice: + it keeps the caller's HACK_HOME and HACK_GLOBAL_CONFIG_PATH so the + existing global Caddy/CoreDNS and local CA apply, and removes native + selectors. Either way the CLI knows it is a candidate and refuses + self-update. + """ directory = self.root / "versions" / version environment = dict(os.environ) + environment["HACK_CANDIDATE_ENTRYPOINT"] = "hack-next" + if backend == "compose": + environment.pop("HACK_NATIVE_BINARY", None) + environment.pop("HACK_NATIVE_HOME", None) + environment["HACK_RUNTIME_BACKEND"] = "compose" + return environment environment.pop("HACK_GLOBAL_CONFIG_PATH", None) environment.update({"HACK_RUNTIME_BACKEND": "native", "HACK_NATIVE_BINARY": str(directory / "bundle/hack-native"), @@ -901,6 +918,9 @@ def install(self, version, archive=None, checksum=None, upgrade=False): remove_created_stage(stage, created) def run(self, arguments): + backend = os.environ.get("HACK_RUNTIME_BACKEND") or "native" + require(backend in BACKENDS, + "HACK_RUNTIME_BACKEND must be native or compose (unset selects native).") version = self.state["selected"] require(version is not None, "No candidate selected. Use your ordinary stable hack command.") directory = self.verify_version(version, self.state["installed"][version]) @@ -908,8 +928,11 @@ def run(self, arguments): # Shared launcher locks permit ps/down alongside foreground up, while an # exclusive selection switch refuses any in-flight launcher. Background # runtime state is gated by the owning executor on later switches. - child = subprocess.Popen([str(directory / "bundle/hack-v5"), *arguments], - env=self.environment(version)) + # Compose runs the verified CLI directly: retained bundles' hack-v5 + # entrypoints always select native and would ignore the choice. + entrypoint = "bundle/hack-cli" if backend == "compose" else "bundle/hack-v5" + child = subprocess.Popen([str(directory / entrypoint), *arguments], + env=self.environment(version, backend)) handlers = {} def forward(signum, _frame): child.send_signal(signum) diff --git a/src/commands/update.ts b/src/commands/update.ts index 1c8fa1dd2..6b5ce8669 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -68,8 +68,13 @@ const handleUpdate: CommandHandlerFor = async ({ ctx, args, }): Promise => { - // The native launcher runs hack-cli; PATH fallback can select the stable install. - if (process.env.HACK_RUNTIME_BACKEND === "native") { + // Candidate launchers run hack-cli; PATH fallback can select the stable install. + // `hack-next` and `hack-v5` mark their children on both backends, because an + // explicit Compose selection no longer carries the native backend marker. + if ( + process.env.HACK_RUNTIME_BACKEND === "native" || + (process.env.HACK_CANDIDATE_ENTRYPOINT ?? "") !== "" + ) { return writeResult({ json: args.options.json === true, result: { diff --git a/tests/native-candidate-launcher.test.ts b/tests/native-candidate-launcher.test.ts index 8bf40e18e..38a0b8b2b 100644 --- a/tests/native-candidate-launcher.test.ts +++ b/tests/native-candidate-launcher.test.ts @@ -1,55 +1,150 @@ -import { expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { chmod, copyFile, mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; -test("candidate launcher selects its adjacent executor and preserves arguments and exit", async () => { - const root = await realpath(await mkdtemp(join(tmpdir(), "hack candidate "))); - try { - await copyFile("scripts/hack-v5.sh", join(root, "hack-v5")); - await Bun.write(join(root, "hack-native"), "#!/bin/sh\nexit 0\n"); - await chmod(join(root, "hack-native"), 0o755); - await Bun.write( - join(root, "hack-cli"), - '#!/bin/sh\nprintf "%s\\n" "$HACK_RUNTIME_BACKEND" "$HACK_NATIVE_BINARY" "$HACK_NATIVE_HOME" "$@"\nexit 7\n' - ); - await chmod(join(root, "hack-cli"), 0o755); - const env = { - ...process.env, +/** + * The bundled `hack-v5` entrypoint runs against a stub `hack-cli` that only + * prints the selection it received, so no candidate software, Docker engine or + * real HOME is reached. + */ +const PRINTED = [ + "HACK_RUNTIME_BACKEND", + "HACK_NATIVE_BINARY", + "HACK_NATIVE_HOME", + "HACK_HOME", + "HACK_CANDIDATE_ENTRYPOINT", +] as const; +const STUB_CLI = `#!/bin/sh\n${PRINTED.map( + (name) => `printf "%s\\n" "\${${name}-}"` +).join("\n")}\nprintf "%s\\n" "$@"\nexit 7\n`; + +let root: string; + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "hack candidate "))); + await copyFile("scripts/hack-v5.sh", join(root, "hack-v5")); + await Bun.write(join(root, "hack-native"), "#!/bin/sh\nexit 0\n"); + await chmod(join(root, "hack-native"), 0o755); + await Bun.write(join(root, "hack-cli"), STUB_CLI); + await chmod(join(root, "hack-cli"), 0o755); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +function launcherEnv(env: Readonly>) { + return privateCliEnv({ + root, + env: { HACK_NATIVE_HOME: join(root, "private home"), - HACK_RUNTIME_BACKEND: "compose", HACK_NATIVE_BINARY: "/wrong/executor", - }; - const child = Bun.spawn( - ["sh", join(root, "hack-v5"), "exec", "a b", "--", "$literal"], - { - env, - stdout: "pipe", - stderr: "pipe", - } - ); - expect(await new Response(child.stdout).text()).toBe( - `native\n${root}/hack-native\n${root}/private home\nexec\na b\n--\n$literal\n` + HACK_HOME: join(root, "caller hack home"), + ...env, + }, + }); +} + +async function launch( + env: Readonly>, + args: readonly string[] = ["exec", "a b", "--", "$literal"] +) { + const child = Bun.spawn(["sh", join(root, "hack-v5"), ...args], { + env: launcherEnv(env), + stdout: "pipe", + stderr: "pipe", + }); + return { + stdout: await new Response(child.stdout).text(), + stderr: await new Response(child.stderr).text(), + exitCode: await child.exited, + }; +} + +test.each([ + ["unset", undefined], + ["empty", ""], + ["native", "native"], +])("candidate launcher selects native and its adjacent executor when the backend is %s", async (_label, backend) => { + const result = await launch({ HACK_RUNTIME_BACKEND: backend }); + expect(result.stdout).toBe( + [ + "native", + `${root}/hack-native`, + `${root}/private home`, + `${root}/caller hack home`, + "hack-v5", + "exec", + "a b", + "--", + "$literal", + "", + ].join("\n") + ); + expect(result.exitCode).toBe(7); +}); + +test("explicit Compose selection is respected with the caller's Hack home and no native selectors", async () => { + const result = await launch({ HACK_RUNTIME_BACKEND: "compose" }); + expect(result.stdout).toBe( + [ + "compose", + "", + "", + `${root}/caller hack home`, + "hack-v5", + "exec", + "a b", + "--", + "$literal", + "", + ].join("\n") + ); + expect(result.exitCode).toBe(7); + // Compose needs no native home, so an absent or relative one does not refuse. + for (const home of [undefined, "relative"]) { + const noHome = await launch( + { HACK_RUNTIME_BACKEND: "compose", HACK_NATIVE_HOME: home }, + ["ps"] ); - expect(await child.exited).toBe(7); - for (const home of ["", "relative"]) { - const refused = Bun.spawn(["sh", join(root, "hack-v5"), "up"], { - env: { ...env, HACK_NATIVE_HOME: home }, - stdout: "pipe", - stderr: "pipe", - }); - expect(await refused.exited).toBe(64); - expect(await new Response(refused.stdout).text()).toBe(""); - } - await rm(join(root, "hack-native")); - const incomplete = Bun.spawn(["sh", join(root, "hack-v5"), "up"], { - env, - stdout: "pipe", - stderr: "pipe", - }); - expect(await incomplete.exited).toBe(69); - expect(await new Response(incomplete.stdout).text()).toBe(""); - } finally { - await rm(root, { recursive: true, force: true }); + expect(noHome.exitCode).toBe(7); + expect(noHome.stdout.split("\n").slice(0, 3)).toEqual([ + "compose", + "", + "", + ]); } }); + +test.each([ + "docker", + "Native", + "native ", +])("unknown backend %p refuses before running the CLI", async (backend) => { + const result = await launch({ HACK_RUNTIME_BACKEND: backend }, ["up"]); + expect(result.exitCode).toBe(64); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + "HACK_RUNTIME_BACKEND must be native or compose" + ); +}); + +test("native selection refuses without an absolute private home", async () => { + for (const home of [undefined, "", "relative"]) { + const refused = await launch({ HACK_NATIVE_HOME: home }, ["up"]); + expect(refused.exitCode).toBe(64); + expect(refused.stdout).toBe(""); + } +}); + +test.each([ + undefined, + "compose", +])("incomplete bundle refuses on backend %p", async (backend) => { + await rm(join(root, "hack-native")); + const incomplete = await launch({ HACK_RUNTIME_BACKEND: backend }, ["up"]); + expect(incomplete.exitCode).toBe(69); + expect(incomplete.stdout).toBe(""); +}); diff --git a/tests/python/test_prerelease_install.py b/tests/python/test_prerelease_install.py index 1bf8db4be..1282e7de6 100644 --- a/tests/python/test_prerelease_install.py +++ b/tests/python/test_prerelease_install.py @@ -163,11 +163,13 @@ def setUp(self): self.run_patch = mock.patch.object(installer.subprocess, "run", side_effect=self.process) self.run_patch.start() self.addCleanup(self.run_patch.stop) + # A manually exported native selection naming a foreign executor and home: + # the launcher must still pin its own. Backend selection has its own tests. self.env_patch = mock.patch.dict(os.environ, { "HOME": str(self.stable_home), "HACK_HOME": str(self.stable_home / ".hack"), "HACK_GLOBAL_CONFIG_PATH": str(self.stable_home / ".hack/config.json"), "HACK_NATIVE_HOME": str(self.stable_home / "foreign-native"), - "HACK_NATIVE_BINARY": str(self.brew / "hack"), "HACK_RUNTIME_BACKEND": "docker", + "HACK_NATIVE_BINARY": str(self.brew / "hack"), "HACK_RUNTIME_BACKEND": "native", "HACK_NATIVE_ADAPTATION": "/private/project/adaptation.json", "HACK_NATIVE_DEPENDENCIES": "/private/project/dependencies.json", "HACK_NATIVE_AWS_PROFILE": "fixture-qa", @@ -1508,6 +1510,8 @@ def test_manager_and_selected_receipt_changes_refuse_status(self): def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launchers(self): self.install() + # No backend selection: the channel default is native with private homes. + os.environ.pop("HACK_RUNTIME_BACKEND") child = mock.Mock() child.wait.return_value = 23 with self.channel.lock(shared=True): @@ -1521,6 +1525,7 @@ def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launche self.assertEqual(environment["HACK_NATIVE_HOME"], str(self.channel.root / "versions/5.0.0-next.1/native-home")) self.assertEqual(environment["HACK_NATIVE_BINARY"], str(self.channel.root / "versions/5.0.0-next.1/bundle/hack-native")) self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_CANDIDATE_ENTRYPOINT"], "hack-next") for name in ("HACK_NATIVE_ADAPTATION", "HACK_NATIVE_DEPENDENCIES", "HACK_NATIVE_AWS_PROFILE", "HACK_NATIVE_HTTPS_PORT", "HACK_NATIVE_SHARED_SOURCE"): self.assertEqual(environment[name], os.environ[name]) @@ -1532,6 +1537,96 @@ def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launche with self.channel.lock(shared=True), mock.patch.object(installer.subprocess, "Popen", return_value=child): self.assertEqual(self.channel.run(["ps"]), 143) + def launch(self, channel=None): + """Run the selected candidate through `channel` with a stand-in child process.""" + channel = channel or self.channel + child = mock.Mock() + child.wait.return_value = 0 + # Every manager imports the same standard-library subprocess module. + with channel.lock(shared=True), \ + mock.patch.object(installer.subprocess, "Popen", return_value=child) as spawn: + self.assertEqual(channel.run(["ps", "--path", "a path"]), 0) + self.assertEqual(spawn.call_args.args[0][1:], ["ps", "--path", "a path"]) + return Path(spawn.call_args.args[0][0]), spawn.call_args.kwargs["env"] + + def test_launcher_respects_explicit_backend_and_keeps_native_default(self): + self.install() + version = self.channel.root / "versions/5.0.0-next.1" + for selection in (None, "", "native"): + with self.subTest(backend=selection), mock.patch.dict(os.environ): + if selection is None: + os.environ.pop("HACK_RUNTIME_BACKEND") + else: + os.environ["HACK_RUNTIME_BACKEND"] = selection + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_NATIVE_BINARY"], str(version / "bundle/hack-native")) + self.assertEqual(environment["HACK_NATIVE_HOME"], str(version / "native-home")) + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.assertNotIn("HACK_GLOBAL_CONFIG_PATH", environment) + with mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": "compose"}): + command, environment = self.launch() + # Compose runs the same verified CLI with the caller's own Hack home, so the + # existing global Caddy/CoreDNS and local CA apply; native selectors are removed. + self.assertEqual(command, version / "bundle/hack-cli") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "compose") + self.assertEqual(environment["HACK_CANDIDATE_ENTRYPOINT"], "hack-next") + self.assertEqual(environment["HACK_HOME"], str(self.stable_home / ".hack")) + self.assertEqual(environment["HACK_GLOBAL_CONFIG_PATH"], str(self.stable_home / ".hack/config.json")) + self.assertNotIn("HACK_NATIVE_BINARY", environment) + self.assertNotIn("HACK_NATIVE_HOME", environment) + for name in ("HACK_NATIVE_ADAPTATION", "HACK_NATIVE_HTTPS_PORT", "HACK_NATIVE_SHARED_SOURCE"): + self.assertEqual(environment[name], os.environ[name]) + # Unknown selections refuse before verification or spawn; inherited `docker` is + # not silently replaced by either backend. + for selection in ("docker", "Native", "native "): + with self.subTest(backend=selection), \ + mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": selection}), \ + mock.patch.object(installer.subprocess, "Popen") as spawn, \ + mock.patch.object(installer, "verify_signatures") as signatures: + with self.channel.lock(shared=True): + with self.assertRaisesRegex(installer.Refusal, "must be native or compose"): + self.channel.run(["up"]) + spawn.assert_not_called() + signatures.assert_not_called() + + def test_released_next5_manager_needs_upgrade_manager_for_compose_selection(self): + source = SOURCE.parent.parent / "tests/fixtures/prerelease-manager-storage-v5.py" + predecessor = "e2e2bcbacf58b21d1086047c94727e5e814776384439700adcf5aabe5c49c2fa" + self.assertEqual(installer.digest(source), predecessor) + self.assertIn(predecessor, installer.MANAGER_PREDECESSORS) + spec = importlib.util.spec_from_file_location("storage_v5_predecessor", source) + previous = importlib.util.module_from_spec(spec) + spec.loader.exec_module(previous) + self.channel = previous.Channel(self.root / "storage-v5") + self.channel.initialize() + archive, checksum = self.archive("5.0.0-next.1", with_current_payload) + with self.channel.lock(): + self.channel.install("5.0.0-next.1", archive, checksum) + version = self.channel.root / "versions/5.0.0-next.1" + for home in ("native-home", "cli-home"): + (version / home / "marker").write_text(home) + before = self.retained_snapshot() + with mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": "compose"}): + # The released manager always selects native and its private homes. + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.channel = self.upgrade_manager() + self.assertEqual((self.channel.root / "manager.py").read_bytes(), SOURCE.read_bytes()) + self.assertEqual(self.retained_snapshot(), before) + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-cli") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "compose") + self.assertEqual(environment["HACK_HOME"], str(self.stable_home / ".hack")) + os.environ.pop("HACK_RUNTIME_BACKEND") + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.assertEqual(self.retained_snapshot(), before) + def test_host_and_local_archive_cli_contract(self): for system, machine in (("Linux", "aarch64"), ("Darwin", "x86_64")): with mock.patch.object(installer.platform, "system", return_value=system), \ diff --git a/tests/update-command.test.ts b/tests/update-command.test.ts index cf684e170..56f65decd 100644 --- a/tests/update-command.test.ts +++ b/tests/update-command.test.ts @@ -20,6 +20,7 @@ const ENV_KEYS = [ "HACK_NATIVE_HOME", "HACK_HOME", "HACK_ASSETS_DIR", + "HACK_CANDIDATE_ENTRYPOINT", ] as const; const STABLE_BYTES = "unrelated standalone stable Hack sentinel\n"; const refuseNetwork: typeof globalThis.fetch = Object.assign( @@ -50,6 +51,7 @@ beforeEach(async () => { process.env.HACK_RUNTIME_BACKEND = "native"; process.env.HACK_NATIVE_BINARY = join(root, "candidate", "hack-native"); process.env.HACK_NATIVE_HOME = join(root, "candidate-home"); + Reflect.deleteProperty(process.env, "HACK_CANDIDATE_ENTRYPOINT"); }); afterEach(async () => { @@ -146,6 +148,39 @@ test("native update --check --tag also refuses with a human diagnostic", async ( expect(await readFile(stable, "utf8")).toBe(STABLE_BYTES); }); +test.each([ + ["hack-next", "compose"], + ["hack-v5", "compose"], + ["hack-next", undefined], +])("candidate launcher %s on backend %p refuses before lookup or fetch", async (entrypoint, backend) => { + // An explicit Compose selection through a candidate launcher drops the native + // marker; the launcher's own marker must still keep update off the stable install. + process.env.HACK_CANDIDATE_ENTRYPOINT = entrypoint; + Reflect.deleteProperty(process.env, "HACK_NATIVE_BINARY"); + Reflect.deleteProperty(process.env, "HACK_NATIVE_HOME"); + if (backend === undefined) { + Reflect.deleteProperty(process.env, "HACK_RUNTIME_BACKEND"); + } else { + process.env.HACK_RUNTIME_BACKEND = backend; + } + const before = await stableIdentity(); + const fetch = spyOn(globalThis, "fetch").mockImplementation(refuseNetwork); + const lookup = spyOn(Bun, "which").mockImplementation(() => { + throw new Error("unexpected stable binary lookup"); + }); + const result = await capturedRun(["update", "--yes", "--json"]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: + "Refusing to self-update the opt-in native candidate. Install a separately reviewed candidate bundle to update it.", + }); + expect(fetch).not.toHaveBeenCalled(); + expect(lookup).not.toHaveBeenCalled(); + expect(await readFile(stable, "utf8")).toBe(STABLE_BYTES); + expect(await stableIdentity()).toEqual(before); +}); + test.each([ "compose", undefined,