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: WithEngine option or auto-detect"]
sel --> envVars["Env vars: DOCKER_HOST, CONTAINER_HOST,\nDOCKER_CONTEXT, active Docker context,\nCONTAINER_ENGINE"]
envVars --> dockerDrv["Docker-API driver (moby client)"]
sel --> sockProbe["Socket probe: Docker, Podman, containerd"]
sockProbe --> dockerDrv
sockProbe --> ctrdDrv["containerd driver (containerd v2)"]
dockerDrv --> dockerSock["Docker socket"]
dockerDrv --> podmanSock["Podman socket (Docker-compatible API)"]
ctrdDrv --> ctrdSock["containerd socket"]
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 resolves the engine in this order and returns the first one that answers
a Ping:
DOCKER_HOSTenvironment variable (Docker engine; readsDOCKER_TLS_VERIFYandDOCKER_CERT_PATHfor TLS)CONTAINER_HOSTenvironment variable (Podman engine)DOCKER_CONTEXTenvironment variable (reads Docker context metadata)- Active context from
~/.docker/config.json(skipped when"default"or absent) CONTAINER_ENGINEenvironment variable (docker,podman, orcontainerd)- Docker socket (
/var/run/docker.sock, then~/.docker/run/docker.sock) - Podman rootless socket (
$XDG_RUNTIME_DIR/podman/podman.sockor~/.local/share/containers/podman/machine/podman.sock) - Podman rootful socket (
/run/podman/podman.sock) - containerd socket (
/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.
DOCKER_HOST and DOCKER_CONTEXT are mutually exclusive. Setting both returns
an error.
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, Endpoint.Host accepts either a raw socket path
(/run/containerd/containerd.sock) or a unix:// URI; both forms work. Set
Endpoint.Namespace to pick the namespace (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.
When a container needs to communicate with the Docker daemon (e.g. a CI sidecar
or a cloud-provider controller), it must bind-mount the daemon socket. Use
Endpoint.DaemonSocket — not Endpoint.Host — for this purpose.
On VM-based Docker setups (Lima, Colima, Docker Desktop, OrbStack, Rancher
Desktop), the forwarded socket the host connects through (e.g.
~/.lima/default/sock/docker.sock) cannot be bind-mounted into containers.
The daemon socket inside the VM is always /var/run/docker.sock. currus
auto-detects this and sets DaemonSocket correctly regardless of the platform:
if er, ok := eng.(currus.EndpointReporter); ok {
ep := er.Endpoint()
// ep.DaemonSocket is correct on Linux and macOS, native and VM-based.
mount := currus.Mount{
Type: currus.MountTypeBind,
Source: ep.DaemonSocket,
Target: "/var/run/docker.sock",
}
}DaemonSocket is empty for non-unix endpoints (tcp://, ssh://) where
bind-mounting is not possible. Override the auto-detected value with
WithDaemonSocket or the CURRUS_DAEMON_SOCKET environment variable:
// Programmatic override
eng, err := currus.New(ctx, currus.WithDaemonSocket("/custom/docker.sock"))
// Environment variable override
// CURRUS_DAEMON_SOCKET=/custom/docker.sockEvery Engine supports the universal container lifecycle:
if err := eng.PullImage(ctx, ref, currus.PullImageOpts{}); err != nil {
log.Fatal(err)
}
id, err := eng.CreateContainer(ctx, currus.ContainerSpec{Image: "nginx:latest"})
if err != nil {
log.Fatal(err)
}
if err := eng.StartContainer(ctx, id); err != nil {
log.Fatal(err)
}
if err := eng.StopContainer(ctx, id, currus.StopContainerOpts{Timeout: 10 * time.Second}); err != nil {
log.Fatal(err)
}
if err := eng.RemoveContainer(ctx, id, currus.RemoveContainerOpts{Force: true}); err != nil {
log.Fatal(err)
}
containers, err := eng.ListContainers(ctx, currus.ListContainersOpts{All: true})
if err != nil {
log.Fatal(err)
}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 {
result, err := ex.Exec(ctx, id, currus.ExecOpts{Cmd: []string{"redis-cli", "ping"}})
if err != nil {
log.Fatal(err)
}
_ = result
}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:
Rootlessistruewhen the daemon is running without root privileges. For Docker and Podman this is detected by querying the daemon at engine initialization time (docker info/podman info). For containerd it is inferred from the socket path: a socket under$XDG_RUNTIME_DIRis treated as rootless.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() // *currustest.Fake: implements Engine and every capability interface
// ... drive the same code path against the fake ...
}Use functional options to configure the fake's reported identity and capabilities:
eng := currustest.New(
currustest.WithKind(currus.Docker),
currustest.WithCaps(currus.Caps{Rootless: true}),
currustest.WithEndpoint(currus.Endpoint{
Host: "unix:///var/run/docker.sock",
DaemonSocket: "/var/run/docker.sock",
}),
)
// eng.Kind() == currus.Docker
// eng.Capabilities().Rootless == trueThe 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