Skip to content

feat(runtime): unpin the studio platform, retire the Rosetta machinery, and fix a native-build regression - #21

Open
duplo-darren wants to merge 4 commits into
duplocloud:devfrom
duplo-darren:feat/studio-platform-unpin
Open

duplo-darren wants to merge 4 commits into
duplocloud:devfrom
duplo-darren:feat/studio-platform-unpin

Conversation

@duplo-darren

Copy link
Copy Markdown

Follow-up to #18 — this is blocker 4 of that review, split out as it suggested: "land the runtime abstraction first and stack this on top … but it has to go in before we ship." #18 is now merged, so this goes up on its own against dev: 3 commits, 10 files.

What this changes

docker-compose.yml pinned the studio image to linux/amd64. That was correct when the image was amd64-only. It no longer is, and the pin is now actively harmful: on Apple Silicon it forces QEMU emulation when a native arm64 layer is already in the manifest — and QEMU cannot run the studio's .NET runtime, which is the failure the Rosetta preflight exists to diagnose. The pin was manufacturing the problem the machinery reports.

The premise, verified

Not forward-looking. The studio tag this kit pins today already carries both arches:

$ docker manifest inspect quay.io/duplocloud/backend:dev-1.0.6-45708c38 | grep -E '"architecture"'
              "architecture": "amd64",
              "architecture": "arm64",

(podman manifest inspect is identical.) So every Apple Silicon user on this tag has been emulating an image they could have run natively.

Four coupled parts — one commit, deliberately

Any subset is broken, which is why they land together:

Change Why it cannot land alone
1 platform: ${STUDIO_PLATFORM:-} The idiom BUILDER_PLATFORM already uses two lines down. Not linux/arm64 — a hard arm64 value breaks amd64 Linux.
2 .env.example pin → commented Alone, existing users keep linux/amd64 forever: the adoption pass skips keys the example ships blank (run.sh, [ -z "$ex" ] && continue).
3 _runtime.sh unset → return 0 The check treated unset as amd64 to match the old compose default. Left alone, dropping the pin hard-exits every Apple Silicon podman user on a false positive.
4 One-time .env migration The mechanism in 2 cannot propagate a blank, so the stale pin has to be removed explicitly.

The migration

runtime_migrate_studio_platform removes STUDIO_PLATFORM from a user's .env, and only when it is provably untouched:

.env holds linux/amd64  AND  .env.defaults agrees that was the last-applied default  →  remove
anything else                                                                        →  leave alone

A hand-pinned value is never overridden. When the migration declines, runtime_rosetta_check explains the situation instead — so nobody is silently stuck. It is idempotent, silent unless it changes something, and takes its paths as arguments so the policy is testable without a real .env. Verified against a real 193-line .env: one line removed, remainder byte-identical, second run silent.

What is deleted

_runtime_rosetta_conf_state, _runtime_machine_vmtype, _runtime_machine_conf_provider, _runtime_machine_provider, and the six-branch remediation with its libkrun/applehv diagnosis. scripts/_runtime.sh goes 671 → 540 lines. troubleshooting.md's Apple Silicon entry goes 206 → 71 lines, and its Rosetta/libkrun mentions 45 → 4.

Kept: _runtime_host_arch and runtime_rosetta_active — the two impure seams that let the policy be tested without a VM. runtime_rosetta_check survives at ~25 lines, firing only on an explicit amd64 pin, and says two things: remove the stale pin (nearly every case), and — because unsetting cannot help someone whose registry genuinely carries one arch — that such a tag does still need Rosetta, via Docker Desktop or an applehv machine. That second branch is why the check survives rather than going with the machinery.

Untouched: runtime_machine_check, the undersized-VM preflight. Unrelated to any of this.

Blast radius

  • .env is written — the only user-data change here. Guarded as above; the pre-migration value is recoverable from .env.defaults.
  • STUDIO_PLATFORM semantics invert: unset now means native, where it used to mean amd64. Anyone with tooling that reads it should know.
  • Docs: the sweep was wider than the review listed — faq.md, configuration.md and upgrading.md carried the same stale "amd64-only" claim. Four tests fail if any of it returns.
  • .env.example: STUDIO_PLATFORM stays in DEFAULT_KEYS but ships no value, so it is skipped while blank. A future release can re-pin it with no code change; upgrading.md records this.

Testing

65 runtime tests, 135 across the suite, all passing on top of current dev. The 16 conf-state, provider and remediation-message cases go with the code they covered; new ones pin the retained check's two branches, the compose default, the .env.example state, and six migration cases.

Both migration guards are mutation-tested — loosening the value check or the lock check each makes a specific test fail, so neither passes by accident.

Verified on real hardware: a libkrun podman machine, which the old code hard-failed with a recreate-the-VM instruction, now passes silently because the arm64 image needs no translation.

Note for reviewers

This deletes the function fixed by "match a [machine] header that carries a trailing comment" in #18. That fix is not wasted — #18 shipped first and covers anyone who hits it in the interim — but reviewers of both PRs will notice the add-then-delete.

🤖 Generated with Claude Code

duplo-darren and others added 3 commits October 1, 2026 13:50
Every studio release now publishes amd64 and arm64, so pinning the platform is no longer correct: the
pin at docker-compose.yml:61 was the only one in the stack, and on Apple Silicon it forced QEMU
emulation when a native arm64 layer was already available in the manifest. Verified against the
currently pinned tag, which is an OCI index carrying both arches.

Four changes that have to land together, because any subset is broken:

  1. docker-compose.yml — `platform: ${STUDIO_PLATFORM:-}`, the idiom BUILDER_PLATFORM already uses
     two lines down. No pin, not linux/arm64: a hard arm64 value would break amd64 Linux.
  2. .env.example — the live pin becomes a commented-out key. Unset is now the correct value.
  3. scripts/_runtime.sh — runtime_rosetta_check treated an unset STUDIO_PLATFORM as amd64 to match
     the old compose default. Left alone, dropping the pin would make it hard-exit with a false
     positive on every Apple Silicon podman user. Unset now returns 0.
  4. run.sh — a one-time migration removes the stale pin from existing .env files. The adoption
     mechanism cannot do this itself: it skips keys the example ships blank, so blanking the key would
     leave every existing user on amd64 forever.

The migration is deliberately conservative. It removes the line only when .env still holds
linux/amd64 AND .env.defaults agrees that was the last-applied default — i.e. the user never touched
it. Anything else is left exactly as found, and runtime_rosetta_check explains the situation instead
of overriding a deliberate choice. It takes its paths as arguments so the policy is testable without a
real .env, and it is silent unless it changes something.

Tests: 52 (was 45). Both migration guards were mutation-tested — loosening either the value check or
the lock check makes a specific test fail, so they are not passing by accident.

Reported in review of duplocloud#18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the studio platform unpinned, the advice this subsystem generated stops being the advice we want
to give. Once an arm64 layer resolves natively, the correct remediation is "remove the stale pin", not
a walk through containers.conf — so ~150 lines that were correct are now actively misleading, pointing
Apple Silicon users at Rosetta they no longer need.

Deleted: _runtime_rosetta_conf_state, _runtime_machine_vmtype, _runtime_machine_conf_provider,
_runtime_machine_provider, and the six-branch remediation with its libkrun/applehv provider diagnosis.
scripts/_runtime.sh drops from 651 lines to 489.

runtime_rosetta_check keeps its purpose and loses its bulk. It fires only on an EXPLICIT amd64
STUDIO_PLATFORM, and says two things: remove the pin (the stale-.env case, which is nearly all of
them), and — because unsetting cannot help someone whose registry genuinely carries amd64 only — that
such a tag does still need Rosetta, via Docker Desktop or an applehv machine. That second branch is
why the check survives at all rather than going with the machinery.

Retained: _runtime_host_arch and runtime_rosetta_active, the two impure seams that let the policy be
tested without a VM. runtime_machine_check is the undersized-machine preflight and is unrelated to any
of this; it is untouched.

Tests: 41 (was 52). The 16 conf-state, provider and remediation-message cases go with the code they
covered; 5 new ones pin the retained check's two branches and assert the deleted helpers are actually
gone rather than merely unreferenced. The pipefail/`grep -q` warning is preserved on the new helper —
it lived in a block this commit deletes, and the bug class it documents outlives the tests.

Note: this deletes the function fixed in the [machine]-trailing-comment commit on duplocloud#18. That fix still
matters — it ships first and protects anyone who hits it in the interim.

Reported in review of duplocloud#18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… platform

The docs described a world where the studio image was amd64-only and Rosetta was a setup step. Both are
now wrong, and wrong in the expensive direction: they send Apple Silicon users to configure Rosetta they
do not need, for emulation that no longer happens.

  troubleshooting.md   The "hangs on Waiting for studio" entry goes from 206 lines to 71, and from 45
                       Rosetta/libkrun mentions to 4. The symptom, the misleading "Login failed" and the
                       QEMU/.NET explanation are all kept — they are still exactly what you see. What
                       goes is the containers.conf walkthrough, the applehv measurement table, the
                       libkrun provider deep-dive and the rosetta-activation.service forensics. The fix
                       is now "remove the pin", with the single-arch-registry case noted after it.
  prerequisites.md     Apple Silicon setup goes from three requirements to one: size the machine. Says
                       plainly that Rosetta is not required.
  configuration.md     STUDIO_PLATFORM's default is *(unset)*, not linux/amd64.
  faq.md               Apple Silicon runs natively rather than emulated.
  upgrading.md         STUDIO_PLATFORM dropped from the tracked-keys list, with a note that it stays
                       tracked in code but is skipped while the example ships no value.
  run.sh               The preflight's comment described the old trigger; it now says the check fires
                       only on an explicit amd64 pin.

Four tests keep the docs honest rather than trusting this sweep: no libkrun/applehv walkthrough survives
anywhere under docs/, nothing still claims the image is amd64-only, configuration.md documents
unset-means-native, and nothing tells the user to pin linux/arm64 — which is now the thing that resolves
by itself. Tests: 45.

Reported in review of duplocloud#18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RUNTIME=podman in .env breaks every containerized extension build, regardless of extension — reported
with a full root-cause trace and a live rebuild confirming both the failure and the fix.

build-extension.sh re-invokes itself inside the builder container to do the actual dotnet/npm work.
docker-compose.yml's builder service sets DUPLO_BUILD_NATIVE=1 in its environment for exactly this
re-entrant call — nesting a container runtime inside the build container isn't supported, and
builder_mode() already has a documented branch for it. But builder_dispatch() called runtime_resolve
unconditionally, before ever checking DUPLO_BUILD_NATIVE. Since the whole repo (.env included) is
bind-mounted into the container at /work, the inner invocation's _envv lookup read RUNTIME=podman
straight off the mounted file, runtime_requested() returned non-empty, and the fallback treated that as
an explicit pin and fataled — before builder_mode()'s want_native=1 short-circuit ever got evaluated.

RUNTIME=docker, the default, usually masks this: docker is typically also on the host's PATH, so the
same resolve call that should be skipped happens to succeed by accident. Same latent bug either way.

Fix: guard the runtime_resolve call with the already-computed want_native flag, so a forced-native
build — which the container always is — skips runtime resolution entirely instead of racing it. No
behavior change on the host-side (outer) dispatch, where want_native is unset by default and the
existing resolve-or-die logic still runs.

Verified both ways: reproduced the exact reported error in isolation against the unpatched code (same
message, same exit 1), confirmed the fix resolves it, and confirmed the host-side path — a developer
who names a runtime they genuinely don't have — still fatals correctly rather than silently going
native. Also verified end-to-end: rebuilding extensions/soc2-posture-extension with RUNTIME=podman set
succeeds after the fix, fails identically before it.

Tests: 5, new file (none existed for _builder.sh before). Confirmed RED against the unpatched code —
exactly the forced-native/unresolvable-RUNTIME case fails, nothing else — then GREEN with the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@duplo-darren duplo-darren changed the title feat(runtime): unpin the studio platform and retire the Rosetta machinery feat(runtime): unpin the studio platform, retire the Rosetta machinery, and fix a native-build regression Oct 5, 2026
@duplo-darren

Copy link
Copy Markdown
Author

Also includes a fourth commit, 7b18fe7: a fix combined in here from #24 (closed as a duplicate of this PR) — builder_dispatch() was calling runtime_resolve unconditionally, before checking DUPLO_BUILD_NATIVE, so RUNTIME=podman in .env broke every containerized extension build regardless of extension (the builder container bind-mounts the whole repo, so the re-entrant build re-reads the host's .env and fatals on a runtime it never needed). Unrelated to the platform-unpin work above it, but zero file overlap with it and no review activity on either PR yet, so combining avoided a second PR for no real benefit. Full details in #24's original description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant