skills/numericalos-build-oci/SKILL.md
back to source
---
name: numericalos-build-oci
description: Build a NumericalOS OCI container image runnable by Docker, Podman, containerd, or nerdctl. Use when numericalos-target-classifier routed here, or when the user asks for a NumericalOS container/Docker/Podman image. Covers the OCI, rootless-Podman, and LXC/system-container differences honestly rather than flattening them, and states plainly what a container can and cannot prove about a boot.
---
# Build a NumericalOS OCI image
Fastest honest feedback loop for NumericalOS: no kernel needed, no privileged
operations, runs on any OCI runtime.
**Prerequisite:** `numericalos-target-classifier` has run and routed here. If it
has not, run it first - it hands you `ARCH`, `REPO`, `STATE`, and the probe
results.
## What a container proves, and what it does not
Be precise about this with the user, because the difference is the entire
honesty boundary of this project.
**A container run DOES prove:** `numinit` parses `numos.state`, resolves the unit
DAG, walks boot phases in ordinal order, applies `on_failure` policy correctly,
and runs units as PID 1 of its namespace. That is the majority of the boot logic,
genuinely exercised.
**A container run does NOT prove:** the machine boots. There is no kernel
handoff, no initramfs, no `switch_root`, no real device or filesystem bring-up.
The container shares the host kernel. `bootstrap.sh`'s arch detection runs, but
its artifact-fetch and exec path is not the initramfs path.
Say both halves. "It runs in Docker" is true and useful; "it boots" is not
established by this skill.
## Step 1 - produce a state file
If the user has no `numos.state`, export one from the repo:
```sh
cd "$REPO"
python3 -m numos.export_state --out dist # POSIX
# py -m numos.export_state --out dist # Windows
```
Without a reachable IntrikataTopology instance, `--ops-url` will fail. Then build
from seed data only, which is a legitimate minimal state:
```sh
python3 -c "from numos.export_state import export; export([], 'dist')"
```
Tell the user which one they got. A seed-only state has the infrastructure units
(mount, net, clock, identity, join) and no op units.
## Step 2 - a Containerfile that does not lie
```dockerfile
# NumericalOS - graph-driven init, container target.
# The graph IS the configuration: no unit files, one numos.state.
FROM busybox:musl
# The boot chain. numinit.sh is the shell PID-1 path.
COPY boot/ /boot/
COPY dist/numos.state /etc/numos.state
# numinit reads exactly one artifact and refuses a state whose
# content hash does not match its C record.
ENV NUMOS_STATE=/etc/numos.state
ENTRYPOINT ["/bin/sh", "/boot/numinit.sh"]
```
Three details that matter and are easy to get wrong:
- **`busybox:musl`, not `scratch`.** `numinit.sh` is a POSIX shell script; it
needs a shell, plus `grep`, `cut`, `head`, `sort`, `sed`, `tr`. `FROM scratch`
produces an image that cannot start and a confusing `exec format error`.
- **`NUMOS_STATE` must be set.** `numos_main` halts with
`numos: HALT: NUMOS_STATE is unset` otherwise. That is correct fail-closed
behavior, not a bug to work around.
- **Do not `COPY` the repo wholesale.** Ship `boot/` and the state. The Python
side is a build-time tool, not a runtime dependency.
## Step 3 - build
```sh
cd "$REPO"
docker build -t numericalos:dev -f Containerfile .
# podman build -t numericalos:dev -f Containerfile .
```
For a foreign architecture, be explicit rather than hoping:
```sh
docker buildx build --platform linux/arm64 -t numericalos:arm64 -f Containerfile .
```
Cross-arch builds need `binfmt_misc`/QEMU registered on the host. If the user has
not done that, `docker run --privileged --rm tonistiigi/binfmt --install all`
registers it. Say that this modifies host state before running it.
## Step 4 - run, and read the output properly
```sh
docker run --rm numericalos:dev
```
Expected output from a healthy seed-only state is the phase walk followed by:
```
numos: boot complete degraded=0
```
Interpret honestly:
| Output | Means |
|---|---|
| `boot complete degraded=0` | every phase succeeded |
| `boot complete degraded=1` | a `degrade`-policy phase failed; node would advertise itself degraded |
| `numos: HALT: phase <n> failed and is on_failure=halt` | correct fail-closed halt, nonzero exit |
| `numos: HALT: state hash mismatch` | the state file does not match its `C` record |
| `numos: HALT: NUMOS_STATE is unset` | you forgot `ENV NUMOS_STATE` |
| hangs | a `longrun` unit is blocking - see below |
**On the seed state, expect failures.** The seeded units run `mount -t proc`,
`ip link set eth0 up`, `ntpd`, and `numctl` - a container has `/proc` already
mounted, no `eth0` to configure that way, and `numctl` does not exist yet. Phases
with `on_failure=degrade` or `continue` will report and continue; the `mount` and
`identity` phases are `halt`. This is the system working correctly on inputs that
do not match its environment. Do not "fix" it by weakening the policy - if the
user wants a container-appropriate state, change the state, which is the point of
a graph-driven init.
## Step 5 - a container-appropriate state, if the user wants a clean run
Build one from the exporter rather than hand-editing the state file - hand-editing
breaks the `C` content hash and `numinit` will correctly refuse it.
```python
from numos.export_state import export
from numos import seed
seed.INFRA_UNITS = [
{"name": "hello", "kind": "oneshot", "restart": "never",
"backoff_ms": 0, "backoff_max_ms": 0, "arch_mask": [],
"health_probe": None, "requires": [], "after": [],
"exec": "echo numericalos: container unit ok"},
]
seed.BOOT_PHASES = [
{"ordinal": 10, "name": "hello", "on_failure": "halt",
"required_units": ["hello"]},
]
export([], "dist")
```
Then rebuild. This demonstrates the actual claim of the project: changing what
the machine does is a data change, not a code change.
## Runtime differences - named, not flattened
| Runtime | What differs | What to tell the user |
|---|---|---|
| **Docker / containerd / nerdctl** | Daemon runs as root; your process is PID 1 of its namespace | Baseline. Everything above applies unchanged. |
| **Podman rootless** | User namespace; UID 0 inside maps to your UID outside | Closest to a real unprivileged init. Any unit needing real `mount` will fail - correctly. Use `--userns=keep-id` if the user cares about file ownership. |
| **LXC / systemd-nspawn** | A *system* container: full userspace, real init slot | Highest fidelity short of a VM. `numinit` runs as actual PID 1 with its own PID namespace and can genuinely supervise. Needs a Linux host and more setup. Recommend when the user wants to exercise supervision rather than just the phase walk. |
**The `longrun` caveat, which applies to all three.** `numinit` backgrounds
`longrun` units and returns success once started - it does not wait. A container
whose only long-running unit is backgrounded will run its phase walk and then
**exit immediately**, because PID 1 finished. That is correct for the phase walk
and surprising if you expected a persistent container. If the user wants the
container to stay up, they need a foreground supervisor - which `numinit` does
not yet invoke (`numos_supervise` exists and is tested but has no caller). Say
that rather than papering over it with a `sleep infinity`.
## Reporting
State: image tag, base image, arch, whether the run completed or halted, the
final `numos:` line verbatim, and which units failed if any. Then state the
boundary: "this exercised the phase walk and DAG resolution under a shared host
kernel; it did not boot a machine."
## Related skills
- `numericalos-target-classifier` - run first
- `numericalos-build-initramfs` - real initramfs, needs Linux
- `numericalos-build-iso` - bootable media
- `numericalos-verify-boot` - QEMU; the only skill that may claim a boot