Docker Subsystem
v0.2.4
Docker container management with contract-based API
Run
wippy run userspace/dockeruserspace/docker
Docker container and image management via contracts and process messaging.
This package depends on userspace/docker-client. The existing
userspace.docker:docker_client library re-exports userspace.docker.client:client.
Hosts needing only the Docker HTTP client can depend on that package without
installing these services,
database requirements or migrations.
Features
- Container lifecycle management (create, start, stop, delete)
- Image operations (pull, build, list, delete)
- Network and volume management
- Port mapping (host:container)
- Live log streaming via process messaging
- Interactive and managed execution modes
- Compose-style multi-container groups
- Declarative containers via registry entries (auto-start on boot)
- Dynamic pickup of new container entries at runtime via registry events
Installation
- name: dependency.docker
kind: ns.dependency
component: userspace/docker
version: ">=0.2.0"
Requirements
| Requirement | Default | Description |
|-------------|---------|-------------|
| database | app:db | Database resource for container and image storage |
| process_host | app:processes | Process host for docker services |
Interactive executor routes
Acknowledged, multi-write stdin uses Wippy's attached Docker executor. It is
enabled only by a registry allowlist; callers cannot select an executor. The
route image and the exec.docker image must be exactly equal. Digest-addressed
images are recommended so a mutable tag cannot change the sandbox between runs.
entries:
- name: agent_executor
kind: exec.docker
image: registry.example/agent@sha256:0123...
auto_remove: true
read_only_rootfs: true
no_new_privileges: true
- name: agent_interactive_route
kind: registry.entry
meta:
type: docker.interactive_executor
image: registry.example/agent@sha256:0123...
executor: app:agent_executor
The service validates every route at startup and again immediately before process creation. Missing, duplicated, replaced, non-Docker, or image-mismatched routes fail closed. The chosen route and executor IDs are persisted on the container, and stdin operation receipts identify that exact executor backend.
Declarative Containers
Register containers as registry.entry with meta.type: docker.container. The docker root process picks them up at startup and whenever a new entry is created at runtime.
This works like docker-compose: any wippy component can declare containers in its _index.yaml, and they start automatically when the component is installed.
Single container
entries:
- name: my_redis
kind: registry.entry
meta:
type: docker.container
image: redis:7
command: redis-server --appendonly yes
ports:
- { host: 6379, container: 6379 }
restart_policy: unless-stopped
Services vs jobs
A restart_policy marks a container as a long-lived service: the worker
starts it and follows its log stream for as long as it runs, including across
restarts performed by Docker's restart policy. Once it has stabilized its
lifecycle belongs to the restart policy and the monitor — it is never finalized
as a job or removed. A stopped/failed service is recreated on the next startup.
Without a restart_policy the container is treated as a finite job: its log
stream is followed until it exits, its logs and exit code are recorded, and it
is removed.
Logs are ingested from a single follow stream per container. The daemon pushes new output as it is written; the worker never re-reads log history or polls container state while the container runs. A dropped stream is reopened from the last delivered timestamp without duplicating lines.
Multi-service stack
entries:
- name: postgres_db
kind: registry.entry
meta:
type: docker.container
image: postgres:16
command: postgres
env:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
ports:
- { host: 5432, container: 5432 }
network: myapp-net
- name: app_server
kind: registry.entry
meta:
type: docker.container
image: myapp:latest
command: ./server --db postgres_db:5432
env:
DATABASE_URL: postgres://postgres:secret@postgres_db:5432/myapp
ports:
- { host: 8080, container: 8080 }
network: myapp-net
- name: nginx_proxy
kind: registry.entry
meta:
type: docker.container
image: nginx:latest
command: nginx -g 'daemon off;'
ports:
- { host: 80, container: 80 }
- { host: 443, container: 443 }
network: myapp-net
volumes:
- { host: /etc/nginx/conf.d, container: /etc/nginx/conf.d, mode: ro }
Available fields
| Field | Type | Description |
|-------|------|-------------|
| image | string | Docker image (default: alpine:latest) |
| command | string | Shell command, wrapped as sh -c (provide this or args) |
| args | array? | Raw Cmd args passed to the image entrypoint, no sh -c wrap (e.g. ["--http","--port","3001"] for an ENTRYPOINT-based server image). Takes precedence over command. |
| entrypoint | array? | Override the image ENTRYPOINT |
| name | string? | Container name |
| env | map | Environment variables |
| ports | array | Port mappings: {host, container, protocol?} |
| network | string? | Docker network name |
| volumes | array | Volume mounts: {host, container, mode?} |
| work_dir | string? | Working directory inside container |
| shm_size | integer? | Size of /dev/shm in bytes (positive integer; Docker HostConfig.ShmSize). Unset keeps the daemon default (64 MiB). |
| interactive | boolean? | Enable stdin for interactive containers |
| labels | map? | Container labels |
How it works
- A wippy component declares
docker.containerentries in its_index.yaml - On install (
wippy install), entries appear in the registry - The docker root process detects new entries via registry events
- Containers are created in the database and picked up by workers
- Workers pull images (if needed) and start containers via Docker API
No manual orchestration required. Install a component, containers start.
Contracts
userspace.docker:containers
local docker = contract.open("userspace.docker:containers")
-- Create a container
local result = docker:create({
image = "alpine:latest",
command = "echo hello",
name = "my-container",
env = { MY_VAR = "value" },
ports = { { host = 8080, container = 80 } },
network = "my-network",
memory_limit = 68719476736, -- bytes
shm_size = 34359738368, -- bytes of /dev/shm; unset keeps the daemon default
stream = { reply_to = process.self(), topic = "docker.logs" },
})
-- Get container by ID
local result = docker:get({ id = container_id })
-- List containers
local result = docker:list({ status = "running", limit = 10 })
-- Read a bounded page after an exclusive per-container cursor. Each line has
-- both a global log_id and a contiguous container-local sequence.
local result = docker:logs({ id = container_id, cursor = 0, limit = 100 })
local next_cursor = result.next_cursor
-- Read the newest lines (oldest first). next_cursor continues after them.
local recent = docker:logs({ id = container_id, tail = 200 })
-- Record-before-send stdin for an interactive executor. `dispatched` is not
-- delivery: poll stdin_status until delivered/failed/uncertain. Managed Docker
-- containers currently return `unsupported` because the package has no
-- attach-stream acknowledgement and never fabricates success.
local write = docker:stdin({
container_id = id,
data = "input\n",
operation_id = "turn-42",
request_digest = "sha256:...",
})
local status = docker:stdin_status({ operation_id = "turn-42" })
-- Delete container
docker:delete({ id = container_id })
-- Create multiple containers on a shared network
local result = docker:compose({
name = "my-group",
network = "my-net",
containers = {
{ image = "redis:latest", command = "redis-server", ports = { { host = 6379, container = 6379 } } },
{ image = "alpine:latest", command = "echo done" },
},
})
userspace.docker:networks
local networks = contract.open("userspace.docker:networks")
networks:create({ name = "my-network", driver = "bridge" })
local result = networks:list({})
networks:remove({ id = network_id })
userspace.docker:volumes
local volumes = contract.open("userspace.docker:volumes")
volumes:create({ name = "my-volume", driver = "local" })
local result = volumes:list({})
volumes:remove({ name = "my-volume" })
userspace.docker:images
local images = contract.open("userspace.docker:images")
-- List images
local result = images:list()
-- Pull an image
local result = images:pull({ image = "alpine", tag = "latest" })
-- Build from Dockerfile
local result = images:build({
name = "my-app",
tag = "v1",
dockerfile = "FROM alpine\nRUN echo hello",
stream = { reply_to = process.self(), topic = "build.logs" },
})
-- Check build status
local result = images:build_status({ build_id = build_id })
-- Delete an image
images:delete({ id = image_id })
userspace.docker:narrow
This contract is for orchestrators that already produce a complete protected
Docker API configuration. It accepts only immutable images, non-root users,
read-only root filesystems, dropped capabilities, no-new-privileges,
seccomp/AppArmor, positive PID/CPU/memory limits, non-host networking, bounded
binds, one hardened tmpfs, and exact bee.* attempt labels. Unknown or broader
Docker fields fail closed. ShmSize is optional; when set it must be a positive
integer no larger than Memory, since /dev/shm pages are charged to the
container memory cgroup. IpcMode is not accepted, so a sandbox never joins the
host IPC namespace.
Set config.Tty explicitly to true for a PTY or false for byte streams.
Both modes require attached stdin/stdout/stderr and open stdin, and pass the same
sandbox validation. create forwards the requested mode and returns the observed
container identity without starting it. Terminal attachment is a separate caller
responsibility; this operation does not create an interactive worker or grant
access to a terminal.
The Docker checks CI workflow exercises both modes through the default
contract binding against a real Linux Docker daemon. To run the same check,
install the locked dependencies in docker/test, then run
WIPPY=/absolute/path/to/wippy bash check_pty.sh from that directory. The daemon
must support AppArmor, seccomp, and cgroup v2; missing protections fail the check
instead of weakening the configuration. It removes only containers labelled
with that run's unique test directory.
local narrow = contract.get("userspace.docker:narrow"):open()
local created = narrow:create({ name = "bee-<digest>", config = protected_config })
narrow:start({ backend_ref = created.backend_ref })
local observed = narrow:inspect({ backend_ref = created.backend_ref })
narrow:stop({ backend_ref = created.backend_ref, timeout_seconds = 10 })
narrow:remove({ backend_ref = created.backend_ref })
The narrow surface is a low-level enforcement boundary, not a second
general-purpose container API. ExtraHosts is empty by default and permits
only the fixed Linux bridge mapping host.docker.internal:host-gateway; all
caller-selected aliases and addresses remain rejected.
Streaming Events
Methods that accept a stream parameter deliver live events via process messaging:
local result = docker:create({
image = "alpine:latest",
command = "echo hello",
stream = { reply_to = process.self(), topic = "my.events" },
})
local ch = process.listen("my.events")
while true do
local ev = ch:receive()
if ev.type == "done" then break end
if ev.type == "log" then
print(ev.stream .. ": " .. ev.line)
elseif ev.type == "status" then
print("status: " .. ev.status)
end
end
Observed container identity and cleanup
Narrow observations preserve Docker's created, running, paused,
restarting, removing, exited and dead states. Missing or unrecognized
states are unknown, never an inferred exit. observed_image_digest comes from
the daemon's actual image ID, not the requested bee.image_digest label; a
missing or malformed image ID is reported as an empty string. Callers must
compare observations with their own admitted identity and treat missing evidence
as uncertainty. Labels alone do not establish authorization or actual image
identity. A successful stop is reported as stopped only after observing
created or exited; paused/restarting/unknown states cannot establish it.
started_at preserves the daemon's State.StartedAt string, including its
fractional precision. It is absent when the daemon supplies no bounded string
(for example, list responses). Compare it with the admitted execution timestamp
before attaching or controlling a retained container: a container ID survives a
restart. The field is observed data, not authorization or an atomic control
fence; callers validate the timestamp and keep uncertain observations uncertain.
If a narrow remove request fails, a subsequent inspection must return Docker
HTTP 404 before the operation reports destroyed. Transport failures, denied
access and daemon errors leave removal unconfirmed; their diagnostic text is
never interpreted as proof of absence. The low-level client's
inspect_container supplies an optional third return value for the actual HTTP
status, preserving its existing result/error returns.
License
Apache-2.0