Repository navigation
refactor(cli): put the container runtime behind one interface - #5423
Merged
Merged
Conversation
Every action that touches the stack's instances, images, volumes or networks now asks a StackRuntime instead of calling lib/docker itself. selectRuntime picks it once per command from tale.json: Docker unless runtime.engine is "incus", which is refused until the Incus runtime exists; an engine the CLI does not know is refused, never read as Docker. The Docker runtime is a facade over today's lib/docker functions with the same argv; the SIGINT cleanup of deploy moves into it as onInterrupt. An instance readback carries an allow-list of non-secret environment keys and never the whole environment, and copyOut covers a copy out of a container. FakeRuntime models a stack in memory. The shared contract suite runs it and the Docker runtime over a fake docker command line; a sentinel secret never reaches status, logs or a readback. A guard records the argv of a whole blue/green deploy and holds it equal to the argv the CLI sent before the interface.
Acceptance, observation, native provisioning, the credential export, the spawner's control, the runtime configuration and the automation writer cutover no longer build docker argv. They ask a RuntimeAccess (lib/deployment/runtime-access.ts): list, inspect and inspectImage, exec, execSession, copyIn, copyOut, stop and restart, plus the two census reads the cutover needs (instances with their service, volumes). The Docker access is the one place that knows the argv, and it keeps each caller's argv and environment. It parses docker inspect into normalized instance and image records; each check still states what it demands of them, so its refusals stay as they were. The cutover receipt keeps its database record under the same keys. A container readback carries no environment entry but DATABASE_URL. Each check hands its bounds over as a RuntimeBudget (deadline, byte budget, fixed sentences), applied to every call, so another runtime can honour them. The managed runtime's own steps still run through runtimeCommand; runtime-apply.ts is untouched. Two census reads are spelled once: observation's ps --all is now -a, and the protocol-1 database check filters by project, then service. Docker reads both the same way.
The managed path learns what an image is from `docker image inspect` after a `docker pull`, so preparing a bundle needs a Docker daemon. A runtime without Docker has none, and it must apply the image's entrypoint, command, environment, user, working directory, stop signal, health check and volumes itself. lib/oci/registry.ts reads an image from its OCI distribution registry alone. A tag resolves to its index digest, the index yields the platform's manifest (attestation entries never match; arm64 and arm64/v8 are one platform), and the manifest yields the config blob. Every byte is checked against the sha256 digest that names it, and a tag's bytes against the digest the registry announces, before anything is parsed. Pulls are anonymous: a bearer challenge is answered with an anonymous pull token per repository, redirects are followed by hand so the token never leaves its origin, and plain HTTP is spoken to loopback registries only. A repository the registry will not open to an anonymous reader (GHCR denies the token for a missing or private one) is refused as not public. A missing tag, digest or platform raises ImageNotFoundError, as the managed path treats the not-found answers of `docker pull` alike. docker.io can be read through a mirror host (registryMirror). Verified manifests and configs are kept by digest under .tale/oci/blobs/sha256/, so a digest-pinned reference reads offline once seen; an entry whose bytes no longer match is fetched again, and a tag is always asked again. lib/oci/image-facts.ts turns the config into ImageFacts: the index, manifest and config digests, os, architecture, the labels with the revision, the automation writer protocol and dev.tale.init, Entrypoint, Cmd, Env, User, WorkingDir, StopSignal, Healthcheck and Volumes. The tests run against a Bun.serve fixture registry in GHCR's shape: index, manifest and config reads; a tampered config, manifest, tag and cache entry refused, the config, manifest and cache entry at their published length so that only the digest can tell; a missing platform reported as not found with the ones present; a denied token; a blob redirect that must not carry the token; the mirror and the cache. TALE_REGISTRY_LIVE=1 adds live reads of tale-proxy and of a repository GHCR denies from ghcr.io, and of the bgutil provider through mirror.gcr.io. Nothing calls the reader yet; the Incus runtime's image verbs and deploy prepare take it up.
The manual layer was emitted only by the service generator, so a unit that existed before it had no way to acquire one but by hand. The CLI, which owns deployments end to end, had none. - `bun run gen:manual <unit> none` renders the shared layer into an existing workspace's tests/manual. The list of shared files and the actions that render them move to generators/manual.ts, and the service generator imports them: one layer, one list. The unit must be a workspace with an @scope/name package and no tests/manual yet; its package names the commands the layer prints. The generator closes with the steps lint:manual still asks for: the first suite, and the readme's suites table. - The service generator now gives the layer its package scope, so a new service's setup.md says `bun run --filter @tale/<name>`, not `/<name>`. - tools/cli/tests/manual is scaffolded with it and written for a command-line unit (the compiled binary on real hosts, a host snapshot as the reset), with the suite header incus-runtime.md (prefix INC-, no boxes yet): each change that ships an Incus behaviour adds its boxes with it. - .agents/repo.md: six manual layers, and gen:manual among the generators. Tests: manual.test.ts runs both generators' own actions through the real lint:manual; templates.test.ts checks that every gen:<x> script reaches a registered generator.
The program that runs Tale on Incus lands on main, as the one exception to the stable line's fixes alone, because the feature line is not yet stable.
yannickmonney
force-pushed
the
refactor/incus-runtime-seam
branch
from
November 12, 2026 17:41
d047e80 to
f1768be
Compare
This was referenced Nov 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
M1 of running Tale on Incus beside Docker Compose, on the stable line. Docker Compose stays the default, and nothing here changes what a Docker deployment does. M1 adds the seam that an Incus backend will plug into:
ContainerRuntimeinterface. Docker is the only implementation for now. An unknownruntime.engineis refused.@tale/incus, which also ships an in-memory fake. The sandbox spawner's Incus backend uses that package.bun run gen:manualadds the manual-test layer to an existing unit that has none. The CLI gets its own layer this way,tools/cli/tests/manual/, including anincus-runtimesuite..agents/repo.mdrecords that the Incus runtime program ships onmain, as the one exception to the stable line taking fixes alone, because the feature line is not yet stable.What this does not do
runtime.engineis not declared or documented as a user setting yet.Lanes (one commit each, on
mainat b682881)941489db5f: refactor(sandbox): move the Incus client into @tale/incusf608d5cb4e: refactor(cli): put the container runtime behind one interface426e73e82d: refactor(cli): give managed deployment checks one runtime access6863d0736e: feat(cli): read image facts straight from the registry7a902905e2: feat(plop): scaffold a manual-test layer into an existing unitd047e80f5d: docs: ship the Incus runtime program on the stable lineHow it was tested
These runs were on a Linux lab VM at
d047e80f5d, withbun install --frozen-lockfilefirst andNODE_OPTIONS=--max-old-space-size=8192set, as in CI.lint,typecheckandtestwith--continue: lint and typecheck are green in every workspace. Two test tasks are red, and a run on a cleanmain(b682881) gives the same failures:@tale/cli#test: 4090 pass, 1 fail. The one failure, "cached site plans preserve Compose overrides, environment interpolation and image identity", fails the same way on the base, which has 4001 passing tests.@tale/session-manager#test: the fake-timers harness test fails (1 of 2771), as on the base.format:check,knip:check,lint:linksand thescripts/performancetests (8 pass) pass.bun run lint:manualpasses: 6 trees and 2053 boxes, now includingtools/cli/tests/manual.CI ready on this pull request is the final gate.
Supersedes #5422
This supersedes #5422. It has the same lanes, moved from
release/0.6to the stable line,main.Next milestones