Skip to content

refactor(cli): put the container runtime behind one interface - #5423

Merged
yannickmonney merged 6 commits into
mainfrom
refactor/incus-runtime-seam
Nov 12, 2026
Merged

yannickmonney merged 6 commits into
mainfrom
refactor/incus-runtime-seam

Conversation

@yannickmonney

Copy link
Copy Markdown
Contributor

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:

  • The CLI talks to the container runtime through one ContainerRuntime interface. Docker is the only implementation for now. An unknown runtime.engine is refused.
  • The managed deployment checks read the runtime through a single runtime access, so they no longer call Docker themselves.
  • The Incus REST client moves out of the sandbox into a new package, @tale/incus, which also ships an in-memory fake. The sandbox spawner's Incus backend uses that package.
  • The CLI can read image facts (digest, platforms, OCI labels) straight from the registry, without a Docker daemon.
  • bun run gen:manual adds the manual-test layer to an existing unit that has none. The CLI gets its own layer this way, tools/cli/tests/manual/, including an incus-runtime suite.
  • .agents/repo.md records that the Incus runtime program ships on main, as the one exception to the stable line taking fixes alone, because the feature line is not yet stable.

What this does not do

  • No Incus runtime implementation and no engine factory yet. The managed modules still build their access with the Docker runtime. That work is M3.
  • The design's later interface verbs are not here yet: freeze/thaw, snapshot/restoreSnapshot, ensureImages/inspectImage, execSession, and logs as a stream.
  • runtime.engine is not declared or documented as a user setting yet.
  • No image builds for Incus.

Lanes (one commit each, on main at b682881)

  • TI-02 941489db5f: refactor(sandbox): move the Incus client into @tale/incus
  • TI-04 f608d5cb4e: refactor(cli): put the container runtime behind one interface
  • TI-05 426e73e82d: refactor(cli): give managed deployment checks one runtime access
  • TI-09 6863d0736e: feat(cli): read image facts straight from the registry
  • TI-21 7a902905e2: feat(plop): scaffold a manual-test layer into an existing unit
  • Docs d047e80f5d: docs: ship the Incus runtime program on the stable line

How it was tested

These runs were on a Linux lab VM at d047e80f5d, with bun install --frozen-lockfile first and NODE_OPTIONS=--max-old-space-size=8192 set, as in CI.

  • turbo lint, typecheck and test with --continue: lint and typecheck are green in every workspace. Two test tasks are red, and a run on a clean main (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:links and the scripts/performance tests (8 pass) pass.
  • bun run lint:manual passes: 6 trees and 2053 boxes, now including tools/cli/tests/manual.
  • The docs, platform, sandbox, session-manager, ui-docs and web images build.

CI ready on this pull request is the final gate.

Supersedes #5422

This supersedes #5422. It has the same lanes, moved from release/0.6 to the stable line, main.

Next milestones

  • M2: images
  • M3: managed Incus (the Incus runtime implementation and the engine factory)
  • M4: workspace

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
yannickmonney force-pushed the refactor/incus-runtime-seam branch from d047e80 to f1768be Compare November 12, 2026 17:41
@yannickmonney
yannickmonney merged commit 87757a8 into main Nov 12, 2026
113 of 115 checks passed
@yannickmonney
yannickmonney deleted the refactor/incus-runtime-seam branch November 12, 2026 18:15
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