Skip to content

feat(cli): run a managed deployment on Incus - #5427

Merged
yannickmonney merged 16 commits into
mainfrom
feat/incus-managed
Nov 13, 2026
Merged

yannickmonney merged 16 commits into
mainfrom
feat/incus-managed

Conversation

@yannickmonney

Copy link
Copy Markdown
Contributor

What M3 does

A deployment whose specification declares runtime.engine: "incus" can now be prepared, applied and run as Incus instances on a single Incus node, on the stable 0.5.x line, with Caddy as the edge. No Docker daemon is needed on that node. The CLI:

  • compiles the compose stack into an Incus target: images by digest, volumes, instances, resolvers and the colour switch
  • plans and converges that target with readback, a journal and drift detection
  • drives the instances (exec, copy, logs, health, one-shot helpers) over the Incus REST API
  • describes, checks and bootstraps the node (tale incus contract|check|bootstrap)
  • applies a managed deployment through tale deploy prepare and tale deploy --bundle

Local developer loops (tale dev, sandbox connect) stay on Docker. tale doctor checks Incus on an Incus workspace. A CI workflow runs end-to-end tests on a real Incus node. The new docs page "Run Tale on Incus" is in en/de/fr.

The runtime is experimental. Docker stays the default, and Docker deployments behave as before. The M2 images (#5424) run unchanged under Compose.

What M3 does not do

  • A changed Incus deployment that already exists is refused: the recovery snapshot and upgrade window come with TI-16. accept --origin-container cannot capture an Incus instance. There is no backend drain and no legacy protocol cutover on Incus.
  • The workspace engine incus is still refused by selectRuntime. tale start and tale stop, the status and doctor crash wiring, and the colour-stack listing come with M4 (TI-17). Until then, the docs and the warnings advise a manual incus stop or incus start.
  • The accept and observe end-to-end suites are todo, and so is the upgrade window. export-client is off by default because of a native-client identity defect, which looks engine-independent.
  • Open design points carried from the lanes:
    • pools are checked only through the instances' disks
    • the rule for a missing mount point: the lane creates the volume with a warning, while D-27 says refuse
    • resolver records are written in place instead of R3-29's generations, because of a measured dnsmasq 2.90 dedup defect
    • a conf-only resolver change applies at the next resolver start
    • a data volume that a later release adds counts as missing
    • a Frozen instance is read as not running
    • the compiler's per-service limit defaults still need a design review

Lanes (one commit each)

  • TI-03 eda7d452b5 feat(cli): extend the Incus client for a whole-stack runtime
  • TI-06 2bf8ae02d7 feat(cli): declare the container runtime of a deployment
  • TI-08 128321443b feat(cli): compile the compose stack into an Incus target
  • TI-10 d4060d9f83 feat(cli): plan and converge an Incus stack with readback
  • TI-11 249b239fdb feat(cli): create and stop stack instances on Incus
  • TI-12 0e3de1b679 feat(cli): exec, copy, logs and health checks on Incus
  • TI-13 0ea74946cf feat(cli): resolve stack names and switch colours on Incus
  • TI-14 95f4e54a48 feat(cli): describe, check and bootstrap an Incus node
  • TI-15 62625cc1df feat(cli): apply managed deployments on Incus
  • TI-19 c2804b2c4a feat(cli): keep local developer loops on Docker
  • TI-20 aa77ec10ad ci(e2e): prove the CLI's Incus runtime on a real Incus node
  • TI-22 670f67e6f8 docs: run Tale on Incus

The chain was reviewed on top of the M2 branch. After #5424 was squash-merged (5adcf24), the chain was rebased onto main without conflicts. Compared with the reviewed tip, the tree differs only by #5424's CRLF tweak in compose-parity.test.ts.

Tests and proofs

Integration gate on a Linux CI VM at 670f67e, Node 22.21.1:

  • release-candidate contract unchanged
  • bun run check --continue: format, conflicts and links pass; turbo 55 of 57 tasks pass
    • @tale/cli 4598 pass, 69 skip, 1 fail. The failure is cached site plans preserve Compose overrides: the VM has no Docker Compose plugin. The same test fails on clean main 5adcf24 on that VM.
    • @tale/session-manager: the harness.test.ts timing test failed under load. It passes alone three times out of three on this branch, and passes on clean main. M3 does not touch services/session-manager.
    • @tale/incus passes with 0 fail. Platform, sandbox and the other workspaces pass.
  • lint:manual, knip:check and lint:sast pass (0 findings)
  • Six stack images build from this branch: db, platform, proxy, sandbox-egress, sandbox and sandbox-llm-gateway. They were removed after the build.

From the lanes (Incus 7.6 lab node):

  • real @tale/incus tests: 12 pass
  • real Incus runtime verbs (TI-11): 49 pass
  • real exec, logs and health (TI-12): 12 pass, 1 skip
  • resolver and switch flips verified live (TI-13)
  • tale incus contract, check and bootstrap: 47 pass (TI-14)
  • the end-to-end Incus suites (TI-20): 20 pass, 1 skip (export-client), 3 todo (accept, observe, upgrade window)
  • each lane ran mutation tests on its key code paths, and each mutation was caught

Not yet observed:

  • a live end-to-end run of prepare, deploy --bundle, accept and observe with the released images (TI-15)
  • a CI dispatch of cli-incus-e2e.yml, which can only run after this push

What follows

M4, the workspace: tale start and tale stop, Incus as a workspace engine (TI-17), the recovery snapshot and upgrade window (TI-16), and the remaining docs (TI-24).

@tale/incus gains what the CLI's Incus runtime drives a whole stack
with, each call a module of its own over the client's bounded transport,
so the sandbox spawner loads none of them:

- exec: mode 1 (exit code) and mode 2 (record-output read under a byte
  cap, then deleted); stdin pushed as a 0600 file of at most 1 MiB in
  /dev/shm and redirected in by the command's shell, never argv; the
  image's timeout wraps the command when the caller names it, since
  Incus cannot cancel a record-output exec.
- exec-session: mode 3, a websocket session over the same pinned mutual
  TLS (fds 0/1/2 and control), stdin of any size, ended by closing the
  control socket.
- files: the instance and custom-volume file APIs with owner and mode,
  every write as application/octet-stream, and no rename.
- volumes, snapshots, state: rename (detached only, waiting out the
  mount the file API leaves behind), attach and detach of a disk under
  an ETag, pool resources, volume snapshots and a copy from one, start,
  stop with a grace or forced, freeze, unfreeze, instance and project
  state.
- client: operation cancel, the console and log files, image alias
  create/list/delete and image delete, and a refusal of a plain-HTTP
  endpoint without a transport of its own.
- unix-transport: a client over the local socket, for bootstrap only.

The fake answers every one of them, a websocket exec through a loopback
Bun.serve. tests/real runs the client against a real Incus in a
restricted project (TALE_INCUS_REAL=1, TALE_INCUS_DIR; `test:real`).
tale.json's runtime and the managed specification's runtime.engine and
runtime.incus now share one schema (lib/project/runtime-declaration.ts),
which select-runtime reads through readProject: engine docker (the
default) or incus with its Incus block (endpoint, project, pool, network
with subnet, gateway and block, sessions subnet, ingress, registry
mirror, root size, volumes with snapshots.* and zfs.* settings, and
operatorReach). A workspace needs a /24 block; a managed deployment takes
a /24 or a /25. Unknown engines, a block without its engine and the
engine without its block are refused, never read as Docker. TALE_RUNTIME
only guards the declared engine.

In the managed specification every text value of the Incus block may be
a literal or an environment reference that preparation resolves. With engine incus,
runtime.containerPrefix is refused and sandbox is required as
{ backend: incus } without an address, keeping a declared egressProxy.
runtime.json gains runtime: incus and pins incus-stack.json exactly then,
so older CLIs refuse such a bundle, and verify-bundle requires
runtime/incus-stack.json exactly for an Incus specification.

Running a workspace or a managed deployment on Incus is refused as "not
available yet" at selection, prepare and apply, until the lanes that
build it lift the refusal.

The Incus credentials loader reads client.crt, client.key and server.crt
from .tale/incus (or TALE_INCUS_DIR) or <stateDirectory>/incus under the
custody check the spawner credentials already use (now shared), and
validates them with @tale/incus readIncusTlsMaterial.

tale init --runtime and the --incus-* flags write the block, checked
before anything is scaffolded; a re-init and tale update keep it, and a
re-init stops before it writes over a block this CLI cannot read. Docs
in English, German and French; INC-F1, INC-F2 and INC-B1 and an
automation row in the CLI's manual layer.
The Incus runtime reads no compose file, so a pure compiler turns the
compose documents Docker Compose runs into an Incus stack target
(lib/runtime/incus/compile): images by digest alias, data and
runtime-owned volumes, the files the runtime writes onto them, and every
instance with its slot address, a CIDR NIC with the gateway and no IPv6,
oci.entrypoint behind the patched /init rewriting the halt signals to the
stop signal (a leading tini dropped), environment, limits.memory and
limits.processes on every instance, start priorities and the three stop
groups (90, 50, 10), oci.dns.nameservers (the gateway for the resolvers,
names for the rest), user.tale.* labels, health check and stop contract.
It adds the resolvers and the switch, the resolver records in a stack
and a session view, the switch's upstream lists over every slot of both
colours (blue alone on a managed /25) and the ingress.

The generators gain build*Compose(), which return the document;
generate*Compose() stringifies it as before. The managed path compiles the
bundle's compose.yml.

Every ComposeService key has one rule (map, ignore with its reason, or
refuse), and rules-coverage.test.ts fails on a key without one. Each
refusal carries a sentence and has its own test; the managed database's
loopback publishes and the Docker sandbox shape's mounts and ports are
ignored with their reasons, and the spawner always gets the Incus shape.
A value in a shape the translator does not read (long-syntax ports, a
mapping of cap_add or extra_hosts) is refused, never dropped.
The placement table (address, start and stop priority, the images
accepted without the init) is held to every generated document and
compose.yml. The address plan takes a /24 for a workspace and a /25 for a
managed deployment and refuses more replicas than a role's 16 slots.

Interpolation and the .env grammar follow Docker Compose (${V}, :-, -,
:?, ?, nested; any other operator refused), from the run's process
snapshot over .env for a workspace and from .env alone for a managed
deployment; a refused .env line is named by its number, never its text.
interpolate-oracle.test.ts holds both to docker compose
config under env -i for every generated document and compose.yml; it
skips on Windows and without Docker Compose, except on a Linux CI runner
with Docker.

Goldens: stateful.json, colour-blue.json and managed-compose.json, each
with no refusal.
The compiled stack target now has a planner, an applier, a journal and
a readback (lib/runtime/incus/plan and apply), the configuration flow's
validate, plan, apply and read back over an Incus project.

The plan (incusPlanSchema, strict like configurationPlanSchema) holds
digests and revisions only: its target, a phase, the destination
(endpoint, pinned server certificate, project, pool, architecture) and
one resource per image, volume, file, instance and ingress, each with
an action (create, replace, update, delete, unchanged, deferred) and
its effects (downtime, drain-sandbox, drain-backend, data, names,
switch). Digests are HMAC-SHA256 under a per-stack key in
.tale/incus/plan.key, made 0600 once. The planner is pure and refuses an
instance or a volume that another stack labelled, or that carries no
label. A running stop-gated service (db, object-store, proxy) keeps
running until a deploy with --stop, as on Docker; the switch and the
resolvers defer an image change alone, and are replaced behind --stop
when their running image does not read the file format the CLI writes.
The switch's upstream lists are part of the switch and written only
when it is made, so a flip, a roll or a scale change rewrites none.
A missing data volume of a stack that ran before (a deploy record in
backups, an instance of the stack, a manifest, a pre-restore holder)
is the data effect; only a first deploy, or the first after a recorded
reset, makes one.
An instance is compared without the oci.cwd, oci.uid and oci.gid that
Incus fills from its image, and a volume on the keys the target sets
alone, not the pool's volume.* defaults Incus copies into it, so a
converged stack on a real Incus plans nothing.

The applier converges in order: images, volumes, files, the instances
tier by tier (made stopped, started, health-gated before their
dependants), the superseded resolver records, the switch, the ingress,
the orphans last. Instance PATCHes carry the planned ETag; where it
moved, or Incus gives none, the resource is read again and compared
with the plan (adopted when already as wanted, refused when changed
otherwise). downtime needs --stop, and data is never applied. It
writes user.tale.want on every start and stop, withdraws a switched
replica and drains the spawner and backends before they stop, writes
resolver records and the deploy record (backups/stack.<gen>.json) under
a new name before it deletes the old, and journals each step in
.tale/incus/runtime.json: pending, ready or interrupted (on SIGINT),
exec payload files in /dev/shm, withdrawal markers, and
restore-pending, which stops converge, start, deploy, rollback,
services and migrate with a sentence naming tale restore --resume and
--abort. The readback gives InstanceInfo with the allow-listed
environment and a keyed digest, never a secret.

Pulling an image, the health check, draining and withdrawing are hooks
the verbs lanes fill. Tests against the @tale/incus fake cover every
action and effect, compare-and-swap conflicts, the gates, the ingress,
the missing-data-volume rule and the restore-pending refusals, and a
crash after every write of a first deploy and of an upgrade with
--stop, after which the next run converges to the same holdings and
plans nothing.
The Incus runtime answers its first verbs: the probe, the images, the
volumes and the instances (lib/runtime/incus/incus-runtime.ts and
verbs/), over the restricted stack project's REST API.

The probe reads GET /1.0, the project, the tale-stack profile and the
pool. It requires the API extensions by name (instance_oci and its
entrypoint, oci_network_config, storage_shifted, storage_initial_owner,
file_storage_volume, the restricted project's snapshots and image
servers, instance_auto_restart; not instance_port_forward), a single
server, an architecture Tale ships images for, idmapped mounts (refused
when the kernel has none, or on a ZFS older than 2.2), a project with
its own images, profiles and volumes, a tale-stack profile that sets
limits.memory and limits.processes whenever the project limits them,
and every registry host in restricted.images.servers exactly as the
remote URL carries it. A pool without quotas warns.

Images are pulled by digest into the project under
tale-<repository>-<32 hex>; an alias the project holds is kept, a failed
pull does not stop the others, and the pull order is kept in
.tale/incus/images.json so pruneImages keeps the current image, the one
before it, and any an instance runs. What only an image's filesystem
says is read once per digest through a stopped probe instance and its
file API, then cached in .tale/incus/image-facts.json: the owner and
mode of each mount point, the uid and gid of the app user, where
timeout sits on the image PATH, and dev.tale.infra-formats.

Volumes are shifted, labelled and made with their mount point's owner as
initial.uid/gid/mode. A mount point the image lacks makes a root-owned
volume with a warning, as Docker makes one there (main's platform and
sandbox images hold no /app/static-assets and /app/platform-config); a
path the image holds as a file is refused, and so is a stack whose
volumes sit on another pool than the declared one.

Instances are made stopped from their spec, started with user.tale.want
running and user.tale.started set from the state's started_at, so a
later started_at reads back as a restart Incus made. A stop waits its
grace and is then forced with a warning; 'forever' waits the declared
grace and never forces. Label writes wait out a start or stop Incus is
running. Remove, parallel freeze and thaw, the census and an
InstanceInfo readback follow, and provePid1 compares /proc/1/cmdline
with oci.entrypoint. The applier starts and stops through the same
verbs.

StackRuntime gains ensureImages (Docker: every pull, then the tags, the
argv unchanged) and inspectImage (the registry reader on both runtimes).
runtimeForEngine builds the Incus runtime from a connection; a workspace
that declares incus is still refused. The verbs the Incus runtime does
not answer yet refuse in words. The in-memory Incus reports
architectures, clustering, storage drivers, started_at and what an
image holds.

Proven on Incus 7.6 (TALE_INCUS_REAL=1 bun test
src/lib/runtime/incus/verbs): tale-proxy 0.5.105 pulled by digest and
kept on the second ensure, its /data owner read and given to a new
volume, an instance made, started, read back, stopped in 0.7 s and
removed; PID 1 is lxc-init without /init and the full /init argv with
an Incus-ready image; main's platform 0.5.105 answers /api/health 200
on empty volumes with /app/data and /app/static-assets owned 1001:1001,
and main's sandbox 0.5.105 listens on :8003 on an empty platform-config.
The Incus runtime answers exec, its held session, the copies in and
out, the console, one-shot helpers and health (lib/runtime/incus/verbs:
exec, files, logs, oneshot, health, health-tracker).

Exec runs in mode 1 or 2 under the image's own timeout, which the image
facts name per digest, because Incus cannot cancel a record-output
exec; an image without one, or whose facts were never read, runs over
the websocket, which a closed control socket ends. A stdin of up to
1 MiB is pushed as a 0600 file in /dev/shm, owned by the exec user,
journaled before the push and removed by the command before it reads
it; a larger stdin, and any stdin for db (whose /dev/shm is Postgres's
shared memory), goes over the websocket and writes nothing. The
journal's leftover files are reaped before the next exec in their
instance, sparing the files a running exec of the same process still
reads, and the journal's updates in one process run one at a time. Every exec has a deadline (600 s by default) and a byte cap
(16 MiB), and fails past either on every path: a command the image's
timeout ends at the deadline fails as one past the websocket deadline
does, whether timeout exits 124 (coreutils) or 143 (busybox).
StackRuntime gains execSession: a websocket exec on Incus, docker exec
-i on Docker; RuntimeAccess shares its RuntimeSession type.

A copy into a stack volume (the config store at /app/data, mounted
read-only in the platform) goes through the volume's file API, any
other path through the instance's, each file and directory written
with its owner (names read in the instance's own passwd and group) and
its local mode. A copy out walks the file API to its byte cap and
deadline into 0600 files, and names a symlink instead of following it.

Spike S21 measured the console on Incus 7.6: a read of a running
instance takes the lines it shows from every later read and from the
console it keeps after a stop, and about 128 KiB is all it keeps of
the lines no read took. So tale logs reads a running instance only
with the new --consume, after a one-line warning; --follow polls,
--tail cuts on the client and --since is refused. A stopped console is
read freely. Health checks, waits and the crash report never read a
running console; the report says "stopped after repeated crashes" for
an instance boot.autorestart gave up on, with the tail labelled "the
last console lines Incus kept".

A one-shot job runs in job-<purpose>-<8 hex>, made from the release's
tale-proxy with the job's volumes, no network device and sleep as its
entrypoint, labelled user.tale.role=helper; its exit code comes from
the exec, then it is deleted, and the next job reaps one a crash left.

Health runs the compose healthcheck by exec (CMD-SHELL as sh -c) and
judges it with Docker's interval, retries, start_period and
start_interval in a pure HealthTracker; waitForHealthy takes the
runtime and keeps its 2 s cadence, and controlCall hands its budget to
the runtime as the exec's deadline. The runtime contract suite also
runs the Incus runtime over the in-memory Incus, for the verbs it
answers today.

Proven on Incus 7.6 (TALE_INCUS_REAL=1): stdin of 1 KiB and 2.7 MiB
byte-equal, nothing left in /dev/shm or the journal, sleep 30 ended at
2 s on both paths, the byte cap, a held session, copies with owner and
mode through both file APIs, a helper with only lo, health by exec,
and a running console taken by its read.
The Incus runtime renders the files of its two dnsmasq resolvers and of
its traffic switch, and withdraws and admits colour replicas
(lib/runtime/incus/verbs: names, switch).

Each resolver view has its dnsmasq.conf (local-ttl=1, server=<gateway>
for names or no-resolv for names-sessions, local=// and one local=/name/
per name) and one records file in its hosts directory, one line per
address, so a colour name answers each of its replicas. Nothing but a
DNS name and an IPv4 address reaches a file. Records are written in
place, because on Incus 7.6 with dnsmasq 2.90 a record two hosts files share is
kept once, under the file read first, so writing a new file and
deleting the old one (or truncating it first) dropped names the new
file held; 36% of queries for an unchanged name answered NXDOMAIN. One
file rewritten in place lost none of 1,420,344 answers over 40 changes.
local=// answers every single-label name from the records, so a name a
records change adds to a running resolver (a colour's names, on every
flip) answers AAAA empty instead of being forwarded.

The switch's upstream files list every slot of each role in every
colour the block holds, owned by the switch instance and written once.
A proxy or egress image that does not name the format the CLI writes in
dev.tale.infra-formats (switch-upstreams/1, names-hosts/1) is refused at
compile, so release/0.6's proxy fails closed; the formats now live in
the placement table, which the planner reads.

StackRuntime gains `withdrawal` and takes replicas in admit and
withdraw. Docker keeps network disconnect, after the drain, its argv
unchanged and its order held by a guard. Incus sets the role's marker
(/tmp/platform-withdrawn, /tmp/backend-withdrawn) by exec as the
image's app user from the image facts, journals it, waits two switch
health intervals and cuts nothing, before the drain; admit removes a
marker only where one is set. retireColor withdraws where the runtime
allows it.

The planner no longer takes oci.cwd, oci.uid and oci.gid for drift on
an OCI instance without image.oci.* beside them: Incus 7.6 keeps none,
and every second plan replaced every instance.

Real check on Incus 7.6 (project ti-13), through the applier, the
compiler's resolver and switch specs and main's edge in front of the
switch. Musl and glibc clients resolve, AAAA is answered empty, the
session view refuses other names; one flip under 16 plain, 8 SSE and 8
WS loops through the edge and a 1 Hz /api/health poller: 1,520,494 OK,
no 5xx, no edge 502/503/504 or maintenance page, the edge's upstreams
never failed, and the 8 SSE and 8 WS drops came at the old colour's
stop alone.
tale incus contract prints the node contract, a versioned additive JSON
document (schemaVersion 1) computed from the Incus declaration and the
compiled stack target: required API extensions, the restricted project
with limits for the peak (stateful tier, two colours, helpers), the
tale-stack profile with its root disk and default limits, the app bridge
(DHCP outside the block, NAT, no IPv6, default drop), the complete ACL
(sessions' real subnet, spawner to the Incus API and the sessions, public
egress per role, the effective egress proxy, operatorReach), pool and
volume sizes, the certificate to trust and Tale's reserved stop band.
The contract compiles with placeholder image facts, so it needs no
registry; a test holds it equal to the contract of real-shaped facts.

tale incus check reads the node back with the restricted certificate and
refuses mismatches in fixed sentences, warns on pool capacity, and judges
forwarding by effect: a helper on the app bridge opens each probed rule's
connection, and a timeout is refused with the DOCKER-USER remedy first and
ip-forward-no-drop second.

tale incus bootstrap applies the contract over the local admin socket:
plan first, idempotent, the sandbox zone as the spawner's docs lay it out,
two restricted certificate pairs, the server certificate pinned (replaced
only when asked), tale.json runtime and .tale/incus, .tale/sandbox-incus.
It reads the FORWARD policy and applies DOCKER-USER rules only after
asking, warns when another project stops inside Tale's band, and
--remove takes away only what it made.
A deployment specification that declares runtime.engine "incus" is now
prepared and applied on Incus (design 7.1, D-39).

- deploy prepare reads every image from its registry, with no Docker
  daemon; the sandbox runs in its Incus shape on the address plan and
  publishes no port; the declared sandbox.egressProxy is kept (R-13); the
  bundle records runtime "incus" and pins runtime/incus-stack.json, the
  compiled stack without any environment value.
- deploy --bundle runs applyIncusRuntime, the sibling of applyRuntime:
  probe, the node check against the contract, the target compiled from
  the deployment's .env and held to the prepared stack, custody, plan,
  apply (images, volumes, files, instances by tier behind health gates,
  the spawner drained before it is replaced, switched replicas withdrawn
  first, the downtime gate of --stop), readback, and the shared
  .tale/runtime.json receipt. --plan-only prints the plan (never with
  --configuration-only); --plan applies a reviewed plan only while the
  node still needs it. A pending restore
  refuses the deploy (R3-22). A single replica of platform or backend-api
  replaced in place is printed as a notice (R3-36).
- Custody over InstanceInfo: labels, stack, index 1, no one-off, the
  expected disks and nothing outside the volume allow-list, and the
  applied configuration digest.
- RuntimeAccess on Incus (runtime-access-incus.ts) for accept, observe,
  provision, export-client, the runtime configuration and the automation
  protocol: 64-hex identities per instance incarnation, Compose-named
  labels from one normalized readback, the backend-local binary pushed
  0550.
- copyOut takes a byte cap on both runtimes and refuses past it with one
  sentence; the bundled-database check reads a derived bundledDatabase
  fact, so no DATABASE_URL leaves either runtime (R-15).
- A changed existing deployment on Incus is refused until the CLI can
  take its recovery snapshot there.
- Docs (EN, DE, FR), the INC-F7 to INC-F10, INC-B5 and INC-B6 boxes and
  the automation row.
tale dev and the sandbox device run on this machine's Docker, and Incus
runs on Linux only, so a workspace that declares runtime.engine "incus"
refuses them in one sentence that says why (design D-35). tale dev
refuses before it writes anything (--stop still stops the Docker
containers a dev run left); sandbox connect and update refuse before the
one-time join token is spent. declaredEngine moves to
lib/project/declared-engine.ts beside the new check, so the loops read
the declaration without loading the runtimes.

tale doctor reports the declared runtime first. On an Incus workspace it
checks Incus instead of Docker: the credentials in .tale/incus (custody,
a pinnable server certificate, the key, expiry), the probe with the
stack's restricted certificate, row by row, and each instance Incus
stopped after repeated crashes with the last console lines it kept
(R-09). Only a stopped instance's console is read, never a running one
(R3-07). --offline makes no network call. Manual boxes INC-F2a, INC-F2b,
INC-F6a and INC-F6b; the CLI reference in English, German and French.
tools/cli/e2e/incus drives the compiled CLI through the managed path
against a real Incus node, with the stack's eight images built from the
checkout (images.sh) into the lab's registry. The suites, in order:
bootstrap, managed, fresh-noop, logs, crash-loop, stop, sandbox,
export-client and secrets. README.md says what each proves, and what
waits for later work (accept, observe, the upgrade's window and the
credential export). test:e2e:incus runs them; harness.test.ts holds the
pure parts.

cli-incus-e2e.yml builds the node with the sandbox lab's lab.sh, runs
@tale/incus's real tests in the project bootstrap made, then the suites.
CI ready (CLI Incus E2E) is the ninth readiness context: PR scope runs
the lab for the cli-incus filter, held by a test to the import closure
of lib/runtime/incus, and the verdict blocks only on cli-incus-required
(the runtime, packages/incus, the workflow) or a full scope; elsewhere
it is reported, not required (R3-31). CI.md, the Forgejo port and the
manual automation register follow.

What the lab found, fixed here with a unit test each:
- A declared host that serves a registry other than Docker Hub
  (runtime.incus.registryMirrors), for prepare, Incus pulls, the
  contract's image servers, the sessions' image and check's probe (docs
  in en/de/fr).
- tale incus bootstrap --spec --compose writes a managed deployment's
  credentials into its state directory.
- An empty .env value is a key Incus does not keep: it no longer reads
  as drift, and the fake drops it as Incus does.
- A first deploy stopped at a health gate resumes.
- tale logs --follow ends cleanly when the service stops between a state
  read and a console read, and shows each line once although a stopped
  console hands out the whole run again.
A new page, Run Tale on Incus, in English, German and French, takes an
operator from an Incus host to a managed deployment on the
experimental runtime. It covers the prerequisites (API extensions, idmapped mounts, a zfs, btrfs
or lvm pool, the pinnable API endpoint), the declaration, the node
contract with bootstrap and check, the restricted model and the network
ACL rules, the two ingress modes, sizing from the profile and service
limits, the in-place window of a single replica, logs that a read
consumes, the three stop groups and the host-shutdown bound on a
Tale-only host, a host shared with Docker, the current limits and the
verified scope of the end-to-end suites.

The install index names the runtime choice. cli-install asks for Docker
only on the Docker runtime, links the page, and its Incus sandbox
section becomes "the sandbox under each runtime" under the anchor it
had. The environment reference gains a container
runtime section (TALE_RUNTIME, TALE_INCUS_DIR, TALE_PROXY_ROLE,
TALE_SHM_SIZE) and SANDBOX_BACKEND names the runtime's only backend.
container-architecture traces the Incus layout: the resolvers, the
switch and the trust boundary. The sandbox's Incus contract says what
the CLI sets when the whole stack runs on Incus; the CLI README shows
the incus commands; the repo contract names the runtime interface and
what Ops passes; .env.example and compose.yml say how the Incus runtime
uses them. nav.json and published.json carry the new page.
Bun's mock.module is process-global and CI's Build (linux) runs every
CLI test file in one process. On its file order rollback.test.ts runs
before runtime-incus-apply.test.ts and left wait-for-healthy replaced by
a mock that afterEach had reset to answer undefined, so the Incus apply's
real health wait read every instance as unhealthy at once and 7 apply
tests failed in ~200 ms.

The rollback suite now captures each module it mocks and puts the real
one back after the file, and the Incus apply suite asserts its health
wait is not a leaked mock, so a future leak names itself.
The Windows build job failed 82 distinct tests (164 lines in the log), nearly
all over the Incus runtime's private files: the plan key, the journal, copies
and the image facts are held by owner and mode (0600 in 0700), which Windows
files do not carry, so readKey and readJournal refused every fixture with
"must be a regular file of this user with mode 0600".

The CLI already keeps POSIX custody off Windows: managed bundles are refused
there (assertManagedPlatform), and the Incus credentials' custody check needs
a POSIX host. So the Incus runtime follows that concept rather than a weaker
check: assertIncusPlatform refuses it in one sentence before anything is read
or made, in tale incus check, tale incus bootstrap and createIncusRuntime.
tale incus contract reads no file and still runs. readKey, readJournal and
markInterruptedSync now refuse a host without account ids instead of skipping
the owner check.

The tests over those files run on POSIX hosts alone (describePosix, the
repository's pattern), the runtime contract drops its Incus leg on Windows,
and a Windows-only test proves the refusal. Two tests had Windows faults of
their own: the credentials directory expected a rooted path without its drive
letter, and the Docker copy-out argv matched a slash-only temporary path.
The Incus guide names the requirement in English, German and French.
A websocket exec connected stdin, stdout and stderr together, so the
command could start, and write, before this side had finished the
stdout upgrade. On macOS (Build (macos)) a command that wrote at once
then read back as silent and resolved: its output never reached the
byte cap. stdout and stderr are now open, their readers in place,
before stdin connects; the command cannot start until it does.

The managed-runtime fixture made its state below tmpdir() unresolved;
on macOS that is /var/folders/..., below the /var symlink, which the
credential custody check refuses as an ancestor, rightly: a private
file's ancestors must be real directories. The fixture now resolves
its directory as the credentials test already does.
3eb220a made runtimeFixture resolve its temporary directory for
every suite. client-export's 'default OS temp path without TMPDIR'
case checks the unresolved /tmp path on purpose, against a canonical
one, so on macOS (where /tmp resolves to /private/tmp) it failed.

Resolving is now an option of the fixture, taken by the Incus apply
suite alone, whose code checks private files' custody and refuses the
/var symlink as an ancestor. Every other suite keeps tmpdir()'s path.
@yannickmonney
yannickmonney merged commit 9f79916 into main Nov 13, 2026
125 of 129 checks passed
@yannickmonney
yannickmonney deleted the feat/incus-managed branch November 13, 2026 07:02
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