// STRYKE-DOCKER — DOCKER CLIENT FOR STRYKE // CONTAINERS + IMAGES + NETWORKS + VOLUMES + LOGS + EXEC + PRUNE

stryke package · cdylib libstryke_docker.{dylib,so} · dlopen'd in-process on use Docker · opt-in (kept out of stryke core)

Report GitHub Issues
// Color scheme

>_STRYKE-DOCKER

The docker daemon, scriptable from a one-liner. Docker client for stryke. Containers, images, networks, volumes, logs, exec, prune against any reachable Docker daemon (Docker Desktop, Linux daemon, Podman with the docker-API socket, remote DOCKER_HOST). The heavy bollard / tokio code ships as a Rust cdylib that stryke dlopens in-process on first use Docker.

Install

# from a release (no rustc on the consumer machine)
s pkg install -g github.com/MenkeTechnologies/stryke-docker

# from a local checkout
cd ~/projects/stryke-docker
cargo build --release            # produces target/release/libstryke_docker.{dylib,so}
s pkg install -g .               # cdylib lands in ~/.stryke/store/docker@<version>/

# one-liner
make install

The cdylib is dlopened in-process on first use Docker — no helper-binary fork per call. A shared tokio runtime + persistent bollard::Docker client is held in OnceCell for the life of the process; any stryke script that declares use Docker resolves the package automatically. The package is versioned 0.19.0 (stryke.toml / Cargo.toml) and is published with publish = false — it installs from a GitHub release or a local checkout, not from crates.io. make install wraps the build + s pkg install -g . step.

Quick start: use Docker

daemon version + pingp Docker::version()->{Version}; exit 1 unless Docker::ping()
daemon infoval $i = Docker::info(); p $i->{Containers}, $i->{Images}
list all containersval @all = Docker::ps(all => 1); p scalar(@all)
inspect onep Docker::inspect("web")->{State}{Status}
create + start in one shotval $r = Docker::run("nginx:alpine", name => "web")
buffered logs (last 50 lines)p Docker::logs("web", tail => "50")
exec (captured stdout+stderr)p Docker::exec("web", ["sh", "-c", "nginx -v"])
stop (timeout secs) + removeDocker::stop("web", timeout => 5); Docker::rm("web")
networks + volumesDocker::network_create("appnet"); Docker::volume_create("appdata")
reclaim spaceval $report = Docker::prune(all => 1)

Every wrapper accepts a trailing host => "<socket-or-tcp-url>" to override the daemon for that one call (see Connection model below); with no override the package targets $DOCKER_HOST or the local socket. The full API surface is enumerated in Daemon & container API and Daemon-free helpers, and in the README "API reference" section.

Connection model

There is no client to construct and no login step. The cdylib resolves a daemon endpoint per call: an explicit host => "…" option wins, otherwise $DOCKER_HOST, otherwise the platform default socket. A bollard::Docker client is built once per distinct endpoint and cached (keyed by DOCKER_HOST), so repeated calls reuse the same connection + HTTP pool instead of reconnecting.

local socket (default)Docker::ps()
environment overrideDOCKER_HOST=tcp://192.168.1.10:2375 s script.stk
per-call overrideDocker::ps(host => "tcp://docker.example.com:2376")

Works against Docker Desktop, a Linux daemon, Podman exposing the docker-API socket, or a remote DOCKER_HOST. TLS client certs (DOCKER_CERT_PATH / DOCKER_TLS_VERIFY) are on the roadmap, not shipped.

Daemon & container API

These wrappers each build a JSON args dict and call the matching docker__* symbol on the cdylib. A daemon must be reachable; on a daemon error the wrapper dies with Docker::<fn>: <message>. Connection opts (host) may trail any call.

Daemonversion() · ping() · info() · df() · pkg_version()
List / inspectps() · inspect($c) · images() · networks() · volumes() · image_inspect($i) · network_inspect($n) · volume_inspect($v)
Lifecyclecreate($img) · run($img) · start($c) · stop($c, timeout => N) · restart($c) · kill($c, signal => "…") · rm($c, force => 1, volumes => 1) · pause($c) · unpause($c) · rename($c, $new) · wait($c)
Introspecttop($c) · stats($c) · diff($c) · port($c) · update($c) · history($img) · commit($c) · exec_inspect($id)
Logs & execlogs($c, tail => "N") · exec($c, \@cmd) · resize($c, $w, $h) · resize_exec($id, $w, $h)
Imagespull($img) · rmi($img, force => 1) · tag($src, $dst) · search($term) · inspect_registry($img)
Networksnetwork_create($n) · network_rm($n) · network_connect($n, $c) · network_disconnect($n, $c)
Volumesvolume_create($n) · volume_rm($n)
Pruneprune(containers => 1, images => 1, volumes => 1, networks => 1, all => 1)

create/run forward name, cmd (arrayref) and env (arrayref of KEY=VALUE) to the daemon; run is create with start => 1. exec requires an arrayref command and returns captured stdout+stderr as a single string. The per-call result shapes (container dicts, inspect documents, prune report) are documented field-by-field in the README API reference.

Daemon-free helpers (pure, no socket)

A large share of the surface needs no daemon at all: parsers, validators and their inverse builders for Docker's CLI spec grammars. They run anywhere, are deterministic, and are what the test suite exercises without a live socket. Each returns a JSON dict.

parse an image refDocker::parse_image_ref("ghcr.io/o/a:1.2") # => { registry, namespace, repository, tag, digest, path }
canonicalize a refDocker::normalize_image_ref("nginx") # => docker.io/library/nginx:latest
reassemble a refDocker::build_image_ref(repository => "nginx", tag => "alpine")
validate a nameDocker::valid_container_name("web") # => 1; rejects names < 2 chars
validate a tag / repo / digest / refDocker::valid_image_tag("1.0") · valid_repository_name("o/a") · valid_digest("sha256:…") · valid_image_ref("o/a:1@sha256:…")
portsDocker::parse_port_spec("0.0.0.0:8080:80/tcp") # => { host_ip, host_port, container_port, protocol }; build_port_spec(…) is the inverse
mountsDocker::parse_mount("/data:/srv:ro") # => { type, source, target, readonly, options }; build_mount(…) inverse
env / labelDocker::parse_env("K=V") · build_env("K","V") · parse_label("K=V") · build_label("K","V")
devices / tmpfsDocker::parse_device("/dev/sda:/dev/xvda:rwm") · build_device(…) · parse_tmpfs("/run:ro") · build_tmpfs(…)
restart policyDocker::parse_restart_policy("on-failure:3") # => { policy, max_retries }; build_restart_policy(…) inverse
memoryDocker::parse_memory("512m") # => { bytes => 536870912, … }; format_memory(536870912) => "512m"
durationDocker::parse_duration("1h30m") # => nanoseconds; format_duration(5400000000000) => "1h30m0s"
cpus / signal / platform / ulimitDocker::parse_cpus("1.5") # => 1500000000 NanoCPUs · parse_signal("9") => SIGKILL · parse_platform("linux/arm64/v8") · parse_ulimit("nofile=1024")

Every parser has a builder inverse where the round-trip is defined (image ref, port, mount, env, label, device, restart policy, platform, memory, duration, tmpfs). The grammar rules each one enforces — tag ≤128 chars and no leading ./-, lowercase-only repository names, OCI algorithm:hex digests (sha256→64, sha512→128 lowercase hex), go-units base-1024 memory, Go time.ParseDuration durations — are spelled out per function in the README API reference.

Deferred operations

Four operations are present in the wrapper but die with a clear message rather than returning partial data — each needs an FFI shape the current blocking JSON-in/JSON-out boundary doesn't model yet:

Docker::logs_followstreaming logs — needs a callback FFI; use Docker::logs for snapshot reads
Docker::eventsstreaming daemon events — needs a callback FFI
Docker::pushneeds the registry-auth surface designed
Docker::buildneeds the tar + stream surface designed

One-shot stats (--no-stream) ships; the continuous-stream variants do not. Calling a deferred op raises a die naming the op and the reason, so scripts fail loudly instead of silently mis-behaving.

Runnable examples

The examples/ directory ships .stk scripts that are eval-guarded so they exit cleanly when no daemon is reachable:

discover.stkread-only tour: version, info, ps -a, image count
health.stkdaemon snapshot + per-container status via inspect
run.stkpull, run, inspect, stop, rm lifecycle
logs.stkbuffered logs read (the streaming follow path is deferred)
build.stkimage-build sketch (the build op is deferred)
# run any example
s examples/discover.stk

# point it at a remote daemon
DOCKER_HOST=tcp://localhost:2375 s examples/discover.stk

Testing

# compile-only — no live daemon needed (exercises the pure helpers)
cargo test

# live round-trip against a reachable daemon
DOCKER_HOST=unix:///var/run/docker.sock s test t/

The live tests under t/ pull busybox:latest, run a sleep container under a unique stryke-test-$$ name, create networks/volumes with unique names, exec into the container, and clean up at exit. t/test_stryke_docker_surface.stk is a wrapper-completeness pin so the .stk surface can't silently drop a function.

Why a package, not a builtin

bollard + tokio for full daemon-API coverage — opt-in so the stryke core binary stays under 40 MB.

The stryke side is a thin .stk wrapper that calls docker__* FFI symbols on the cdylib; the heavy code lives in libstryke_docker.{dylib,so}, dlopened in-process on first use Docker. Core stryke is never linked against this package's deps.

FFI layer

Each Docker::* wrapper builds a JSON args dict and calls a sibling docker__* symbol resolved out of libstryke_docker.{dylib,so}. The cdylib is dlopened in-process on first use Docker (via stryke's pkg::commands::try_load_ffi_for resolver hook). Dispatch is by symbol name, not by a verb field in the payload — the wrapper sends the call-specific keys directly (container, image, tail, …) plus an optional host override. The authoritative export list is [ffi].exports in stryke.toml.

Responses are JSON. A success returns the per-function result object directly; a failure returns {"error": "<msg>"} and the wrapper's _decode step raises die "Docker::<fn>: <msg>". A panic inside the handler is caught and surfaced as the same error shape rather than aborting the process.

# request (built by the .stk wrapper, dispatched by symbol name)
docker__stop({"container": "web", "timeout": 5})

# success — the per-fn result object, returned as-is
{"ok": true}
{"registry": "ghcr.io", "namespace": "o", "repository": "a", "tag": "1.2", ...}

# failure — wrapper die()s with the message
{"error": "No such container: web"}

Persistent state. A single shared tokio multi-thread runtime drives every async call, and a bollard::Docker client cache keyed by DOCKER_HOST reuses the same client + underlying HTTP pool across calls — no fork-per-call, no fresh connection each time. The returned CString is freed via the cdylib-exported stryke_free_cstring, wired automatically by stryke's cdylib loader.

Troubleshooting

Docker::ping returns false / version diesNo daemon at the resolved endpoint. Start Docker Desktop, sudo systemctl start docker, or set DOCKER_HOST to a reachable socket / tcp url.
die "Docker::<fn>: <msg>"The daemon returned an error (no such container, image not found, …). The message is the daemon's, passed through the FFI error envelope.
a deferred op dieslogs_follow, events, push, build are not implemented in the 0.19.0 cdylib — see Deferred operations.
Docker::exec: cmd must be arrayrefexec takes the command as an arrayref: Docker::exec("web", ["sh","-c","…"]).
helper validators want pure inputThe parse_* / valid_* / build_* helpers never touch a socket — if they fail it is bad input, not a daemon problem. valid_* returns { valid, reason } rather than dying.

Layout

stryke-docker/
├── Cargo.toml             # cdylib crate manifest (crate-type = ["cdylib"], publish = false)
├── src/
│   └── lib.rs            # cdylib — docker__* extern "C" exports
├── lib/                   # stryke .stk wrapper(s) — `use Docker`
├── stryke.toml            # stryke package manifest
├── t/                     # zunit-style tests
├── examples/              # runnable .stk examples
├── Makefile               # `make install` builds + installs
└── docs/                  # this site (GitHub Pages)

Sibling packages

Part of the stryke package family. Browse the others via the MenkeTechnologiesMeta umbrella repo: