Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

31 Commits

Repository files navigation

Currus

CIcodecovGo ReferenceGo Report CardLicenseSlack

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.

Try it

go get gopherly.dev/currus
go run ./examples/basic/...

Important

Requires Go 1.26 or later.

[!NOTE] The example needs a reachable Docker or Podman daemon on its default socket.

Why 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.

How it works

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"]
Loading

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.

Contents

  1. Try it
  2. Quick start
  3. Auto-detection
  4. Explicit engine selection
  5. Remote and rootless engines
  6. Container lifecycle
  7. Capability interfaces
  8. Logging and tracing
  9. Error handling
  10. Testing
  11. Engine capability matrix
  12. Examples
  13. License
  14. Community
  15. Contributing

Quick start

import"gopherly.dev/currus"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()))
defereng.Close()
iferr:=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"},
})
iferr!=nil {
log.Fatalf("create: %v", err)
}
iferr:=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.

Auto-detection

New resolves the engine in this order and returns the first one that answers a Ping:

  1. DOCKER_HOST environment variable (Docker engine; reads DOCKER_TLS_VERIFY and DOCKER_CERT_PATH for TLS)
  2. CONTAINER_HOST environment variable (Podman engine)
  3. DOCKER_CONTEXT environment variable (reads Docker context metadata)
  4. Active context from ~/.docker/config.json (skipped when "default" or absent)
  5. CONTAINER_ENGINE environment variable (docker, podman, or containerd)
  6. Docker socket (/var/run/docker.sock, then ~/.docker/run/docker.sock)
  7. Podman rootless socket ($XDG_RUNTIME_DIR/podman/podman.sock or ~/.local/share/containers/podman/machine/podman.sock)
  8. Podman rootful socket (/run/podman/podman.sock)
  9. 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.

Explicit engine selection

eng, err:=currus.New(ctx, currus.WithEngine(currus.Podman))

Available EngineKind values: currus.Docker, currus.Podman, currus.Containerd.

Remote and rootless engines

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.sock for a local socket (the default)
  • tcp://host:2376 for a remote daemon over TCP (use TLSConfig for mutual TLS)
  • ssh://user@host for a remote Podman or Docker daemon over SSH
  • npipe:////./pipe/docker_engine for 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.

Bind-mounting the daemon socket

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:

ifer, 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 overrideeng, err:=currus.New(ctx, currus.WithDaemonSocket("/custom/docker.sock"))
// Environment variable override// CURRUS_DAEMON_SOCKET=/custom/docker.sock

Container lifecycle

Every Engine supports the universal container lifecycle:

iferr:=eng.PullImage(ctx, ref, currus.PullImageOpts{}); err!=nil {
log.Fatal(err)
}
id, err:=eng.CreateContainer(ctx, currus.ContainerSpec{Image: "nginx:latest"})
iferr!=nil {
log.Fatal(err)
}
iferr:=eng.StartContainer(ctx, id); err!=nil {
log.Fatal(err)
}
iferr:=eng.StopContainer(ctx, id, currus.StopContainerOpts{Timeout: 10*time.Second}); err!=nil {
log.Fatal(err)
}
iferr:=eng.RemoveContainer(ctx, id, currus.RemoveContainerOpts{Force: true}); err!=nil {
log.Fatal(err)
}
containers, err:=eng.ListContainers(ctx, currus.ListContainersOpts{All: true})
iferr!=nil {
log.Fatal(err)
}

Capability interfaces

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.iflg, ok:=eng.(currus.Logger); ok {
rc, _:=lg.ContainerLogs(ctx, id, currus.ContainerLogsOpts{Follow: false, Tail: 100})
deferrc.Close()
io.Copy(os.Stdout, rc)
}
// Execifex, ok:=eng.(currus.Execer); ok {
result, err:=ex.Exec(ctx, id, currus.ExecOpts{Cmd: []string{"redis-cli", "ping"}})
iferr!=nil {
log.Fatal(err)
}
_=result
}

The full set of capability interfaces:

InterfaceWhat it does
Loggerread container log streams
Execerrun a command inside a container
Inspectorread full container metadata
Staterread point-in-time CPU and memory usage
Waiterblock until a container exits
Eventersubscribe to engine lifecycle events
Imagerlist, remove, and tag images
Networkercreate, list, and remove networks
Volumercreate, list, and remove named volumes
Copiercopy 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:

  • Rootless is true when 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_DIR is treated as rootless.
  • NamespaceModel names the isolation model, for example "containerd".

Logging and tracing

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),
)

Error handling

Currus normalizes engine errors into a small, stable set of sentinels you can match with errors.Is:

iferr:=eng.RemoveContainer(ctx, id, currus.RemoveContainerOpts{Force: true}); err!=nil {
iferrors.Is(err, currus.ErrNotFound) {
// already gone, which is fine
} else {
returnfmt.Errorf("remove container: %w", err)
}
}

Sentinel errors: ErrNotFound, ErrAlreadyExists, ErrConflict, ErrNotImplemented, ErrUnsupported, and ErrNoEngine (returned by New when no reachable engine is found).

Testing

Swap the real engine for an in-memory fake in your tests, so you need no daemon:

import"gopherly.dev/currus/currustest"funcTestStartsCache(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 == true

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:

funcTestConformance(t*testing.T) {
conformance.Run(t, func(t*testing.T) currus.Engine {
returncurrustest.New()
})
}

Engine capability matrix

Yes means the engine implements the interface. No means a type assertion to that interface returns ok == false.

CapabilityDockerPodmancontainerd
Core lifecycle (Engine)YesYesYes
Logs (Logger)YesYesNo
Exec (Execer)YesYesNo
Inspect (Inspector)YesYesNo
Stats (Stater)YesYesNo
Wait (Waiter)YesYesNo
Events (Eventer)YesYesNo
Images (Imager)YesYesNo
Networks (Networker)YesYesNo
Volumes (Volumer)YesYesNo
Copy files (Copier)YesYesNo

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.

Examples

See the examples/ directory for complete runnable programs. examples/basic covers auto-detect, pull, create, start, read logs, and clean up (same command as Try it).

License

Currus is released under the Apache License 2.0. See LICENSE.

Community

Join #gopherly on the Gophers Slack.

Contributing

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 (each app starts an ephemeral engine):
nix run .#test-docker
nix run .#test-podman
nix run .#test-containerd # requires sudo
nix run .#test-dind # DinD conformance only

About

Go package: neutral container API for Docker, Podman, and containerd. Auto-detects the host engine via native client APIs, no CLI subprocesses.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages