diff --git a/.env.example b/.env.example index 6eb4b14..3921241 100644 --- a/.env.example +++ b/.env.example @@ -69,8 +69,10 @@ BUILDER_TAG=latest #PODMAN_MIN_MEMORY_MIB=6144 #PODMAN_MIN_CPUS=4 -# On Apple Silicon the studio image is amd64-only (emulated). Leave as-is unless you have an arm64 image. -STUDIO_PLATFORM=linux/amd64 +# Studio images publish both amd64 and arm64, so leave this unset and the right one is pulled natively. +# Set it only to force a specific arch — e.g. an amd64-only private mirror, which on Apple Silicon then +# requires Rosetta in the podman machine. +#STUDIO_PLATFORM= # ── Host ports ── # Offset from the platform's standard ports (60021 / 4200 / 8000 / 27017 / 6060) so this dev-kit can run diff --git a/docker-compose.yml b/docker-compose.yml index 893fd7f..300593b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,9 +56,10 @@ services: duplo-ai-studio: image: quay.io/duplocloud/backend:${STUDIO_TAG} - # Feature-branch studio images are built amd64-only (arm64 is built on main). On Apple Silicon - # this runs under emulation. Override per-arch by setting STUDIO_PLATFORM in .env. - platform: ${STUDIO_PLATFORM:-linux/amd64} + # No platform pin: every studio release publishes amd64 and arm64, so this resolves natively from + # the manifest — the same as every other service here. Set STUDIO_PLATFORM in .env only to force a + # specific arch (an amd64-only private mirror, say), which on Apple Silicon then needs Rosetta. + platform: ${STUDIO_PLATFORM:-} restart: unless-stopped ports: - "${STUDIO_PORT:-60021}:60021" diff --git a/docs/configuration.md b/docs/configuration.md index 1b91098..0236472 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,7 +110,7 @@ Pin the published images the stack runs. | `AGENT_TAG` | `main-d27e511` | The `claude-code-agent` image that executes provisioning tickets. A `main` build — it carries no extension-specific changes. | | `UI_TAG` | `2a949c536be71efd4501dd60ca6fdda4f7b4676a` | The Angular portal image. A `duplo-ui` git SHA, pinned to a build that renders the license state. | | `XTERM_TAG` | `main-d323e0b` | The in-browser terminal image. | -| `STUDIO_PLATFORM` | `linux/amd64` | Docker platform for the studio image. On Apple Silicon the studio image is amd64-only and runs emulated. Leave as-is unless you have an arm64 image. | +| `STUDIO_PLATFORM` | *(unset)* | Platform override for the studio image. Leave unset: releases publish amd64 and arm64, so the native image resolves from the manifest. Set it only to force one arch against a single-arch registry — on Apple Silicon that then needs Rosetta. | `STUDIO_TAG` and `UI_TAG` are required — `run.sh` exits if either is empty: diff --git a/docs/faq.md b/docs/faq.md index e5b66ed..0b2601e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -65,8 +65,8 @@ Yes. Set `DUPLO_TARGET=remote` with `DUPLO_HOST` and `DUPLO_TOKEN` (an Administr ### Can I run it on Windows, WSL, or an ARM Mac? -- **ARM Macs (Apple Silicon)** — yes. The studio image is amd64-only and runs under emulation; - `STUDIO_PLATFORM=linux/amd64` in `.env.example` handles this. Expect it to be slower than native. +- **ARM Macs (Apple Silicon)** — yes, natively. Studio images publish arm64 alongside amd64 and the + platform is unpinned, so the native image is pulled and nothing is emulated. - **Windows and WSL** — not verified. Docker Compose v2 and `python3` are the only hard requirements and the scripts are POSIX shell, so WSL2 is the likely path, but nobody has confirmed it end to end. If you try it, please say how it went. diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md index ce2b98d..d1dae72 100644 --- a/docs/getting-started/prerequisites.md +++ b/docs/getting-started/prerequisites.md @@ -55,44 +55,21 @@ What matters is that the runtime is reachable and that its `compose` subcommand > podman needs nothing else; the build scripts handle the user-namespace uid mapping for you, so > extension bundles come out owned by you rather than by root. > -> **podman on Apple Silicon** needs three more things before the studio will start, because the studio -> image is amd64 on some tags and runs emulated: -> -> 1. **Create the machine with `--provider applehv`.** Rosetta is an applehv feature. podman 6 defaults -> to **libkrun** on Apple Silicon, and libkrun has no Rosetta support at all — upstream's -> `LibKrunStubber.GetRosetta` returns false unconditionally, so a `rosetta = true` under libkrun is -> read and silently discarded, and `podman machine inspect` keeps reporting `Rosetta: false` however -> many times you edit the config. **Unlike the rosetta key, the provider is fixed at -> `podman machine init`** — it is the one setting an existing machine cannot be talked out of, so -> switching means destroying and recreating the VM. -> 2. **Enable Rosetta.** podman does **not** enable it by default, and without it amd64 binaries fall -> through to QEMU, which cannot run the studio's .NET runtime. Create -> `~/.config/containers/containers.conf` with `[machine] provider = "applehv"` and -> `[machine] rosetta = true` before starting the machine (on applehv the rosetta key is re-read on -> every `podman machine start`, so *that* key only needs a stop/start, not re-creating). **If that -> file already exists, edit its `[machine]` section — never append a second `[machine]` table, which -> is a TOML duplicate-key error that stops podman running at all.** -> 3. **Size the machine.** `podman machine init` defaults to 2048 MiB; `run.sh` hard-fails below -> 6144 MiB, because an undersized VM does not stop the stack coming up — it poisons later extension -> builds with an OOM kill that reports itself as `exit status 137`. -> -> Which makes the whole first-time sequence: +> **podman on Apple Silicon** needs one more thing: **size the machine.** `podman machine init` defaults +> to 2048 MiB; `run.sh` hard-fails below 6144 MiB, because an undersized VM does not stop the stack coming +> up — it poisons later extension builds with an OOM kill that reports itself as `exit status 137`. > > ```bash -> mkdir -p ~/.config/containers -> printf '[machine]\nprovider = "applehv"\nrosetta = true\n' > ~/.config/containers/containers.conf -> podman machine init --provider applehv --cpus 4 -m 8192 --disk-size 100 +> podman machine init --cpus 4 -m 8192 --disk-size 100 > podman machine start -> ``` -> -> Verify all three before your first run: -> -> ```bash -> podman machine list --format '{{.Name}} {{.VMType}}' # want applehv, NOT libkrun -> podman machine ssh 'ls /proc/sys/fs/binfmt_misc/' # want a `rosetta` entry, no `qemu-x86_64` > podman machine list # want MEMORY >= 6144 MiB > ``` > +> Rosetta is **not** required. Studio images publish arm64 alongside amd64, so the native image is pulled +> and nothing is emulated. You only need Rosetta if you pin `STUDIO_PLATFORM` to amd64 against a registry +> that carries no arm64 build — see +> [troubleshooting](../troubleshooting.md#runsh-hangs-on-waiting-for-studio-apple-silicon). +> > If the studio hangs on startup anyway, see > [troubleshooting](../troubleshooting.md#runsh-hangs-on-waiting-for-studio-apple-silicon). > diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a975af7..4d4ec20 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -102,10 +102,9 @@ maps it, and podman additionally provides `host.containers.internal` for the sam ### `./run.sh` hangs on "Waiting for studio" (Apple Silicon) -> `run.sh` now catches this before it happens — `runtime_rosetta_check` in `scripts/_runtime.sh` stops -> the run with the fix below rather than letting it hang. You should only reach this entry if the check -> could not reach the machine to judge it (it stays silent rather than guessing), or if you are on a -> version of the kit that predates it. The diagnosis and fix are the same either way. +> `run.sh` catches this before it happens — `runtime_rosetta_check` in `scripts/_runtime.sh` stops the +> run with the fix below. You should only reach this entry if the check could not reach the machine to +> judge it (it stays silent rather than guessing), or on a version of the kit that predates it. **Symptom** — minutes of dots, then a failure that blames the wrong thing: @@ -122,157 +121,39 @@ at Program.
$(String[] args) in /src/Duplo.ai.studio/Program.cs:line 255 qemu: uncaught target signal 6 (Aborted) - core dumped ``` -**Why** — feature-branch studio images are built amd64-only (`platform: ${STUDIO_PLATFORM:-linux/amd64}` -in `docker-compose.yml`), so on Apple Silicon the studio runs emulated. There are two emulators and only -one of them works: +**Why** — `STUDIO_PLATFORM` is pinning the studio image to amd64 on an arm64 host, so it runs under QEMU +user-mode emulation, which cannot run .NET's JIT/reflection. The process takes `SIGABRT` while +`MapControllers()` walks controller attributes at startup. The crash does not take PID 1 down promptly, +so the container stays `Up`, `/healthz` never answers, and the wait loop burns ~4.5 minutes before +failing on the login step — which has nothing to do with it. -- **Rosetta** — used when the podman VM has the Rosetta share attached. Runs .NET correctly. -- **QEMU user-mode** — the fallback. Cannot run .NET's JIT/reflection; the process takes `SIGABRT` while - `MapControllers()` walks controller attributes at startup. - -**First check the provider — under libkrun none of the rest of this applies.** podman 6 defaults to the -`libkrun` provider on Apple Silicon, and libkrun has **no Rosetta support at all**: upstream's -`LibKrunStubber.GetRosetta` returns false unconditionally, so `rosetta = true` is read and discarded -without a warning anywhere. The symptom is identical to a missing key — no `rosetta` binfmt handler, QEMU, -a studio that aborts — but every fix aimed at the config or at restarting the machine is wasted, because -the config was never the problem: - -```bash -podman machine list --format '{{.Name}} {{.VMType}}' # want applehv; libkrun can never have Rosetta -``` - -Unlike the rosetta key, **the provider is fixed at `podman machine init`**. Switching to applehv means -recreating the VM, which destroys its images and volumes: - -```bash -podman machine stop && podman machine rm -f -podman machine init --provider applehv --cpus 4 -m 8192 --disk-size 100 -podman machine start -``` - -Podman Desktop muddies this further: it has a known bug ([#16341](https://github.com/podman-desktop/podman-desktop/issues/16341)) -where it reports Rosetta as enabled on libkrun machines that cannot have it. - -**On applehv, Rosetta is opt-in, and podman does not enable it for you.** Unless -`~/.config/containers/containers.conf` sets `[machine] rosetta = true`, the VM is started with no Rosetta -share and every amd64 binary falls through to QEMU. The key is read on every `podman machine start`, not -only at `init`. Measured on an **applehv** machine, podman 6.1.2, reading `AppleHypervisor.Vfkit.Rosetta` -from the machine's JSON (these numbers do not describe libkrun, which is false in every row): - -| `containers.conf` | resulting `Vfkit.Rosetta` | -| --- | --- | -| `[machine] rosetta = true` | `true` | -| *file absent* | `false` | -| `[machine] rosetta = false` | `false` | - -An absent file behaves exactly like an explicit `false`. Do not assume the default is on. - -Nothing tells you it is off, because it then fails **silently** twice over: - -1. Inside the VM, `rosetta-activation.service` runs `mount -t virtiofs rosetta /var/mnt`, fails with - `wrong fs type, bad option, bad superblock on rosetta`, and **exits 0 anyway**. `systemctl status` - reports the unit as `status=0/SUCCESS`. -2. The studio container stays `Up` after the .NET process aborts — the crash does not take PID 1 down - promptly — so `podman ps` shows nothing wrong. - -A second possible cause, **not observed here and not verified** — recorded because the strings are present -in the `vfkit` binary and it would produce identical symptoms even with the config set correctly: vfkit -degrades on its own with `Rosetta installation failed. Continuing without Rosetta.` and still exits 0, -which would happen if Rosetta were not registered on the host when the machine starts and vfkit probes -for it. The diagnosis below catches it either way; step 1 of the fix rules it out. - -With the studio dead, `/healthz` never answers and the 90 × 3s poll in `run.sh` spins for ~4.5 minutes -before falling through to a login failure that has nothing to do with your password. - -> **Do not trust the top-level `Rosetta` field** in `~/.config/containers/podman/machine/applehv/podman-machine-default.json`. -> It is not the field that decides, and it is routinely the *opposite* of the effective value. The one -> that matters is `AppleHypervisor.Vfkit.Rosetta`, which is what `podman machine inspect` reports. - -**Diagnose** — the decisive check is the binfmt handler list inside the VM: +**Fix — remove the pin.** Studio releases publish arm64 alongside amd64, so an unset `STUDIO_PLATFORM` +pulls the native image and there is nothing to emulate: ```bash -podman machine ssh 'ls /proc/sys/fs/binfmt_misc/' -``` - -| You see | Meaning | -| --- | --- | -| `rosetta` present | Rosetta is live. This is not your problem — look elsewhere. | -| `qemu-x86_64` present, no `rosetta` | Broken. amd64 runs under QEMU and .NET will abort. | - -Corroborate: - -```bash -podman machine list --format '{{.VMType}}' # libkrun means Rosetta is impossible, not absent -podman machine inspect --format '{{.Rosetta}}' # false when broken -podman machine ssh 'mount | grep -i rosetta' # no output when broken -podman machine ssh 'systemctl status rosetta-activation.service --no-pager -l' - # "wrong fs type" despite status=0/SUCCESS -``` - -**Fix** — add the config, then restart the machine. **The machine does not need to be recreated.** podman -reads `[machine] rosetta` at every `podman machine start`, not only at `init`, so a stop/start picks it up -and nothing is destroyed. - -1. Confirm Rosetta on the host first — the VM share cannot be created if the host has no Rosetta: - -```bash -arch -x86_64 /usr/bin/true && echo "Rosetta OK" || softwareupdate --install-rosetta --agree-to-license -``` - -If you just installed it, re-run the `arch -x86_64` check and see it succeed before going on. - -2. Opt in to Rosetta. **This is mandatory, not a nicety** — see the table above. - - **Only if `~/.config/containers/containers.conf` does not exist yet:** - -```bash -mkdir -p ~/.config/containers -printf '[machine]\nrosetta = true\n' > ~/.config/containers/containers.conf +grep -n '^STUDIO_PLATFORM=' .env # if this prints anything, that is the problem +sed -i '' '/^STUDIO_PLATFORM=/d' .env +./run.sh ``` - > ⚠️ **If the file already exists, edit it — do not append.** Add `rosetta = true` under its - > existing `[machine]` section, or add a `[machine]` section if it has none. Appending a second - > `[machine]` table is a TOML duplicate-key error and podman then refuses to run **at all**: - > - > ``` - > Failed to obtain podman configuration: parsing containers.conf: - > toml: line 11: Key 'machine' has already been defined. - > ``` - > - > That is a worse state than the missing Rosetta you started with. `run.sh`'s preflight reads your - > actual config and prints the right instruction for whichever case you are in. - - Keep the file — it is read on every start, so deleting it silently loses Rosetta at the next one. +`./run.sh` removes a stale `STUDIO_PLATFORM=linux/amd64` for you on its next run, but only when it can +see you never changed it yourself (`.env.defaults` has to agree). A value you set by hand is always left +alone — so if you pinned it deliberately, the line above is the deliberate way to unpin it. -3. Restart the machine. No `rm`, no `init`, no re-pull — images and volumes are untouched: +**If the tag really is amd64-only** — a private or mirrored registry that carries one arch — then +unsetting changes nothing and you do need Rosetta. Check what your tag actually publishes: ```bash -podman machine stop -podman machine start +podman manifest inspect "quay.io/duplocloud/backend:$(grep '^STUDIO_TAG=' .env | cut -d= -f2)" \ + | grep architecture ``` -> Verified by toggling one machine twice: with the file absent a stop/start yields `Vfkit.Rosetta: false` -> and a `qemu-x86_64` binfmt handler; with the file present the same machine yields `true` and a `rosetta` -> handler. The machine's `Created` timestamp is unchanged throughout, so no re-creation is involved. -> -> Only recreate the machine if you need to change its **sizing** — and then pass your sizing back -> explicitly, because the `init` default is 2048 MiB and `runtime_machine_check` rejects anything under -> 6144: `podman machine init --cpus 4 -m 8192 --disk-size 100`. +With no `arm64` in that list, either use Docker Desktop (it ships Rosetta itself, with nothing to +configure) or give the podman machine an `applehv` provider with Rosetta enabled — `applehv` is the only +provider that supports it, and the provider is fixed when the machine is created, so switching means +recreating the VM. -**Confirm** — run this gate *before* `./run.sh`, so a failure costs seconds rather than a confusing hang: - -```bash -podman machine inspect --format '{{.Rosetta}}' # true -podman machine ssh 'ls /proc/sys/fs/binfmt_misc/' | grep rosetta # rosetta -podman machine ssh 'mount | grep -i rosetta' # rosetta on /var/mnt type virtiofs -podman run --rm --platform linux/amd64 docker.io/library/alpine:3 uname -m # x86_64 -``` - -The first three must pass, and `qemu-x86_64` should now be **absent** from the binfmt list — Rosetta -replaces it. The fourth is a liveness check only: a trivial amd64 binary runs under QEMU too, so `x86_64` -there confirms amd64 executes but does **not** prove Rosetta is the thing executing it. The binfmt list is -the discriminating check. -Then start the stack and verify the studio is alive rather than merely `Up`: +**Confirm the fix:** ```bash ./run.sh @@ -280,23 +161,7 @@ curl -fsS http://localhost:60031/healthz && echo " studio is answering" podman logs devkit-duplo-ai-studio-1 2>&1 | grep -c 'uncaught target signal' # must be 0 ``` -**Avoiding it entirely** — if an arm64 studio image exists for your tag, use it and skip emulation. -Observed so far: `branch-*` tags are published amd64-only, while `dev-*` tags are multi-arch. Never assume -either way — check: - -```bash -podman manifest inspect "quay.io/duplocloud/backend:$(grep '^STUDIO_TAG=' .env | cut -d= -f2)" \ - | grep architecture -``` - -If an `arm64` variant is listed, set `STUDIO_PLATFORM=linux/arm64` in `.env` and re-run `./run.sh`. - -**Or use Docker instead** — Docker Desktop on Apple Silicon provides Rosetta itself, with no -`containers.conf` and no machine configuration. The same amd64 studio image that aborts under podman's -QEMU starts normally under Docker: `uname -m` reports `x86_64` and the log carries zero -`uncaught target signal` entries. This whole entry is podman-specific. - -> **If you have both installed, check which one you are actually using.** `runtime_detect` in +> **If you have both runtimes installed, check which one you are actually using.** `runtime_detect` in > `scripts/_runtime.sh` walks `docker podman` and takes the first on `PATH` — presence only, with no > daemon check — so installing Docker silently takes precedence over podman. A podman machine can be up > and correctly configured while `./run.sh` uses Docker. Pin it deliberately: diff --git a/docs/upgrading.md b/docs/upgrading.md index 47ee694..286762d 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -35,11 +35,13 @@ Flags: ## How defaults are adopted -Framework-shipped defaults — the image tags and `STUDIO_PLATFORM` — live in +Framework-shipped defaults — the image tags — live in `.env.example`. An upgrade changes that file, but it never touches your `.env`. `run.sh` bridges the two on every run. -The tracked keys are `STUDIO_TAG`, `AGENT_TAG`, `UI_TAG`, `XTERM_TAG`, and `STUDIO_PLATFORM`. Nothing +The tracked keys are `STUDIO_TAG`, `AGENT_TAG`, `UI_TAG`, and `XTERM_TAG`. (`STUDIO_PLATFORM` is still +tracked in code, but `.env.example` no longer ships a value for it, and a key the example leaves blank is +skipped — so it is adopted only if a future release pins it again.) Nothing licensing-related is adopted or overwritten this way — `Licensing__Token` is yours, not a framework default, and the license server URL is a built-in rather than a shipped `.env` value. diff --git a/run.sh b/run.sh index 50c9314..ffbc47d 100755 --- a/run.sh +++ b/run.sh @@ -195,12 +195,15 @@ fi # deadlock and 'exit status 137'. A no-op on docker and on a machine-less podman. runtime_machine_check || exit 1 -# Rosetta preflight. Same shape and the same reason as the sized-VM check above: on Apple Silicon a -# podman machine without Rosetta hands amd64 binaries to QEMU, which cannot run the studio's .NET +# Stale-amd64-pin preflight. Same shape and the same reason as the sized-VM check above: on Apple Silicon +# a podman machine without Rosetta hands amd64 binaries to QEMU, which cannot run the studio's .NET # runtime — and every layer of that failure lies. The container reports "Up", /healthz simply never # answers, and the wait loop further down burns 4.5 minutes before exiting on "Login failed", which -# points at credentials. Catching it here turns all of that into one accurate message. A no-op on -# docker, on an amd64 host, and on an arm64 studio image. +# points at credentials. Catching it here turns all of that into one accurate message. +# +# Studio images publish arm64 now, so this fires only on an EXPLICIT amd64 STUDIO_PLATFORM — a stale .env +# the migration above declined to touch, or a single-arch registry. A no-op on docker, on an amd64 host, +# and whenever the platform is unset or already native. runtime_rosetta_check || exit 1 # ── .env helpers (line-based; safe for tokens/keys with special chars) ──────── @@ -619,6 +622,10 @@ if [ "$RESET_LICENSE" = 1 ] && [ -z "$F_LICENSE" ]; then fi fi +# Retire the old amd64 studio pin before the adoption pass below, which rewrites .env.defaults and would +# otherwise destroy the evidence this migration depends on. +runtime_migrate_studio_platform "$ENV" .env.defaults + # ── adopt changed framework defaults from .env.example (survives dev-kit upgrades) ── # Framework-shipped defaults (image tags, platform) live in .env.example and change on `upgrade_dev_kit.sh`, # which never touches your .env. We adopt a new default into .env ONLY when you haven't diverged from the diff --git a/scripts/_builder.sh b/scripts/_builder.sh index aae8ea1..944ce5d 100644 --- a/scripts/_builder.sh +++ b/scripts/_builder.sh @@ -438,15 +438,21 @@ builder_dispatch() { # value we actually resolved, which is not necessarily the one in the environment. local want_native; want_native="$(builder_clean_value "${DUPLO_BUILD_NATIVE:-$(_envv DUPLO_BUILD_NATIVE)}")" - # Resolve the CLI before probing. Failure is only fatal when a runtime was NAMED: the caller said - # which one to use, and quietly building with a different one — or dropping to a native build they - # did not ask for — is not what they asked for. Auto-detect finding nothing stays silent, because - # builder_mode's native fallback below still builds on a machine with the full toolchain, and that is - # what keeps a runtime-less CI runner working. The re-run is to print the specific reason, which was - # suppressed on the first call. - runtime_resolve 2>/dev/null || { - if [ -n "$(runtime_requested)" ]; then runtime_resolve; exit 1; fi - } + # Resolve the CLI before probing — but only when native wasn't already forced. want_native=1 outranks + # everything in builder_mode below, so a runtime that fails to resolve at this point (e.g. RUNTIME=podman + # read back out of the bind-mounted .env while running *inside* the builder container, which has no + # reason to carry a podman binary) must not be fatal: nothing downstream is going to use it. + # + # Failure is only fatal when a runtime was NAMED: the caller said which one to use, and quietly building + # with a different one — or dropping to a native build they did not ask for — is not what they asked + # for. Auto-detect finding nothing stays silent, because builder_mode's native fallback below still + # builds on a machine with the full toolchain, and that is what keeps a runtime-less CI runner working. + # The re-run is to print the specific reason, which was suppressed on the first call. + if [ "$want_native" != 1 ]; then + runtime_resolve 2>/dev/null || { + if [ -n "$(runtime_requested)" ]; then runtime_resolve; exit 1; fi + } + fi local mode; mode="$(builder_mode \ "$want_native" \ diff --git a/scripts/_runtime.sh b/scripts/_runtime.sh index 2b33521..2ce8ebc 100644 --- a/scripts/_runtime.sh +++ b/scripts/_runtime.sh @@ -446,100 +446,35 @@ runtime_rosetta_active() { return 1 } -# _runtime_machine_vmtype -> the VMType of the machine this run would talk to, or empty. -# -# The impure half of the provider question, split out for the same reason as runtime_rosetta_active: it -# needs podman and a machine, and the policy that consumes it must be testable without either. Same -# machine-selection rule as runtime_machine_check (running, else default, else first) so both checks -# always talk about the same VM. Anything unreadable is empty, never a guess. -_runtime_machine_vmtype() { - command -v podman >/dev/null 2>&1 || { printf ''; return; } - command -v python3 >/dev/null 2>&1 || { printf ''; return; } - podman machine list --format json 2>/dev/null | python3 -c ' -import json, sys -try: - ms = json.load(sys.stdin) -except Exception: - sys.exit(0) -if not isinstance(ms, list) or not ms: - sys.exit(0) -m = next((x for x in ms if x.get("Running")), None) \ - or next((x for x in ms if x.get("Default")), None) \ - or ms[0] -sys.stdout.write(str(m.get("VMType") or "")) -' 2>/dev/null || printf '' -} - -# _runtime_machine_conf_provider -> the [machine] provider in containers.conf, or empty. -_runtime_machine_conf_provider() { - local f="${CONTAINERS_CONF:-$HOME/.config/containers/containers.conf}" - [ -f "$f" ] || { printf ''; return; } - # Same [machine]-table scan as _runtime_rosetta_conf_state: header to the next [section] or EOF. - local block - block="$(awk '/^[[:space:]]*\[machine\][[:space:]]*(#.*)?$/{f=1;next} /^[[:space:]]*\[/{f=0} f' "$f" 2>/dev/null)" - [ -n "$block" ] || { printf ''; return; } - # _runtime_clean strips quotes BEFORE trimming whitespace, so ` "applehv"` (the leading space `cut` - # leaves behind) would keep its quotes. Trim the left side first and the quote is what it sees. - local v - v="$(printf '%s' "$block" | grep -E '^[[:space:]]*provider[[:space:]]*=' | tail -1 | cut -d= -f2-)" - v="${v#"${v%%[![:space:]]*}"}" - _runtime_clean "$v" -} - -# _runtime_machine_provider -> applehv | libkrun | | empty -# -# Which machine provider is in effect, resolved the way podman resolves it: the environment override -# first, then containers.conf, then — for a machine that already exists — what it was actually created -# as. The last one matters because the provider is fixed at `podman machine init`: editing the config -# afterwards changes what the NEXT machine would be, not this one, so the config alone would cheerfully -# report applehv for a libkrun VM that is running right now. -# -# This exists because Rosetta is an applehv feature. Upstream's LibKrunStubber.GetRosetta returns false -# unconditionally, so `rosetta = true` under libkrun is read, discarded, and leaves no trace anywhere -# except the missing binfmt handler — which is exactly the symptom runtime_rosetta_check already sees -# and, until now, mis-diagnosed as "you forgot to restart the machine". -_runtime_machine_provider() { - local p - # An existing machine answers first, and outranks both config sources: `podman machine init - # --provider applehv` does NOT write containers.conf, so the two disagree the moment anyone passes - # that flag — and it is the running VM, not the file, that this run will talk to. Reading the file - # first would blame libkrun for a healthy applehv machine and advise destroying it, which is the - # same class of wrong answer this function exists to stop. - p="$(_runtime_machine_vmtype)" - # No machine yet: both remaining sources describe what the NEXT one would be, in podman's own order. - [ -n "$p" ] || p="$(_runtime_clean "${CONTAINERS_MACHINE_PROVIDER:-}")" - [ -n "$p" ] || p="$(_runtime_machine_conf_provider)" - printf '%s' "$p" -} - -# _runtime_rosetta_conf_state -> absent | no-machine | no-key | disabled | enabled -# -# What containers.conf currently says about Rosetta, so the remediation can be specific. It has to be, -# because the obvious advice is actively harmful: appending a second `[machine]` table to a file that -# already has one is a TOML duplicate-key error, and podman then refuses to do ANYTHING — -# -# Failed to obtain podman configuration: parsing containers.conf: toml: line 11: -# Key 'machine' has already been defined. -# -# — which is a worse place to be than the missing Rosetta we were trying to fix. The `enabled` state -# matters just as much: config that is already right means the machine simply has not been restarted -# since, and telling someone to re-add a key they already have sends them looking in the wrong place. -# -# CONTAINERS_CONF is podman's own override for this path, so honouring it keeps us reading the same -# file podman will, and lets the tests point at a fixture. -_runtime_rosetta_conf_state() { - local f="${CONTAINERS_CONF:-$HOME/.config/containers/containers.conf}" - [ -f "$f" ] || { printf 'absent'; return; } - # The [machine] table runs from its header to the next [section] header or EOF. - local block - block="$(awk '/^[[:space:]]*\[machine\][[:space:]]*(#.*)?$/{f=1;next} /^[[:space:]]*\[/{f=0} f' "$f" 2>/dev/null)" - [ -n "$block" ] || { grep -qE '^[[:space:]]*\[machine\][[:space:]]*(#.*)?$' "$f" 2>/dev/null \ - && { printf 'no-key'; return; }; printf 'no-machine'; return; } - case "$(printf '%s' "$block" | grep -E '^[[:space:]]*rosetta[[:space:]]*=' | tail -1)" in - *true*) printf 'enabled' ;; - *false*) printf 'disabled' ;; - *) printf 'no-key' ;; - esac +# One-time migration of the studio platform pin. Until this release the kit shipped +# STUDIO_PLATFORM=linux/amd64 as a tracked default, because the studio image was amd64-only. Every +# release now publishes both arches, so the correct value is no pin at all. +# +# run.sh's adoption mechanism cannot carry this on its own: it skips keys the example ships blank +# (`[ -z "$ex" ] && continue`), so blanking STUDIO_PLATFORM in .env.example would leave every existing +# user pinned to amd64 forever. Hence an explicit removal. +# +# Conservative by design. The line goes only when .env still holds the old default AND the lock agrees +# that is what was last applied — i.e. the user never touched it. Every other combination is left exactly +# as found, and runtime_rosetta_check then explains it rather than overriding a deliberate choice. +# Idempotent, and silent unless it actually changes the file. Paths are arguments so the policy is +# testable without a real .env. +runtime_migrate_studio_platform() { # + local envf="${1-}" lockf="${2-}" cur base tmp + [ -n "$envf" ] && [ -f "$envf" ] || return 0 + + cur="$(_runtime_clean "$(grep -E '^STUDIO_PLATFORM=' "$envf" 2>/dev/null | head -1 | cut -d= -f2-)")" + [ "$cur" = linux/amd64 ] || return 0 + base="$(_runtime_clean "$(grep -E '^STUDIO_PLATFORM=' "$lockf" 2>/dev/null | head -1 | cut -d= -f2-)")" + [ "$base" = linux/amd64 ] || return 0 + + tmp="$(mktemp)" || return 0 + grep -v '^STUDIO_PLATFORM=' "$envf" > "$tmp" 2>/dev/null || true + cat "$tmp" > "$envf" # rewrite in place: keeps the file's mode and any symlink + rm -f "$tmp" + + echo "==> Removed the STUDIO_PLATFORM=linux/amd64 pin from .env: studio images now publish arm64 too," + echo " so the platform resolves natively. Set it again only to force a specific arch." } # runtime_rosetta_check -> 0 when there is nothing to complain about, 1 when an amd64 studio image is @@ -551,20 +486,29 @@ _runtime_rosetta_conf_state() { # failing with "Login failed" — a message about credentials, for a problem that has nothing to do with # them. Every layer of that is silent or misleading, so it is caught here instead. # +# Studio images now publish arm64 alongside amd64, so this is no longer a Rosetta configuration problem +# to be walked through — it is a stale pin to be removed. The check therefore fires only on an EXPLICIT +# amd64 STUDIO_PLATFORM: unset resolves natively from the manifest and needs no translation. The six-branch +# containers.conf remediation and the libkrun/applehv provider diagnosis that used to live here are gone +# with the pin that made them necessary. +# +# It is still needed, because an explicit pin still happens: a stale .env this migration declined to touch, +# and a private or mirrored registry that genuinely carries amd64 only. The second case cannot be fixed by +# unsetting anything, so the message covers both. +# # Scoped tightly, because Rosetta is only ever relevant to one combination: # - docker is not checked: Docker Desktop provides Rosetta itself, with nothing to configure. -# - a non-amd64 studio platform needs no translation at all. +# - an unset or non-amd64 studio platform needs no translation at all. # - a non-arm64 host needs no translation to run amd64. -# Anything it cannot determine returns 0. Like runtime_machine_check, this exists to replace a baffling -# failure with a clear one, never to become a new way for the kit to refuse to start. +# Anything it cannot determine returns 0 — never a new way for the kit to refuse to start. runtime_rosetta_check() { [ "${RUNTIME:-}" = podman ] || return 0 local plat host rc - # Same precedence as runtime_requested: environment wins over .env. The fallback matches the compose - # default (`platform: ${STUDIO_PLATFORM:-linux/amd64}`), so an unset value is amd64 here too. + # Same precedence as runtime_requested: environment wins over .env. Unset means no compose pin, which + # resolves natively from the manifest — so there is nothing to translate and nothing to check. plat="$(_runtime_clean "${STUDIO_PLATFORM:-$(_runtime_envv STUDIO_PLATFORM)}")" - [ -n "$plat" ] || plat=linux/amd64 + [ -n "$plat" ] || return 0 case "$plat" in *amd64*|*x86_64*) ;; *) return 0 ;; esac host="$(_runtime_host_arch)" @@ -573,99 +517,24 @@ runtime_rosetta_check() { runtime_rosetta_active; rc=$? [ "$rc" = 1 ] || return 0 - # The remediation depends on what containers.conf already says. This is not polish: telling someone to - # append `[machine]` to a file that already has that table produces a TOML duplicate-key error, and - # podman then refuses to run at all ("Key 'machine' has already been defined") — strictly worse than - # the missing Rosetta. And when the key is already correct, the file is not the problem: the machine - # simply has not been restarted since it was set. - local conf_path conf_dir fix provider after - conf_path="${CONTAINERS_CONF:-$HOME/.config/containers/containers.conf}" - conf_dir="$(dirname "$conf_path")" - provider="$(_runtime_machine_provider)" - - # The provider outranks every conf state, because under anything but applehv the rosetta key is read - # and thrown away: "your config is already correct, just restart" is then advice that cannot ever come - # true, and the person dutifully recreates the machine again and again with the same result. - if [ -n "$provider" ] && [ "$provider" != applehv ]; then - fix="The machine provider is '$provider', and Rosetta is an applehv feature. podman's $provider -backend reports Rosetta as unavailable whatever containers.conf says, so a 'rosetta = true' -there is read and discarded — which is why this looks like a config that ought to work. - -Set BOTH keys under the SINGLE existing [machine] section of -$conf_path -— never add a second [machine] header, TOML rejects a duplicate table and podman then -refuses to run at all: - - [machine] - provider = \"applehv\" - rosetta = true" - after="Then recreate the machine. The PROVIDER, unlike the rosetta key, is written at -'podman machine init' and never revisited, so it cannot be changed in place. -Recreating DESTROYS that VM's images and volumes: - - podman machine stop - podman machine rm -f - podman machine init --provider applehv --cpus 4 -m 8192 --disk-size 100 - podman machine start - ./run.sh" - else - case "$(_runtime_rosetta_conf_state)" in - absent) - fix="No $conf_path yet — create it: - - mkdir -p $conf_dir - printf '[machine]\\nrosetta = true\\n' > $conf_path" ;; - no-machine) - fix="$conf_path exists but has no [machine] section. Open it and ADD this section -(do not append a second one elsewhere in the file): - - [machine] - rosetta = true" ;; - no-key) - fix="$conf_path already has a [machine] section. Add this line UNDER that existing -section — do not add a second [machine] header, TOML rejects a duplicate table and podman -then refuses to start at all: - - rosetta = true" ;; - disabled) - fix="$conf_path sets 'rosetta = false' under [machine]. Change that one value to: - - rosetta = true" ;; - enabled) - fix="$conf_path ALREADY sets 'rosetta = true' — the file is correct and needs no edit. -The key is read at every machine start, so the machine simply has not been restarted since." ;; - esac - after="Then restart the machine — it does NOT need to be recreated, and nothing is lost. applehv -re-reads the rosetta key from containers.conf on every start and syncs it into the machine: - - podman machine stop - podman machine start - ./run.sh" - fi - cat >&2 < +# +# Returns (as its own exit status — never captured via $(), since the code under test calls `exit` +# directly on its failure path, which would terminate a command-substitution subshell before any +# echo-the-exit-code line inside it ever ran) whatever builder_dispatch itself exits with. +# +# `command -v podman` is shadowed to simulate the builder container, which has no reason to carry a +# podman binary even when the bind-mounted .env the re-entrant build reads says RUNTIME=podman. +dispatch_rc() { + ( RV="$1"; NATIVE="$2"; HASPODMAN="$3" + export DUPLO_ENV_FILE=/dev/null + [ -n "$RV" ] && export RUNTIME="$RV" || unset RUNTIME + [ -n "$NATIVE" ] && export DUPLO_BUILD_NATIVE="$NATIVE" || unset DUPLO_BUILD_NATIVE + command() { + if [ "${1-}" = -v ] && [ "${2-}" = podman ] && [ "$HASPODMAN" != 1 ]; then return 1; fi + builtin command "$@" + } + . ./scripts/_runtime.sh + . ./scripts/_builder.sh + builder_probe_in_container() { echo "$NATIVE"; } + builder_probe_toolchain() { echo 1; } + builder_probe_runtime() { echo 0; } + builder_dispatch some-script.sh some-dir + ) >/dev/null 2>&1 +} + +t "a forced-native build is unaffected by a \$RUNTIME it cannot resolve" +# This is the exact failure reported from a real build: the builder container's own compose environment +# sets DUPLO_BUILD_NATIVE=1, but the whole repo — .env included — is bind-mounted into it, so the +# re-entrant build.sh invocation reads RUNTIME=podman straight off the host's real .env even though the +# builder image has no podman binary and never needed one. +dispatch_rc podman 1 0; RC=$? +[ "$RC" = 0 ] && ok || bad "forced-native build still fataled on an unresolvable \$RUNTIME (rc=$RC)" + +t "a forced-native build is unaffected when nothing is on PATH at all" +dispatch_rc "" 1 0; RC=$? +[ "$RC" = 0 ] && ok || bad "rc=$RC" + +t "a HOST build (not forced-native) still fatals when the named runtime cannot be resolved" +# The guard must be scoped to the forced-native case only — a developer on their own machine who pinned +# RUNTIME=podman without installing it still needs the real error, not a silent native fallback they +# never asked for. +dispatch_rc podman "" 0; RC=$? +[ "$RC" != 0 ] && ok || bad "a host build with a genuinely broken \$RUNTIME exited 0 — the guard is too broad" + +t "a HOST build with no \$RUNTIME pinned still resolves normally (auto-detect, not skipped)" +dispatch_rc "" "" 1; RC=$? +[ "$RC" = 0 ] && ok || bad "rc=$RC (auto-detect should have found podman and proceeded)" + +t "bash -n on the touched files" +if bash -n scripts/_builder.sh 2>/dev/null; then ok; else bad "syntax error"; fi + +echo +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] diff --git a/tests/test-runtime.sh b/tests/test-runtime.sh index d32a89e..e7c1d15 100755 --- a/tests/test-runtime.sh +++ b/tests/test-runtime.sh @@ -89,188 +89,17 @@ if [ -z "$A" ] && [ "$B" = podman ]; then ok; else bad "auto='$A' explicit='$B'" ros() { # ros [runtime] -> "|" ( HA="$1"; PL="$2"; PRC="$3" export DUPLO_ENV_FILE=/dev/null RUNTIME="${4:-podman}" STUDIO_PLATFORM="$PL" - # Pin the provider inputs too: left alone, the check reads the developer's real containers.conf and - # shells out to podman, and these cases would then answer differently on an applehv laptop than on - # CI. The provider's own behaviour is asserted through rosmsg below, where it is set explicitly. - export CONTAINERS_CONF=/dev/null; unset CONTAINERS_MACHINE_PROVIDER . ./scripts/_runtime.sh eval "_runtime_host_arch() { printf '%s' '$HA'; }" eval "runtime_rosetta_active() { return $PRC; }" - _runtime_machine_vmtype() { printf ''; } ERR="$(runtime_rosetta_check 2>&1 >/dev/null)"; RC=$? printf '%s|%s' "$RC" "$(printf '%s' "$ERR" | head -1)" ) } -# The remediation the check prints depends on what containers.conf already says. Appending a second -# [machine] table to an existing file is a TOML "key already defined" error that stops podman dead, so -# the advice must never be one-size-fits-all. CONTAINERS_CONF is podman's own override, so pointing it -# at a fixture is both faithful and testable. -conf() { # conf -> the state word - ( F=/dev/null - if [ "$1" != NONE ]; then F="$(mktemp)"; printf '%s' "$1" > "$F"; else F="$(mktemp -u)"; fi - export CONTAINERS_CONF="$F" - . ./scripts/_runtime.sh - _runtime_rosetta_conf_state ) -} - -t "conf state reports absent when containers.conf does not exist" -[ "$(conf NONE)" = absent ] && ok || bad "got '$(conf NONE)'" - -t "conf state reports enabled when [machine] already sets rosetta = true" -R="$(conf '[containers] -[machine] -rosetta = true -[network] -')" -[ "$R" = enabled ] && ok || bad "got '$R'" - -t "conf state reports disabled when [machine] explicitly sets rosetta = false" -R="$(conf '[machine] -rosetta = false -')" -[ "$R" = disabled ] && ok || bad "got '$R'" - -t "conf state reports no-key when [machine] exists without a rosetta key" -R="$(conf '[containers] -[machine] -cpus = 4 -')" -[ "$R" = no-key ] && ok || bad "got '$R'" - -t "conf state finds [machine] when the header carries a trailing comment" -# `[machine] # my settings` is valid TOML. Anchoring the scan on end-of-line after the header hid the -# section, and the resulting no-machine advice tells the user to ADD [machine] — a duplicate-key error -# that stops podman outright, on a user whose config was already correct. -R="$(conf '[containers] -[machine] # my settings -rosetta = true -')" -[ "$R" = enabled ] && ok || bad "got '$R' (a commented header must not read as a missing section)" - -t "conf state reports no-machine when the file exists with no [machine] section" -R="$(conf '[containers] -[engine] -env = [] -')" -[ "$R" = no-machine ] && ok || bad "got '$R'" - -# Machine provider. Rosetta is an applehv feature: the libkrun provider's GetRosetta returns false -# unconditionally upstream, so `rosetta = true` under libkrun is read and discarded. The provider is -# therefore part of the diagnosis, not a detail. Resolution order mirrors podman's own, and the podman -# call is its own function so the policy is testable without a VM. -prov() { # prov -> the provider word - ( eval "$1" - if [ "$2" != NONE ]; then F="$(mktemp)"; printf '%s' "$2" > "$F"; else F="$(mktemp -u)"; fi - export CONTAINERS_CONF="$F" - . ./scripts/_runtime.sh - if [ "$3" != NONE ]; then eval "_runtime_machine_vmtype() { printf '%s' '$3'; }" - else _runtime_machine_vmtype() { printf ''; }; fi - _runtime_machine_provider ) -} - -t "an existing machine's VMType outranks containers.conf, which only describes the NEXT machine" -# `podman machine init --provider applehv` does not write containers.conf, so the two disagree the -# moment anyone passes the flag — and the VM that is running is the one this run will talk to. Reading -# the config first would blame libkrun for a healthy applehv machine and advise destroying it. -R="$(prov 'unset CONTAINERS_MACHINE_PROVIDER' '[machine] -provider = "libkrun" -' applehv)" -[ "$R" = applehv ] && ok || bad "got '$R'" - -t "with no machine yet, CONTAINERS_MACHINE_PROVIDER outranks containers.conf" -R="$(prov 'export CONTAINERS_MACHINE_PROVIDER=applehv' '[machine] -provider = "libkrun" -' NONE)" -[ "$R" = applehv ] && ok || bad "got '$R'" - -t "with no machine and no environment override, containers.conf says what the next machine will be" -R="$(prov 'unset CONTAINERS_MACHINE_PROVIDER' '[machine] -provider = "libkrun" -' NONE)" -[ "$R" = libkrun ] && ok || bad "got '$R'" - -t "machine provider is empty rather than guessed when nothing can say" -R="$(prov 'unset CONTAINERS_MACHINE_PROVIDER' NONE NONE)" -[ -z "$R" ] && ok || bad "got '$R'" - t "rosetta check fails when an amd64 image would run on an arm64 host without Rosetta" R="$(ros arm64 linux/amd64 1)" case "$R" in 1\|*[Rr]osetta*) ok ;; *) bad "got '$R'" ;; esac -rosmsg() { # rosmsg [provider] -> the whole stderr message - ( C="$1"; P="${2-}" - if [ "$C" != NONE ]; then F="$(mktemp)"; printf '%s' "$C" > "$F"; else F="$(mktemp -u)"; fi - export DUPLO_ENV_FILE=/dev/null RUNTIME=podman STUDIO_PLATFORM=linux/amd64 CONTAINERS_CONF="$F" - . ./scripts/_runtime.sh - _runtime_host_arch() { printf 'arm64'; } - runtime_rosetta_active() { return 1; } - # Unset provider (the default) is the "cannot tell" case: the advice must still stand on the config - # alone, which is what every pre-existing case below asserts. - eval "_runtime_machine_provider() { printf '%s' '$P'; }" - runtime_rosetta_check 2>&1 >/dev/null ) -} - -# NOTE: capture into a variable, never `rosmsg ... | grep -q`. Under the `set -o pipefail` at the top of -# this file, grep -q exits on first match, SIGPIPEs the writer, and the pipeline reports the writer's -# failure — so the condition reads FALSE precisely when the pattern matched. That silently inverted -# these three assertions when they were first written. -t "the fix never suggests appending a second [machine] table to an existing config" -M="$(rosmsg '[containers] -[machine] -rosetta = false -')" -if printf '%s\n' "$M" | grep -qE '>>[[:space:]]*[^|]*containers\.conf'; then - bad "tells the user to append a second [machine] table — a TOML duplicate-key error that stops podman" -else ok; fi - -t "an applehv machine whose config is already right points at a restart, not at recreating" -# applehv's StartVM re-reads cfg.Machine.Rosetta from containers.conf on every start and syncs it into -# the machine config, so the key really does take effect without re-initialising. Only the PROVIDER is -# fixed at init. Telling an applehv user to destroy their VM would cost them images and volumes for a -# change a stop/start already makes. -M="$(rosmsg '[machine] -rosetta = true -' applehv)" -if printf '%s\n' "$M" | grep -qi 'already' && printf '%s\n' "$M" | grep -q 'podman machine start' \ - && ! printf '%s\n' "$M" | grep -q 'podman machine rm'; then ok -else bad "got: $(printf '%s' "$M" | sed -n '9,16p' | tr '\n' ' ')"; fi - -t "a libkrun machine is diagnosed as the provider, not as a missing or unrestarted config" -M="$(rosmsg '[machine] -provider = "libkrun" -rosetta = true -' libkrun)" -if printf '%s\n' "$M" | grep -q 'applehv' && printf '%s\n' "$M" | grep -qi 'libkrun'; then ok -else bad "never names the provider: $(printf '%s' "$M" | sed -n '5,14p' | tr '\n' ' ')"; fi - -t "the libkrun remediation does not send the user back to a config that is already correct" -M="$(rosmsg '[machine] -provider = "libkrun" -rosetta = true -' libkrun)" -if printf '%s\n' "$M" | grep -qi 'rosetta = true.*add\|add.*rosetta = true'; then - bad "tells them to add a key they already have" -else ok; fi - -t "a non-applehv machine is never told a restart will do, because the provider is fixed at init" -# The provider — unlike rosetta — is written at `podman machine init` and never revisited. The old -# footer's "it does NOT need to be recreated, and nothing is lost" is true for applehv and false here, -# and it is the sentence that turns a five-minute fix into an afternoon of recreating the same VM. -M="$(rosmsg '[machine] -provider = "libkrun" -rosetta = true -' libkrun)" -if printf '%s\n' "$M" | grep -qi 'not need to be recreated'; then - bad "promises a restart is enough for a provider that can never honour the key" -elif printf '%s\n' "$M" | grep -q 'podman machine rm'; then ok -else bad "never tells them to recreate: $(printf '%s' "$M" | sed -n '20,28p' | tr '\n' ' ')"; fi - -t "a missing config gets a create command that cannot clobber anything" -M="$(rosmsg NONE)" -# `>` not `>>`: there is no file to append to, and `>>` would be the wrong habit to teach. -if printf '%s\n' "$M" | grep -qE "printf .* > " && printf '%s\n' "$M" | grep -qi 'create it'; then ok -else bad "no safe create command: $(printf '%s' "$M" | sed -n '10,13p' | tr '\n' ' ')"; fi - t "rosetta check is a no-op on docker, which provides Rosetta itself" R="$(ros arm64 linux/amd64 1 docker)" [ "$R" = "0|" ] && ok || bad "got '$R'" @@ -291,9 +120,59 @@ t "rosetta check stays quiet when the machine cannot be probed rather than cryin R="$(ros arm64 linux/amd64 2)" [ "$R" = "0|" ] && ok || bad "got '$R'" -t "rosetta check treats an unset STUDIO_PLATFORM as amd64, matching the compose default" +t "rosetta check treats an unset STUDIO_PLATFORM as native and stays quiet" +# The compose default is no pin now, so unset means "resolve from the manifest" — which is arm64 on +# Apple Silicon and needs no translation. Firing here would hard-exit every podman user on that host. R="$(ros arm64 "" 1)" -case "$R" in 1\|*[Rr]osetta*) ok ;; *) bad "got '$R'" ;; esac +[ "$R" = "0|" ] && ok || bad "got '$R'" + +t "rosetta check still fires on an EXPLICIT amd64 pin with no Rosetta" +R="$(ros arm64 linux/amd64 1)" +case "$R" in 1\|*) ok ;; *) bad "got '$R'" ;; esac + +# The retained check's whole message. +# +# Capture into a variable and match with a herestring, never `rosfull | grep -q`. Under the `set -o +# pipefail` at the top of this file, grep -q exits on first match, SIGPIPEs the writer, and the pipeline +# reports the WRITER's failure — so the condition reads FALSE precisely when the pattern matched. That +# silently inverted three assertions when they were first written. +rosfull() { # rosfull -> the whole stderr message for an explicit amd64 pin, arm64 host, no Rosetta + ( export DUPLO_ENV_FILE=/dev/null RUNTIME=podman STUDIO_PLATFORM=linux/amd64 + . ./scripts/_runtime.sh + _runtime_host_arch() { printf 'arm64'; } + runtime_rosetta_active() { return 1; } + runtime_rosetta_check 2>&1 >/dev/null ) +} + +t "the fix leads with removing the STUDIO_PLATFORM pin" +M="$(rosfull)" +if grep -q 'STUDIO_PLATFORM' <<<"$M" && grep -qiE 'remove|delete|unset' <<<"$M"; then ok +else bad "does not tell the user to drop the pin: $M"; fi + +t "the fix also covers a genuinely amd64-only tag, which unsetting cannot help" +M="$(rosfull)" +if grep -qiE 'rosetta' <<<"$M" && grep -qiE 'only|mirror|private' <<<"$M"; then ok +else bad "an amd64-only registry mirror is left with advice that cannot work: $M"; fi + +t "the fix no longer walks the user through containers.conf surgery" +M="$(rosfull)" +if grep -qE '^\s*\[machine\]' <<<"$M" || grep -qiE 'ADD this section|add a second|duplicate' <<<"$M"; then + bad "still carries the six-branch conf remediation" +else ok; fi + +t "the retired provider and conf-state helpers are gone" +LEFT="" +for f in _runtime_rosetta_conf_state _runtime_machine_provider _runtime_machine_conf_provider _runtime_machine_vmtype; do + grep -qE "^$f\(\)" scripts/_runtime.sh && LEFT="$LEFT $f" +done +if [ -z "$LEFT" ]; then ok; else bad "still defined:$LEFT"; fi + +t "the policy seams the check still needs are retained" +MISS="" +for f in _runtime_host_arch runtime_rosetta_active runtime_machine_check runtime_migrate_studio_platform; do + grep -qE "^$f\(\)" scripts/_runtime.sh || MISS="$MISS $f" +done +if [ -z "$MISS" ]; then ok; else bad "wrongly removed:$MISS"; fi t "builder ids are the caller's uid/gid on docker, but 0:0 on rootless podman" IDS="$( ( export DUPLO_ENV_FILE=/dev/null; . ./scripts/_runtime.sh; runtime_resolve 2>/dev/null @@ -422,6 +301,87 @@ else bad "no runtime announced next to 'Pulling images'"; fi t "builder_dispatch's error:runtime branch delegates to the message function" if grep -A4 'error:runtime)' scripts/_builder.sh | grep -q 'builder_runtime_message'; then ok else bad "the branch still inlines its own text, so none of the above applies to a real build"; fi +echo "studio platform is unpinned:" + +t "compose does not default the studio platform to amd64" +if grep -qE '^\s*platform: \$\{STUDIO_PLATFORM:-\}\s*$' docker-compose.yml; then ok +else bad "expected 'platform: ${STUDIO_PLATFORM:-}' (unset = resolve natively from the manifest)"; fi + +t ".env.example ships no live STUDIO_PLATFORM value" +if grep -qE '^STUDIO_PLATFORM=' .env.example; then + bad "still ships a live pin; existing users can never adopt a blank (run.sh skips blank example keys)" +else ok; fi + +# The migration takes its file paths as arguments so the policy is testable without touching a real .env. +mig() { # mig -> "|" + ( d="$(mktemp -d)"; printf '%s' "$1" > "$d/.env" + if [ "$2" = NONE ]; then rm -f "$d/.env.defaults"; else printf '%s' "$2" > "$d/.env.defaults"; fi + . ./scripts/_runtime.sh + NOTE="$(runtime_migrate_studio_platform "$d/.env" "$d/.env.defaults" 2>/dev/null)" + printf '%s|%s' "$(grep -c '^STUDIO_PLATFORM=' "$d/.env" || true)" "$NOTE" + rm -rf "$d" ) +} + +t "migration removes the old amd64 default when the lock agrees it was never hand-pinned" +R="$(mig 'STUDIO_TAG=x +STUDIO_PLATFORM=linux/amd64 +UI_TAG=y +' 'STUDIO_PLATFORM=linux/amd64 +')" +case "$R" in 0\|*) ok ;; *) bad "got '$R' (expected the line gone)" ;; esac + +t "migration leaves the rest of .env alone when it removes the line" +KEPT="$( ( d="$(mktemp -d)"; printf 'STUDIO_TAG=x\nSTUDIO_PLATFORM=linux/amd64\nUI_TAG=y\n' > "$d/.env" + printf 'STUDIO_PLATFORM=linux/amd64\n' > "$d/.env.defaults" + . ./scripts/_runtime.sh + runtime_migrate_studio_platform "$d/.env" "$d/.env.defaults" >/dev/null 2>&1 + tr '\n' ',' < "$d/.env"; rm -rf "$d" ) )" +[ "$KEPT" = "STUDIO_TAG=x,UI_TAG=y," ] && ok || bad "got '$KEPT'" + +t "migration keeps a hand-pinned STUDIO_PLATFORM the lock disagrees with" +R="$(mig 'STUDIO_PLATFORM=linux/arm64 +' 'STUDIO_PLATFORM=linux/amd64 +')" +case "$R" in 1\|*) ok ;; *) bad "got '$R' (must never undo a value the user chose)" ;; esac + +t "migration keeps the value when there is no lock to vouch for it" +R="$(mig 'STUDIO_PLATFORM=linux/amd64 +' NONE)" +case "$R" in 1\|*) ok ;; *) bad "got '$R' (cannot prove it was the tracked default)" ;; esac + +t "migration is a no-op when STUDIO_PLATFORM is already absent" +R="$(mig 'STUDIO_TAG=x +' 'STUDIO_PLATFORM=linux/amd64 +')" +[ "$R" = "0|" ] && ok || bad "got '$R' (should say nothing when there is nothing to do)" + +t "migration announces itself when it changes .env" +R="$(mig 'STUDIO_PLATFORM=linux/amd64 +' 'STUDIO_PLATFORM=linux/amd64 +')" +case "$R" in *STUDIO_PLATFORM*) ok ;; *) bad "silent .env edit: got '$R'" ;; esac + +t "the docs no longer carry the containers.conf / libkrun Rosetta walkthrough" +LEFT="$(grep -rliE 'LibKrunStubber|Vfkit\.Rosetta|rosetta-activation|provider = "applehv"' docs/ 2>/dev/null)" +if [ -z "$LEFT" ]; then ok; else bad "the applehv/libkrun deep-dive outlived the pin that needed it: $LEFT"; fi + +t "no doc still claims the studio image is amd64-only" +LEFT="$(grep -rniE 'studio image is amd64[- ]only|built amd64-only|amd64-only \(emulated\)' docs/ .env.example 2>/dev/null)" +if [ -z "$LEFT" ]; then ok; else bad "factually wrong now that every release publishes arm64: $LEFT"; fi + +t "configuration.md documents STUDIO_PLATFORM as unset-means-native" +if grep -E '\| *`STUDIO_PLATFORM`' docs/configuration.md | grep -qiE 'unset|native|manifest'; then ok +else bad "still documents a linux/amd64 default"; fi + +t "no doc still tells the user to set STUDIO_PLATFORM=linux/arm64" +LEFT="$(grep -rn 'STUDIO_PLATFORM=linux/arm64' docs/ 2>/dev/null)" +if [ -z "$LEFT" ]; then ok; else bad "arm64 is the native resolution now, not something to pin: $LEFT"; fi + +t "run.sh runs the platform migration before it adopts .env.example defaults" +MIGL="$(grep -n 'runtime_migrate_studio_platform' run.sh | head -1 | cut -d: -f1)" +ADOPTL="$(grep -n 'DEFAULT_KEYS=' run.sh | head -1 | cut -d: -f1)" +if [ -n "$MIGL" ] && [ -n "$ADOPTL" ] && [ "$MIGL" -lt "$ADOPTL" ]; then ok +else bad "migration at ${MIGL:-?}, adoption at ${ADOPTL:-?} -- must run first or the lock is rewritten under it"; fi echo "no stray hard-coded docker calls:"