Currus is a Go package that provides a single, neutral API for running and managing containers. It does not care which engine is installed on the host. It detects whether Docker, Podman, or containerd is present and drives whatever it finds through each engine's client API, so it never shells out to a CLI. Write your container logic once against one interface; Currus adapts to whatever runs underneath.
go get gopherly.dev/currusImportant
Requires Go 1.26 or later.
import "gopherly.dev/currus"- One interface for Docker, Podman, and containerd. Write the code once.
- Auto-detection that pings each candidate before it trusts the socket. A stale socket file does not count as a live engine.
- Optional features live behind capability interfaces, so a missing feature is
a typed
ok == false, not a surprise at runtime. - Errors are normalized into a small set of sentinels you can match with
errors.Is. - Built for testing: an in-memory fake and a shared conformance suite ship with the package.
- Native client calls only. No CLI subprocesses to install, parse, or trust.
flowchart TD
caller["Caller code"] --> api["Engine interface plus capability interfaces"]
api --> sel["New: auto-detect or WithEngine(kind)"]
sel --> dockerDrv["Docker-API driver (moby client)"]
sel --> ctrdDrv["containerd driver (containerd v2)"]
dockerDrv --> dockerSock["Docker socket"]
dockerDrv --> podmanSock["Podman socket (Docker-compatible API)"]
ctrdDrv --> ctrdSock["containerd socket"]
style caller fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style api fill:#fef3c7,stroke:#f59e0b,color:#78350f
style sel fill:#fef3c7,stroke:#f59e0b,color:#78350f
style dockerDrv fill:#d1fae5,stroke:#10b981,color:#064e3b
style ctrdDrv fill:#d1fae5,stroke:#10b981,color:#064e3b
style dockerSock fill:#ede9fe,stroke:#8b5cf6,color:#3b0764
style podmanSock fill:#ede9fe,stroke:#8b5cf6,color:#3b0764
style ctrdSock fill:#ede9fe,stroke:#8b5cf6,color:#3b0764
The Docker-API driver serves both Docker and Podman, because Podman speaks the Docker Engine API. The containerd driver speaks the containerd v2 client API and adapts containerd to the same neutral, Docker-like model.
- Quick start
- Auto-detection
- Explicit engine selection
- Remote and rootless engines
- Container lifecycle
- Capability interfaces
- Logging and tracing
- Error handling
- Testing
- Engine capability matrix
- Examples
- License
- Community
- Contributing
ctx := context.Background()
// Zero-config: detects whatever engine is installed.
// MustNew panics if no engine is reachable, which is handy at startup.
// Use New when you want to handle the error yourself.
eng := currus.MustNew(ctx, currus.WithLogger(slog.Default()))
defer eng.Close()
if err := eng.PullImage(ctx, "docker.io/library/redis:7", currus.PullImageOpts{}); err != nil {
log.Fatalf("pull: %v", err)
}
id, err := eng.CreateContainer(ctx, currus.ContainerSpec{
Image: "docker.io/library/redis:7",
Name: "cache",
Env: []string{"REDIS_ARGS=--save 60 1"},
})
if err != nil {
log.Fatalf("create: %v", err)
}
if err := eng.StartContainer(ctx, id); err != nil {
log.Fatalf("start: %v", err)
}Warning
MustNew panics if no engine is reachable. Use New when you want to
handle the error yourself.
New probes endpoints in this order and returns the first one that answers a
Ping:
CONTAINER_ENGINEenvironment variable (docker,podman, orcontainerd)- Docker socket (
unix:///var/run/docker.sock) - Podman rootless socket (
$XDG_RUNTIME_DIR/podman/podman.sock) - Podman rootful socket (
unix:///run/podman/podman.sock) - containerd socket (
unix:///run/containerd/containerd.sock)
Each candidate is validated with Ping before it is returned. A stale socket
file that no daemon is listening on does not count as a live engine.
eng, err := currus.New(ctx, currus.WithEngine(currus.Podman))Available EngineKind values: currus.Docker, currus.Podman,
currus.Containerd.
Use WithEndpoint to point at a non-default socket or a remote daemon. The
Endpoint type supports several URI schemes:
// Remote Docker over TCP with mutual TLS.
eng, err := currus.New(ctx,
currus.WithEngine(currus.Docker),
currus.WithEndpoint(currus.Endpoint{
Host: "tcp://docker-host:2376",
TLS: &currus.TLSConfig{
CACert: caCertPEM,
Cert: certPEM,
Key: keyPEM,
},
}),
)Supported schemes:
unix:///var/run/docker.sockfor a local socket (the default)tcp://host:2376for a remote daemon over TCP (useTLSConfigfor mutual TLS)ssh://user@hostfor a remote Podman or Docker daemon over SSHnpipe:////./pipe/docker_enginefor a Windows named pipe
For containerd, set Endpoint.Namespace to pick the namespace. It defaults to
default.
Rootless Docker and rootless Podman are picked up by auto-detection through the
XDG_RUNTIME_DIR socket path, so they usually work with no extra configuration.
Every Engine supports the universal container lifecycle:
eng.PullImage(ctx, ref, currus.PullImageOpts{})
id, _ := eng.CreateContainer(ctx, currus.ContainerSpec{Image: "nginx:latest"})
eng.StartContainer(ctx, id)
eng.StopContainer(ctx, id, currus.StopContainerOpts{Timeout: 10 * time.Second})
eng.RemoveContainer(ctx, id, currus.RemoveContainerOpts{Force: true})
containers, _ := eng.ListContainers(ctx, currus.ListContainersOpts{All: true})Not every engine supports every feature, so non-universal features live behind optional capability interfaces. You discover them at runtime with a type assertion. This lets you branch cleanly instead of assuming a feature is there:
// Logs: containerd has no native container logs.
if lg, ok := eng.(currus.Logger); ok {
rc, _ := lg.ContainerLogs(ctx, id, currus.ContainerLogsOpts{Follow: false, Tail: 100})
defer rc.Close()
io.Copy(os.Stdout, rc)
}
// Exec
if ex, ok := eng.(currus.Execer); ok {
ex.Exec(ctx, id, currus.ExecOpts{Cmd: []string{"redis-cli", "ping"}})
}The full set of capability interfaces:
| Interface | What it does |
|---|---|
Logger |
read container log streams |
Execer |
run a command inside a container |
Inspector |
read full container metadata |
Stater |
read point-in-time CPU and memory usage |
Waiter |
block until a container exits |
Eventer |
subscribe to engine lifecycle events |
Imager |
list, remove, and tag images |
Networker |
create, list, and remove networks |
Volumer |
create, list, and remove named volumes |
Copier |
copy files into and out of a container |
For traits that are not method-shaped, call eng.Capabilities(). It returns a
Caps value with these fields:
RootlessCapablereports whether the driver runs rootless.SupportsPodsreports whether the engine groups containers into pods.OneShotRunreports whether the engine can run a container in a single call.NamespaceModelnames the isolation model, for example"containerd".
Pass a *slog.Logger with WithLogger to see structured debug output for each
operation. Pass an OpenTelemetry TracerProvider with WithTracerProvider to
wrap each engine call in a span named currus.<method>:
eng, err := currus.New(ctx,
currus.WithLogger(slog.Default()),
currus.WithTracerProvider(tp),
)Currus normalizes engine errors into a small, stable set of sentinels you can
match with errors.Is:
if err := eng.RemoveContainer(ctx, id, currus.RemoveContainerOpts{Force: true}); err != nil {
if errors.Is(err, currus.ErrNotFound) {
// already gone, which is fine
} else {
return fmt.Errorf("remove container: %w", err)
}
}Sentinel errors: ErrNotFound, ErrAlreadyExists, ErrConflict,
ErrNotImplemented, ErrUnsupported, and ErrNoEngine (returned by New
when no reachable engine is found).
Swap the real engine for an in-memory fake in your tests, so you need no daemon:
import "gopherly.dev/currus/currustest"
func TestStartsCache(t *testing.T) {
eng := currustest.New() // implements Engine and every capability interface
// ... drive the same code path against the fake ...
}The conformance package holds a shared behavioural test suite
that checks any Engine against the neutral contract. It runs against the
in-memory fake on every unit run, and against real daemons in the integration
layer:
func TestConformance(t *testing.T) {
conformance.Run(t, func(t *testing.T) currus.Engine {
return currustest.New()
})
}Yes means the engine implements the interface. No means a type assertion to
that interface returns ok == false.
| Capability | Docker | Podman | containerd |
|---|---|---|---|
Core lifecycle (Engine) |
Yes | Yes | Yes |
Logs (Logger) |
Yes | Yes | No |
Exec (Execer) |
Yes | Yes | No |
Inspect (Inspector) |
Yes | Yes | No |
Stats (Stater) |
Yes | Yes | No |
Wait (Waiter) |
Yes | Yes | No |
Events (Eventer) |
Yes | Yes | No |
Images (Imager) |
Yes | Yes | No |
Networks (Networker) |
Yes | Yes | No |
Volumes (Volumer) |
Yes | Yes | No |
Copy files (Copier) |
Yes | Yes | No |
Note
The containerd driver implements only the core Engine today. containerd has
no native container logs through its client API, and the other capabilities
are not yet adapted to its model.
See the examples/ directory for complete runnable programs:
examples/basiccovers auto-detect, pull, create, start, read logs, and clean up.
Run any example with:
go run ./examples/basic/...Currus is released under the Apache License 2.0. See LICENSE.
Join #gopherly on the Gophers Slack.
nix develop # enter the dev shell (auto-loaded via .envrc + direnv)
nix run .#lint # run golangci-lint
nix run .#fmt # auto-fix formatting
nix run .#test-unit # run unit tests (no daemon required)
# run integration tests against a real engine:
CURRUS_TEST_ENGINE=docker nix run .#test-integration
CURRUS_TEST_ENGINE=podman nix run .#test-integration