Architecture
System Design Interview — the Delonix Engine
Before you read: Architecture and The crates — this page argues why the structure they describe is shaped that way.
InterviewerDesign a container and microVM engine for a single Linux node. It must run without root by default, without a resident daemon, and it must not know who is calling it.
This page answers that prompt the way a strong candidate would, and then checks every answer against what the Delonix Engine actually does. Each deep dive ends with Where it lives in the code, listing files and symbols that were read for this page. For the structural map (layers, crate graph, processes, state paths) read Architecture first; this page is about why the design is shaped that way. After it you can defend — or challenge with evidence — the engine's main design choices: rootless process creation, the network holder, shared image layers, the VM port and the reconciler without a state file.
Numbers quoted below are measurements recorded in the repository with their date or release, not timeless facts. Re-measure before relying on one.
1. Requirements#
CandidateBefore drawing boxes I want to pin down what "done" means.
Functional#
- Run containers from OCI images: pull, unpack, create, start, stop, exec, logs, remove.
- Run microVMs from disk images, on more than one hypervisor.
- Networks between workloads: private bridges, published ports, firewall, DNS names, isolation by namespace.
- Storage: named volumes, bind mounts, network shares.
- Declarative operation: a manifest of Kinds,
plan,apply, drift detection, prune. - Serve the kubelet through the CRI, so the engine can be a Kubernetes node runtime.
- Expose the same operations to local programs (an API, an AI tool protocol), not only a shell.
Non-functional#
- Rootless-first. The normal path runs as an unprivileged user; privilege is opt-in and said.
- Daemonless. No process runs "just in case". Persistence belongs to systemd or to a per-workload process with an owner.
- No consumer knowledge. No tenant, account, plan or billing; the engine validates its own contract instead of trusting a caller to refuse what it cannot do.
- Observable through open standards (OpenTelemetry, Prometheus) and an event log.
- Honest failure. A refused or half-done operation is reported as such, with a reason and a
stable exit class — never
0over a failure. - Survives restarts of its own control processes without disturbing running workloads.
2. Background constraints: what an unprivileged Linux user may do#
CandidateRootless changes the design more than any other requirement, so let me list the kernel rules I have to live with.
| The kernel lets an unprivileged user… | …but not | Consequence for the design |
|---|---|---|
create a user namespace and be uid 0 inside it (CLONE_NEWUSER) |
map arbitrary host uids | a single-uid map unless newuidmap/newgidmap and /etc/subuid grant a range; an image that chowns to uid 101 needs the range |
create net, mount, PID, IPC, UTS namespaces owned by that user namespace, with CAP_NET_ADMIN/CAP_SYS_ADMIN inside them |
touch the host's initial network namespace | networking is built inside a namespace the engine owns; reaching the host needs a user-space bridge (slirp4netns) |
mount overlayfs, tmpfs, binds inside its own mount namespace |
mount in the host's view | the container's own init performs the overlay mount after clone |
| write limits into a delegated cgroup v2 subtree | write cgroups it was not delegated | limits only apply where systemd delegated them (systemd-run --user --scope -p Delegate=yes) |
setns into a namespace owned by its own user namespace |
setns into a namespace owned by another process's user namespace |
joining a network created elsewhere means entering that owner's user namespace first |
run clone safely in a single-threaded process |
assume clone is safe in a multi-threaded one (clone does not run pthread_atfork handlers) |
servers built on tokio must hand process creation to a fresh process |
Two host policies turn up constantly and look like engine bugs: Ubuntu 23.10+ restricts unprivileged user namespaces through AppArmor (a profile is attached to the path of the executable that creates the namespace), and a plain SSH session is not a delegated cgroup scope. Each row of the table is a primitive taught hands-on in Linux foundations; the two host policies are in Preparing your environment — Known host traps.
3. API#
CandidateOne set of operations, several doors — and one of them is the contract the others converge on.
| Door | Encoding | Who uses it |
|---|---|---|
CLI delonix |
argv, stable exit classes | operators, scripts |
Node contract delonix.node.v1 |
gRPC and HTTP/JSON on the same local unix socket | any local client (design; not served yet) |
CRI runtime.v1 |
gRPC on a unix socket | the kubelet |
| MCP | JSON-RPC over stdio | a local AI client, one session per process |
Design points of the node contract, all written in ADR-0040 D4 and ADR-0042:
- The
.protofiles are the source of truth; the REST mapping comes fromgoogle.api.httpannotations and the OpenAPI document is generated from them. A CI gate checks format, lint, breaking changes against the last release, that every RPC except the bidirectional streams (Exec,Console) has an HTTP mapping, and that the committed OpenAPI is the generated one. - Resource-oriented services (
ContainerService,PodService,VirtualMachineService,NetworkService,VolumeService,ImageService,StackService,NodeService,OperationService), one request message per RPC, identity as explicitnamespace/name. - Long work returns an
Operationthat is persisted before it is acknowledged, so a restarted server can sayInterruptedinstead ofRUNNINGforever. - Local only.
SO_PEERCRED, same uid, no TCP, no TLS, no identity in the engine — ADR-0010 rejected a remote API. Anything off-node puts its own proxy in front.
InterviewerWhy not just a REST server?
CandidateBecause gRPC clients and shell tooling both deserve a first-class encoding, and generating both from one file keeps them from drifting. The CLI is not a second-class citizen either: its exit classes (
delonix-model/src/exitcode.rs) are theDX_*classes ADR-0040 D4 requires the contract's errors to carry.
Where it lives in the code: proto/delonix/node/v1/{node,compute,infra,operations,common}.proto;
scripts/contract_gate.py; docs/api/openapi.yaml; crates/interfaces/delonix-cri/src/lib.rs
(serve_blocking); bins/delonix-mcp-bin/src/main.rs; crates/foundation/delonix-model/src/exitcode.rs.
Honest status: no crate references delonix.node.v1 yet; local programs use the management API
(crates/interfaces/delonix-mgmt), which ADR-0042 plans to migrate and remove.
4. High-level design#
CandidateI will layer it so the rules of the domain never import the kernel, and I will make every long-lived process own exactly one thing.
Legend — white box with red border: engine building block · outlined region: a layer · cylinder, blue: state on disk · solid arrow: call or data flow, labelled.
The domain's rules never mount, spawn or configure the network, and one adapter owns every file under the state root.
flowchart TB
subgraph IF["interfaces — parse a request, present a result"]
CLI["CLI<br/><small>delonix</small>"]
CRI["CRI server<br/><small>delonix-cri</small>"]
API["local API server<br/><small>delonix-mgmt</small>"]
MCP["MCP server<br/><small>delonix-mcp</small>"]
end
subgraph CX["contexts — use cases, ports and records"]
COMPUTE["compute<br/><small>Container, Vm, RunOpts, resolve_run, launch, ports</small>"]
STACK["stack<br/><small>Kind table, 3-way plan</small>"]
NODE["node<br/><small>event log, pid and host checks, server dispatch</small>"]
end
subgraph AD["adapters and providers — implement ports"]
LINUX["kernel<br/><small>clone, mounts, cgroups, seccomp</small>"]
SDN["SDN<br/><small>pin, control, nftables, slirp</small>"]
OCI["OCI<br/><small>registry, CAS, layers</small>"]
VMS["VM backends<br/><small>Cloud Hypervisor, libvirt, Proxmox</small>"]
STA["state<br/><small>stores, atomic writes, secret vault</small>"]
end
FILES[("files under one state root")]
IF -->|"call use cases"| CX
AD -->|"implement ports"| CX
IF -->|"call directly, today"| AD
STA -->|"flock, temp file + rename"| FILES
class CLI,CRI,API,MCP,COMPUTE,STACK,NODE,LINUX,SDN,OCI,VMS,STA block
class FILES store
classDef person fill:#191513,stroke:#191513,color:#ffffff
classDef engine fill:#cc2823,stroke:#8f1b17,color:#ffffff
classDef block fill:#ffffff,stroke:#cc2823,color:#191513
classDef external fill:#e1ddda,stroke:#8a817c,color:#191513
classDef store fill:#2390c8,stroke:#17618a,color:#ffffff
- Layers (ADR-0040 D1): foundation → contexts → adapters/providers → interfaces → binaries,
enforced by
scripts/arch_fitness.py. - State is JSON records and content-addressed files under one root, with atomic writes and
flockaround read-modify-write — no database, because there is no daemon to own one. The record types are defined apart from them (Container/Vmin thedelonix-computecontext;Statusand the firewall records in thedelonix-modelfoundation crate); the files are opened only through thedelonix-stateadapter. - Processes exist per workload (a supervisor that is the container's parent, the init, a log
shim) and per node when networking is used (a pin that only holds namespaces, a restartable
control process, one
slirp4netnsuplink). Nothing else stays up.
Where it lives in the code: scripts/arch_fitness.py (LAYERS, ALLOWED);
crates/adapters/delonix-state/src/store.rs (Store::update, JsonStore::update,
write_atomic); crates/contexts/delonix-compute/src/record.rs (Container, Vm);
crates/foundation/delonix-model/src/records.rs (Status, ContainerFw, FwRule); crates/contexts/delonix-compute/src/{ports,launch}.rs;
crates/adapters/delonix-linux/src/supervise.rs (run_supervised).
5. Deep dives#
5.1 A rootless container run#
InterviewerWalk me through
run -d -p 8080:80 nginxas an unprivileged user.
CandidateResolve everything that can fail before creating a process; then create the process stopped, configure it from outside, and only release it when it is ready.
- Resolve. One run specification (
RunOpts) comes from every entry — CLI flags, a Pod manifest, the Docker API, the CRI.resolve_runpulls the image if absent, prepares the root filesystem, resolves--useragainst it, resolves volumes and devices, and validates security options. Every refusal happens here, and a guard removes the prepared directory on any early return. - Record.
build_recordturns the specification into aContainerrecord (pure). - Choose the parent. For a detached start the CLI forks a supervisor that becomes the
container's parent (
launch::start→should_supervise). Only the real parent canwaitpid, so this is what makes the real exit code and--restartpossible without a daemon. - Clone.
spawncallsclonewith new mount, UTS, PID and IPC namespaces, plus user and network namespaces when the container gets its own. The child blocks on a pipe. - Configure from outside, in a fixed order. The parent writes the uid/gid maps
(
write_userns_maps, throughnewuidmapwhen a subuid range exists), sets up the cgroup, runs theon_startedhook (here:slirp_attachwith the-pports, so the network exists before the entrypoint runs), and only then writes the "go" byte. - Inside the child.
container_initmounts the overlay (mount_overlay_if_marked), binds volumes, sets up/dev,pivot_roots, masks/procpaths, applies capabilities, seccomp andno_new_privs, and signals "mounted" on a second pipe beforeexecvp. - Publish the record last. The parent waits for the "mounted" byte (
wait_for_mounts), briefly for the exec result, and only thenstore.savesRunning.
Legend — participants are processes; solid arrows are calls, socket lines, forks or clones (the label says which); dashed arrows are replies or bytes sent back; a self-arrow is work inside that process; notes mark state or waits.
The child is created blocked, configured from outside, and the record is saved only after the child reports its mounts.
sequenceDiagram
participant CLI as delonix
participant SV as supervisor
participant I as child (init)
CLI->>CLI: resolve_run, build_record
CLI->>SV: fork (launch::start → supervise)
SV->>I: clone(NEWNS, NEWUTS, NEWPID, NEWIPC, NEWNET, NEWUSER)
Note over I: blocked on the sync pipe
SV->>SV: write_userns_maps
SV->>SV: setup_cgroup
SV->>SV: on_started hook — slirp_attach with -p ports
SV->>I: "go" byte
I->>I: overlay mount, volumes, pivot_root, caps, seccomp
I-->>SV: "mounted" byte
SV->>SV: store.save Running
SV-->>CLI: started
I->>I: execvp
InterviewerWhy wait for "mounted" before saving the record?
CandidateBecause the record is what other processes read to decide they can enter the container. Before
pivot_root,setnsinto the child's mount namespace lands in the host's filesystem. Measured on 2026-08-28 with a probe that could tell the two filesystems apart: before the fix, 5 of 54execs issued straight afterrun -dran outside the container; after it, 0 of 54. The wait has three outcomes (MountWait::{Ready, InitExited, Unknown}) and a ceiling, so a hung mount cannot hangrun.
Where it lives in the code: crates/contexts/delonix-compute/src/run.rs (resolve_run,
build_record); crates/contexts/delonix-compute/src/launch.rs (start, should_supervise,
WorkloadRuntime); crates/adapters/delonix-linux/src/workload.rs (HostWorkload);
crates/adapters/delonix-linux/src/supervise.rs (run_supervised);
crates/adapters/delonix-linux/src/lib.rs (spawn, write_userns_maps, setup_cgroup,
container_init, setup_rootfs, wait_for_mounts, MountWait);
bins/delonix-runtime-bin/src/cmd/container.rs (cmd_run).
5.2 Networking: pin, control, slirp, nftables#
InterviewerContainers need to talk to each other, be isolated by namespace, and publish ports — without
CAP_NET_ADMINon the host.
CandidateBuild a private network world inside one namespace the user owns, bridge it to the host in user space, and do all filtering there.
Split holding from serving. A single "holder" process that owned the namespaces and served requests would take every workload's network down whenever it restarted. So:
- the pin (
delonix netns pin) creates the user, network and mount namespaces itself and then only sleeps (pin_main). Its pid is what everynsenter -t <pin>targets, and it never changes; - the control process (
delonix netns control, started throughnsenterinto the pin's namespaces) serves a0600unix socket restricted bySO_PEERCRED, and runs DNS, DHCP and Router Advertisements. It is restartable:ensure_uprestarts only it when the pin is alive; - one
slirp4netnsattachestap0to the pin's netns and exposes an API socket foradd_hostfwd.
The pin creates its namespaces in-process (the caller writes the id maps through two pipes)
instead of via unshare(1), because an AppArmor profile is attached by the path of the executable
that creates the user namespace, and /usr/bin/unshare is not the engine's.
Joining a custom network. A container on --net web cannot setns into a netns owned by the
pin's user namespace. The CLI therefore asks the control process to create the netns and veth
(attach …), then re-executes itself inside the pin's user and mount namespaces
(nsenter -t <pin> -U -m -n -- ip netns exec <netns> delonix netns run <spec>); the second pass
inherits the user and network namespaces instead of creating them.
Publishing a port is two steps, both dataplane state rather than process state (which is why
ports can be added and removed on a running container): add_hostfwd on the single slirp, and a
DNAT rule inside the pin's netns (publish … on the control socket).
Filtering with a verdict map. The ingress table (table ip dlxing) is built so per-container
policy costs the same no matter how many containers exist:
forward priority -20 fwguard drop 169.254.0.0/16 and 127.0.0.0/8
forward priority -10 fwdeny established → accept; bridge pair in @netpair → verdict; bridge↔bridge → drop
forward priority -5 fwcont ip daddr vmap @fwmap ; ip saddr vmap @fwmap
forward priority 0 forward policy drop; established; tap0; same-bridge; @netpair
fwcont has two rules; each container's rules live in its own chain, reached through the fwmap
verdict map keyed by IP. Traffic between networks is dropped pairwise unless a NetworkRoute
puts the pair in @netpair — a route says the packet may cross, and the per-container chain
still decides whether it is allowed.
Isolation by namespace lives in each container's chain: members of @dlxns<hash> (same
namespace) are accepted, and new connections from any other container address (@dlxall) are
dropped; replies still flow because the drop matches only ct state new. An explicit ingress
policy replaces that default. IPv6 in the SDN is refused by default (table ip6 with
policy drop), because every rule above is IPv4.
Legend — participants are processes; solid arrows are calls, socket lines, forks or clones (the label says which); dashed arrows are replies or bytes sent back; a self-arrow is work inside that process; notes mark state or waits.
Joining a custom network is one control-socket line plus a re-exec into the pin's namespaces; publishing a port is two dataplane writes.
sequenceDiagram
participant CLI as delonix (1st pass)
participant C as control
participant S as slirp4netns
participant P2 as delonix netns run
CLI->>C: attach netns ip bridge gateway namespace
C->>C: ip netns add, veth to bridge, anti-spoofing, namespace sets
CLI->>P2: nsenter -t pin -U -m -n ip netns exec netns
P2->>S: add_hostfwd host-port (api socket)
P2->>C: publish proto host-port ip container-port (DNAT)
P2->>C: firewall id ip rules
P2->>P2: launch — inherits user and net namespaces
InterviewerThe control process serves one connection at a time. Isn't that a bottleneck?
CandidateIt is deliberately the serialization point for netns, veth and nftables changes, which must not interleave. The risk is clients giving up in the queue: when v0.47.0 was prepared, 30 concurrent attaches with a 5-second read ceiling lost 15; with the reply ceiling raised (
CONTROL_REPLY_TIMEOUT, 30 s) all 30 completed. The per-connection I/O ceiling (CONTROL_IO_TIMEOUT) exists so one stuck client cannot freeze the node's control plane.
Where it lives in the code: crates/adapters/delonix-sdn/src/infra.rs (ensure_up,
start_pin, pin_main, start_control, control_main, control_loop, start_slirp,
attach_container, do_attach, publish_port, join_argv, ingress_table_ruleset,
fw_chain_body, dlxns_set, DLXALL_SET, ingress_v6_refusal_ruleset, CONTROL_IO_TIMEOUT,
CONTROL_REPLY_TIMEOUT); crates/adapters/delonix-sdn/src/pin_userns.rs;
crates/adapters/delonix-sdn/src/run_network.rs (HostNetwork);
crates/contexts/delonix-compute/src/network.rs (attach_custom_network, wire_network);
bins/delonix-runtime-bin/src/cmd/container.rs (reexec_into_netns, run_from_spec).
5.3 Images: CAS, shared layers, and the many-layer mount#
InterviewerA node runs twenty containers of the same image. What is on disk?
CandidateBlobs once, unpacked layers once, and one small writable directory per container.
- CAS. Blobs are named by their sha256 under
blobs/sha256/<hex>; writing an existing digest is a no-op. A pull verifies each blob against the manifest and the manifest against the digest the user pinned (verify_manifest_digest) — otherwise a pin would be decorative. - Resumable downloads. A blob download retries with
Range:from the bytes already held (BLOB_ATTEMPTS), and distinguishes a206at the requested offset (resume), a206elsewhere and a200(restart). The digest check at the end makes stitching safe. - Shared layers.
prepare_overlaycreatesupper/,work/,merged/for the container and writes the ordered list of shared layer directories tooverlay-lowers. The container's own init mounts it, inside its mount namespace, where an unprivileged user is allowed to. The contract is a file on disk rather than a field in memory because the rootless path re-executes the binary and a struct does not cross that boundary.
Back of the envelope, as recorded for v0.59.0: the previous flat copy cost each container a
full image tree — on one development host containers/ held 47 GiB, most of it identical
copies, and each run of a 2.1 GiB image spent about 13 s copying. Sharing layers took that
directory to 7.2 GiB.
- Many layers. The classic mount(2) passes lowerdir=a:b:c… as one string and the kernel
copies at most a page of it, truncating silently. Measured for
ADR-0037 (validated 2026-09-06): 20 layers (4084 bytes)
mounted, 30 (5994 bytes) failed, and a 91-layer builder image needed 9107 bytes. The mount now
uses fsopen/fsconfig/fsmount/move_mount with one lowerdir+ call per layer, so there is
no length ceiling.
Where it lives in the code: crates/adapters/delonix-oci/src/cas.rs (Cas::write,
Cas::has); crates/adapters/delonix-oci/src/registry.rs (blob_with_progress_capped,
BLOB_ATTEMPTS, parse_content_range, verify_manifest_digest);
crates/adapters/delonix-oci/src/overlay.rs (prepare_overlay, LOWERS_FILE);
crates/adapters/delonix-oci/src/run_images.rs (HostImages);
crates/adapters/delonix-linux/src/lib.rs (mount_overlay_if_marked, fsopen_overlay).
5.4 microVMs: one port, a registry, and the firmware trap#
InterviewerAdd VMs without building a hypervisor abstraction that leaks everywhere.
CandidateA trait per provider, a registry the composition root fills, and quirks that belong to the provider answered by the provider.
- Port.
VmBackendhasid,available,boot,is_running,ip,stop, and optional operations (pause,snapshot,restore, …) whose default answer is "not supported". Provider facts are methods, not string checks at call sites:ip_is_predicted(Cloud Hypervisor's address is computed from the MAC, not observed),manages_own_storage(a remote node owns its disk),destroydistinct fromstop(locally the disk is the engine's; remotely only destroy releases it). - Registry.
builtin_backendsseeds Cloud Hypervisor and libvirt in preference order;register_backendadds more (the Proxmox backend, registered by the CLI's composition root only when configured). A registration carries a factory closure and anauto_selectableflag, so auto-detection never constructs — and therefore never authenticates — a remote backend. Registering does no I/O. - Networking a VM. Cloud Hypervisor runs inside the pin's netns and gets a
tapon a network bridge through theVmNetworkport, which the SDN implements (HostVmNetwork);delonix-vmdoes not depend ondelonix-sdn. Because the DHCP server is the engine's own and deterministic, the lease is known before the guest boots, which is what lets namespace isolation apply to a VM's address from the first packet — and why "has an IP" is not proof of a booted guest (sdn_reachableasks by ARP from inside the netns). - Firmware. The Cloud Hypervisor firmware search prefers EDK2
CLOUDHV.fdoverhypervisor-fw(DEFAULT_CH_FIRMWARES, with a test fixing the order). - cloud-init.
VmConfigcarries intent (hostname, user, SSH keys); local backends realize it as a NoCloud ISO whosenetwork-configmatches the primary NIC by MAC, and a remote backend may realize it natively.
Where it lives in the code: crates/adapters/delonix-vm/src/lib.rs (VmBackend,
BackendRegistration, builtin_backends, register_backend, select_backend, auto_detect,
backend_for, CloudHypervisorBackend, LibvirtBackend, launch_vmm, DEFAULT_CH_FIRMWARES,
set_network); crates/adapters/delonix-vm/src/cloudinit.rs (generate_seed_iso);
crates/contexts/delonix-compute/src/ports.rs (VmNetwork);
crates/adapters/delonix-sdn/src/vm_network.rs (HostVmNetwork);
crates/adapters/delonix-sdn/src/infra.rs (sdn_reachable, dhcp_lease_ip);
crates/providers/delonix-proxmox/src/lib.rs (ProxmoxBackend);
bins/delonix-runtime-bin/src/cmd/vmbackends.rs (register_configured).
5.5 The declarative reconciler, without a state file#
Interviewer
applymust converge, detect drift, and prune — Terraform-like — but you said no daemon and no database.
CandidateKeep the last applied spec on the resource itself, derive ownership from a label, and make planning a pure function.
- Pure plan.
reconcile::plan(desired, actual, stack)takes two snapshots and returnsVec<Change>; it never opens a store. That makes the hard cases testable as data. - Three-way diff. The last applied field map is stored on the resource
(
delonix.io/last-applied). A field present on the machine but absent from the manifest is reverted only if we set it; otherwise it is left alone — the distinction a two-way diff cannot make. - Ownership by label (
delonix.io/stack). A resource with no owner isAdopted; one owned by another stack is aConflictand never touched;--pruneanddestroyonly see what carries the label. - Actions are
Create,Adopt,Update(hot, same PID),Replace(refused unless--replace <Kind>/<name>is given, checked before anything is created),NoOp,Delete,Conflict,NotConverged.plan --detailed-exitcodeanswers 0/2/1 for a CI drift gate. - One table of Kind facts (domain, form, whether it converges, has teardown, is namespaced, how presence is observed) governs the planner, apply order and teardown order, instead of lists kept in sync by hand.
Legend — participants are the operator, the
stack applycommand, the pure planner and the stores and dataplane it acts on; solid arrows are calls; dashed arrows are replies;altandoptboxes are the failure branch and the optional prune.
Nothing is created before the plan is checked, and a failure mid-apply is stamped rather than rolled back.
sequenceDiagram
participant U as operator
participant A as stack apply
participant P as reconcile::plan
participant M as stores and dataplane
U->>A: apply -f manifest [--prune] [--replace K/n]
A->>M: read actual state (actual_of)
A->>P: plan(desired, actual, stack)
P-->>A: changes
A->>A: refuse_unallowed — Replace without --replace fails before any creation
A->>M: run_layers in dependency order
alt a layer fails
A->>M: salvage_ownership — stamp what this run created
A-->>U: error (no rollback)
end
A->>M: converge_and_stamp — hot updates, labels, last-applied
opt --prune
A->>M: prune — Delete changes in teardown order
end
Where it lives in the code: crates/contexts/delonix-stack/src/reconcile.rs (plan,
Action, Change, STACK_LABEL, LAST_APPLIED, hot_fields_for, encode_last_applied);
crates/contexts/delonix-stack/src/kinds.rs (KindFacts, facts, stack_kinds, converges,
has_teardown); bins/delonix-runtime-bin/src/cmd/stack.rs (apply, apply_docs,
refuse_unallowed, run_layers, salvage_ownership, converge_and_stamp, prune,
destroy_one).
6. Trade-offs#
| Decision | What it buys | What it costs |
|---|---|---|
| No daemon; a supervisor per detached container, systemd for boot persistence | no single process whose death takes every workload; each process has an obvious owner | nothing sees a process die unless its own supervisor does; a caller that cannot fork starts unsupervised and the real exit code is lost; orphans need explicit reapers |
JSON files + flock instead of a database |
inspectable state, crash-tolerant, no extra dependency, works across CLI/CRI/supervisor processes | no transactions or queries; the truth about liveness is reconciled on read (reconcile_status, safe_to_signal) |
User-space uplink (slirp4netns) instead of veth pairs in the host |
works with zero host privilege | extra hop and CPU in user space; a loopback client appears as the slirp gateway (SLIRP_GW) rather than itself |
| Pin/control split | a control restart moves no wire | two processes to reason about, and in-place upgrades must still recognize older pins |
Re-exec instead of in-process clone in servers |
clone never runs in a multi-threaded process |
a process per operation, and error text crossing a process boundary — the cycle ADR-0040 removes with a launcher |
| Overlay mounted by the container's init | one copy of each layer on disk; unprivileged mount | a stopped container's merged view needs a helper process to hold the mount (reexec_mapped_hold) |
| Verdict map dispatch | constant per-packet cost as containers grow | rules are generated text; the generator and the counter reader must share formatting (fw_rule_tail) |
| 3-way diff on the resource | no state file to lose or diverge | only fields a Kind can read back can be compared; Secret values are not decrypted for planning |
7. Failure modes and the limits of one node#
InterviewerTell me how it breaks.
- The control process dies.
ensure_upfinds the pin alive and restarts only the control plane inside the surviving namespaces; running workloads keep their PIDs and network. - The pin dies. The namespaces go with it and cannot be re-entered, so the infra is rebuilt.
delonix net netns upfinds containers and pod members that were running with a network and restarts them (reconcile_after_respawn;DELONIX_NO_AUTO_RECOVER=1only reports). This is recovery by restart, and it reads the container store only — VMs are not recovered this way. - An upgrade over an older holder. A pre-split holder serving a legacy socket path is detected and reported with both paths; it is deliberately not killed automatically, because that would drop every workload's network.
applydies midway. Apply is fail-fast without rollback. Before creating anything it validates the graph and refuses unauthorized replacements; if a layer fails, what this run created is stamped with ownership (salvage_ownership) so a laterdestroyor--prunecan still reach it, and the failed run is recorded as a revision.- Leaks without a daemon. Every lease and reference is released by a normal detach, so anything
that dies another way leaks. Measured on 2026-08-25: one network's IPAM file held 391 leases, of
which 47 belonged to an existing container. The reapers respect a grace window
(
REF_MARKER_GRACE) because a container being created holds a lease and a reference before it has a record; the IPAM reaper is two-pass (a lease is only reclaimed if it is still orphaned on a later run, past the window) and fails closed — an unreadable store is an error, never "nothing is alive". Liveness counts every container record, the pod netns of pod members and attached reference markers, not only running container ids (cmd/prune.rs::lease_owners,live_ref_owners). - The mount-wait race (5.1): closed by publishing the record only after the init reports its
mounts; a detached start whose init exits before mounting is an error, not
0. - Limits of a single node. Each network is a
/16inside the pin's netns; every netns/veth/nftables change goes through one serialized control connection;slirp4netnsthroughput is user-space; and moving a VM to another host (vm migrate) involves real downtime — live migration is a NO-GO as built (ADR-0031). Scheduling across nodes is out of scope by design.
Where it lives in the code: crates/adapters/delonix-sdn/src/infra.rs (ensure_up,
stale_holder_message, reap_orphan_refs, REF_MARKER_GRACE);
crates/adapters/delonix-sdn/src/ipam.rs (reap_orphan_leases);
crates/adapters/delonix-sdn/src/lib.rs (reap_orphan_slirp);
bins/delonix-runtime-bin/src/cmd/netns.rs (reconcile_after_respawn, is_reattach_candidate);
bins/delonix-runtime-bin/src/cmd/prune.rs (lease_owners, live_ref_owners);
bins/delonix-runtime-bin/src/cmd/stack.rs (salvage_ownership);
crates/adapters/delonix-linux/src/lib.rs (reconcile_status, MountWait).
8. Follow-up questions#
Why not add a small daemon for events and restarts?
Because every resident process is a failure domain and an attack surface. The event log is an
append-only file (delonix_node::events), restarts belong to the per-container
supervisor, and boot persistence is a systemd unit per workload (delonix system boot). A daemon
needs its own ADR with evidence of what the alternatives could not do — see
ADR-0034 for a case where the question came up, and
ADR-0021 (Proposed) for continuous reconciliation that
stays daemonless.
How does the CRI start a container if the server must not clone?
StartContainer builds a typed RunOpts, writes it to a 0600 file and runs
delonix __apirun <spec>, which calls the same cmd_run as the CLI. ADR-0040 D5 replaces the
CLI hop with a launcher executable. Resource policy on the CRI path follows the kubelet
(ADR-0038).
Why is the management API local-only? Remote access means identity, authorization, certificates and audit of callers the engine has no notion of. ADR-0010 rejected it; the MCP surface is local for the same reason (ADR-0025).
How would you add a new VM provider?
A new crate implementing VmBackend, registered at the composition root — no edit to call sites
(ADR-0008). ADR-0040 D3 moves provider knobs into namespaced
extensions and quirks into capabilities; OpenStack is gated on a spike
(ADR-0039).
How do Services load-balance without a VIP?
A Service selects containers by label and the internal DNS returns several A records, rotated
per query — no new dataplane (ADR-0032).
Why ext4 and not btrfs/zfs under the state root? Overlay over a shared layer cache already removed the duplication; a different filesystem is revisited only for a measured need (ADR-0016).
What about macOS and Windows? Not a port — nothing this engine uses exists outside the Linux kernel. The plan is a launcher for a Linux guest VM (ADR-0036, Proposed).
Where is the restructuring going? Four layers, one run specification, provider ports with capabilities, one node contract served on a socket-activated server, and a launcher owning every namespace-creating spawn (ADR-0040, ADR-0042).
Next: Delonixfile and VMfile — the two build grammars — Delonixfile for OCI images and VMfile for bootable disks — as the parsers accept them.