// Package docker is a thin wrapper over the official Docker Engine Go SDK// (decision 8, tx9-cli-design.md). It exposes only what box lifecycle// commands need — images, containers, networks, volumes, exec — behind a// small struct so a remote daemon (SSH tunnel / DOCKER_HOST) can slot in// later without restructuring callers.package docker import ( "bytes" "context" "fmt" "io" "github.com/docker/docker/api/types/build" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" "github.com/docker/docker/pkg/jsonmessage" "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" "github.com/davis7dotsh/tx9/internal/assets") // Client wraps the Docker Engine API client with tx9-shaped helpers.type Client struct { cli *client.Client} // NewClient connects to the Docker daemon from the environment// (DOCKER_HOST/DOCKER_CERT_PATH/etc, same as the docker CLI) and negotiates// the API version so it works across a range of daemon versions.func NewClient(ctx context.Context) (*Client, error) { cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return nil, fmt.Errorf("docker: new client: %w", err) } if _, err := cli.Ping(ctx); err != nil { cli.Close() return nil, fmt.Errorf("docker: daemon unreachable: %w", err) } return &Client{cli: cli}, nil} // Close releases the underlying HTTP client's connections.func (c *Client) Close() error { return c.cli.Close()} // Raw exposes the underlying SDK client for calls this wrapper doesn't// (yet) cover.func (c *Client) Raw() *client.Client { return c.cli} // ImageExists reports whether tag is present in the local image store.func (c *Client) ImageExists(ctx context.Context, tag string) (bool, error) { _, err := c.cli.ImageInspect(ctx, tag) if err == nil { return true, nil } if client.IsErrNotFound(err) { return false, nil } return false, fmt.Errorf("docker: image inspect %s: %w", tag, err)} // BuildImage builds tag from buildContext (a tar stream — see// internal/assets.BuildContextTar), streaming daemon build progress as// human-readable lines to progress.func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, tag string, progress io.Writer) error { resp, err := c.cli.ImageBuild(ctx, buildContext, build.ImageBuildOptions{ Tags: []string{tag}, Dockerfile: assets.DockerfilePath, Remove: true, }) if err != nil { return fmt.Errorf("docker: image build %s: %w", tag, err) } defer resp.Body.Close() if err := jsonmessage.DisplayJSONMessagesStream(resp.Body, progress, 0, false, nil); err != nil { return fmt.Errorf("docker: image build %s: %w", tag, err) } return nil} // ListBoxContainers returns every container tx9 has ever labeled// (LabelManaged), running or not, so `tx9 list` can reconstruct state from// the daemon alone.func (c *Client) ListBoxContainers(ctx context.Context) ([]container.Summary, error) { f := filters.NewArgs(filters.Arg("label", LabelManaged+"=1")) containers, err := c.cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f}) if err != nil { return nil, fmt.Errorf("docker: list containers: %w", err) } return containers, nil} // NetworkCreate creates a private bridge network for a box, with IPv6// explicitly disabled at the network level (dossier §1.4 — compose's// default when no IPv6 config is requested; tx9 replicates it explicitly).func (c *Client) NetworkCreate(ctx context.Context, name string, labels map[string]string) (string, error) { enableIPv6 := false resp, err := c.cli.NetworkCreate(ctx, name, network.CreateOptions{ Driver: "bridge", EnableIPv6: &enableIPv6, Labels: labels, }) if err != nil { return "", fmt.Errorf("docker: network create %s: %w", name, err) } return resp.ID, nil} // NetworkRemove removes a network by ID or name.func (c *Client) NetworkRemove(ctx context.Context, id string) error { if err := c.cli.NetworkRemove(ctx, id); err != nil { return fmt.Errorf("docker: network remove %s: %w", id, err) } return nil} // VolumeCreate creates a named volume.func (c *Client) VolumeCreate(ctx context.Context, name string, labels map[string]string) (string, error) { v, err := c.cli.VolumeCreate(ctx, volume.CreateOptions{Name: name, Labels: labels}) if err != nil { return "", fmt.Errorf("docker: volume create %s: %w", name, err) } return v.Name, nil} // VolumeRemove removes a named volume.func (c *Client) VolumeRemove(ctx context.Context, name string, force bool) error { if err := c.cli.VolumeRemove(ctx, name, force); err != nil { return fmt.Errorf("docker: volume remove %s: %w", name, err) } return nil} // ContainerSpec describes a container tx9 wants created. It only covers the// fields the box topology (dossier §1) actually uses — not the full Engine// API surface.type ContainerSpec struct { Name string Image string Entrypoint []string Env []string Labels map[string]string Hostname string NetworkID string NetworkAliases []string Binds []string // "volume:/container/path" or "volume:/path:ro" ExposedPorts nat.PortSet PortBindings nat.PortMap Sysctls map[string]string NanoCPUs int64 MemoryBytes int64} // ContainerCreate creates (but does not start) a container per spec, with// `--init` and `restart: unless-stopped` baked in (dossier §1.3 — every box// container uses both).func (c *Client) ContainerCreate(ctx context.Context, spec ContainerSpec) (string, error) { initEnabled := true cfg := &container.Config{ Image: spec.Image, Entrypoint: spec.Entrypoint, Env: spec.Env, Labels: spec.Labels, Hostname: spec.Hostname, ExposedPorts: spec.ExposedPorts, } hostCfg := &container.HostConfig{ Binds: spec.Binds, PortBindings: spec.PortBindings, Sysctls: spec.Sysctls, Init: &initEnabled, RestartPolicy: container.RestartPolicy{ Name: container.RestartPolicyUnlessStopped, }, Resources: container.Resources{ NanoCPUs: spec.NanoCPUs, Memory: spec.MemoryBytes, }, } var netCfg *network.NetworkingConfig if spec.NetworkID != "" { netCfg = &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ spec.NetworkID: {Aliases: spec.NetworkAliases}, }, } } resp, err := c.cli.ContainerCreate(ctx, cfg, hostCfg, netCfg, nil, spec.Name) if err != nil { return "", fmt.Errorf("docker: container create %s: %w", spec.Name, err) } return resp.ID, nil} // ContainerStart starts a previously-created container.func (c *Client) ContainerStart(ctx context.Context, id string) error { if err := c.cli.ContainerStart(ctx, id, container.StartOptions{}); err != nil { return fmt.Errorf("docker: container start %s: %w", id, err) } return nil} // ContainerStop stops a running container (nil timeout uses the daemon// default grace period).func (c *Client) ContainerStop(ctx context.Context, id string, timeoutSeconds *int) error { if err := c.cli.ContainerStop(ctx, id, container.StopOptions{Timeout: timeoutSeconds}); err != nil { return fmt.Errorf("docker: container stop %s: %w", id, err) } return nil} // ContainerRemove removes a container. force also kills it if running.func (c *Client) ContainerRemove(ctx context.Context, id string, force bool) error { if err := c.cli.ContainerRemove(ctx, id, container.RemoveOptions{Force: force, RemoveVolumes: false}); err != nil { return fmt.Errorf("docker: container remove %s: %w", id, err) } return nil} // ContainerInspect returns full container state.func (c *Client) ContainerInspect(ctx context.Context, id string) (container.InspectResponse, error) { resp, err := c.cli.ContainerInspect(ctx, id) if err != nil { return container.InspectResponse{}, fmt.Errorf("docker: container inspect %s: %w", id, err) } return resp, nil} // ImageInspect returns full image metadata (used by `list` to show// per-box image version drift).func (c *Client) ImageInspect(ctx context.Context, tag string) (image.InspectResponse, error) { resp, err := c.cli.ImageInspect(ctx, tag) if err != nil { return image.InspectResponse{}, fmt.Errorf("docker: image inspect %s: %w", tag, err) } return resp, nil} // ImageList returns image summaries matching a reference filter (e.g.// "tx9-box:*"), used by `tx9 prune` to find stale box images.func (c *Client) ImageList(ctx context.Context, referenceFilter string) ([]image.Summary, error) { f := filters.NewArgs(filters.Arg("reference", referenceFilter)) images, err := c.cli.ImageList(ctx, image.ListOptions{Filters: f}) if err != nil { return nil, fmt.Errorf("docker: list images %s: %w", referenceFilter, err) } return images, nil} // ImageRemove removes an image by ID or tag.func (c *Client) ImageRemove(ctx context.Context, ref string) error { if _, err := c.cli.ImageRemove(ctx, ref, image.RemoveOptions{}); err != nil { return fmt.Errorf("docker: image remove %s: %w", ref, err) } return nil} // ExecResult is the outcome of a non-interactive Exec call.type ExecResult struct { Stdout string Stderr string ExitCode int} // Exec runs cmd inside containerID (no TTY), waits for completion, and// returns its combined stdout/stderr and exit code. This is the// "remote-control" pattern used to invoke `hb` inside the agent container// (dossier §3, `_guest_hb`) for calls like `up`/`doctor`/`wire-once`.//// Interactive/TTY exec (e.g. `tx9 enter`) needs raw terminal handling this// helper doesn't provide and should use Raw() directly.func (c *Client) Exec(ctx context.Context, containerID string, cmd []string, env []string, user string) (ExecResult, error) { created, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{ Cmd: cmd, Env: env, User: user, AttachStdout: true, AttachStderr: true, }) if err != nil { return ExecResult{}, fmt.Errorf("docker: exec create: %w", err) } attach, err := c.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{}) if err != nil { return ExecResult{}, fmt.Errorf("docker: exec attach: %w", err) } defer attach.Close() var stdout, stderr bytes.Buffer if _, err := stdcopy.StdCopy(&stdout, &stderr, attach.Reader); err != nil { return ExecResult{}, fmt.Errorf("docker: exec read output: %w", err) } inspect, err := c.cli.ContainerExecInspect(ctx, created.ID) if err != nil { return ExecResult{}, fmt.Errorf("docker: exec inspect: %w", err) } return ExecResult{ Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: inspect.ExitCode, }, nil}
// Package docker is a thin wrapper over the official Docker Engine Go SDK
// (decision 8, tx9-cli-design.md). It exposes only what box lifecycle
// commands need — images, containers, networks, volumes, exec — behind a
// small struct so a remote daemon (SSH tunnel / DOCKER_HOST) can slot in
// later without restructuring callers.
package docker
import (
"bytes"
"context"
"fmt"
"io"
"github.com/docker/docker/api/types/build"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
"github.com/davis7dotsh/tx9/internal/assets"
)
// Client wraps the Docker Engine API client with tx9-shaped helpers.
type Client struct {
cli *client.Client
}
// NewClient connects to the Docker daemon from the environment
// (DOCKER_HOST/DOCKER_CERT_PATH/etc, same as the docker CLI) and negotiates
// the API version so it works across a range of daemon versions.
func NewClient(ctx context.Context) (*Client, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("docker: new client: %w", err)
}
if _, err := cli.Ping(ctx); err != nil {
cli.Close()
return nil, fmt.Errorf("docker: daemon unreachable: %w", err)
}
return &Client{cli: cli}, nil
}
// Close releases the underlying HTTP client's connections.
func (c *Client) Close() error {
return c.cli.Close()
}
// Raw exposes the underlying SDK client for calls this wrapper doesn't
// (yet) cover.
func (c *Client) Raw() *client.Client {
return c.cli
}
// ImageExists reports whether tag is present in the local image store.
func (c *Client) ImageExists(ctx context.Context, tag string) (bool, error) {
_, err := c.cli.ImageInspect(ctx, tag)
if err == nil {
return true, nil
}
if client.IsErrNotFound(err) {
return false, nil
}
return false, fmt.Errorf("docker: image inspect %s: %w", tag, err)
}
// BuildImage builds tag from buildContext (a tar stream — see
// internal/assets.BuildContextTar), streaming daemon build progress as
// human-readable lines to progress.
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, tag string, progress io.Writer) error {
resp, err := c.cli.ImageBuild(ctx, buildContext, build.ImageBuildOptions{
Tags: []string{tag},
Dockerfile: assets.DockerfilePath,
Remove: true,
})
if err != nil {
return fmt.Errorf("docker: image build %s: %w", tag, err)
}
defer resp.Body.Close()
if err := jsonmessage.DisplayJSONMessagesStream(resp.Body, progress, 0, false, nil); err != nil {
return fmt.Errorf("docker: image build %s: %w", tag, err)
}
return nil
}
// ListBoxContainers returns every container tx9 has ever labeled
// (LabelManaged), running or not, so `tx9 list` can reconstruct state from
// the daemon alone.
func (c *Client) ListBoxContainers(ctx context.Context) ([]container.Summary, error) {
f := filters.NewArgs(filters.Arg("label", LabelManaged+"=1"))
containers, err := c.cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f})
if err != nil {
return nil, fmt.Errorf("docker: list containers: %w", err)
}
return containers, nil
}
// NetworkCreate creates a private bridge network for a box, with IPv6
// explicitly disabled at the network level (dossier §1.4 — compose's
// default when no IPv6 config is requested; tx9 replicates it explicitly).
func (c *Client) NetworkCreate(ctx context.Context, name string, labels map[string]string) (string, error) {
enableIPv6 := false
resp, err := c.cli.NetworkCreate(ctx, name, network.CreateOptions{
Driver: "bridge",
EnableIPv6: &enableIPv6,
Labels: labels,
})
if err != nil {
return "", fmt.Errorf("docker: network create %s: %w", name, err)
}
return resp.ID, nil
}
// NetworkRemove removes a network by ID or name.
func (c *Client) NetworkRemove(ctx context.Context, id string) error {
if err := c.cli.NetworkRemove(ctx, id); err != nil {
return fmt.Errorf("docker: network remove %s: %w", id, err)
}
return nil
}
// VolumeCreate creates a named volume.
func (c *Client) VolumeCreate(ctx context.Context, name string, labels map[string]string) (string, error) {
v, err := c.cli.VolumeCreate(ctx, volume.CreateOptions{Name: name, Labels: labels})
if err != nil {
return "", fmt.Errorf("docker: volume create %s: %w", name, err)
}
return v.Name, nil
}
// VolumeRemove removes a named volume.
func (c *Client) VolumeRemove(ctx context.Context, name string, force bool) error {
if err := c.cli.VolumeRemove(ctx, name, force); err != nil {
return fmt.Errorf("docker: volume remove %s: %w", name, err)
}
return nil
}
// ContainerSpec describes a container tx9 wants created. It only covers the
// fields the box topology (dossier §1) actually uses — not the full Engine
// API surface.
type ContainerSpec struct {
Name string
Image string
Entrypoint []string
Env []string
Labels map[string]string
Hostname string
NetworkID string
NetworkAliases []string
Binds []string // "volume:/container/path" or "volume:/path:ro"
ExposedPorts nat.PortSet
PortBindings nat.PortMap
Sysctls map[string]string
NanoCPUs int64
MemoryBytes int64
}
// ContainerCreate creates (but does not start) a container per spec, with
// `--init` and `restart: unless-stopped` baked in (dossier §1.3 — every box
// container uses both).
func (c *Client) ContainerCreate(ctx context.Context, spec ContainerSpec) (string, error) {
initEnabled := true
cfg := &container.Config{
Image: spec.Image,
Entrypoint: spec.Entrypoint,
Env: spec.Env,
Labels: spec.Labels,
Hostname: spec.Hostname,
ExposedPorts: spec.ExposedPorts,
}
hostCfg := &container.HostConfig{
Binds: spec.Binds,
PortBindings: spec.PortBindings,
Sysctls: spec.Sysctls,
Init: &initEnabled,
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyUnlessStopped,
},
Resources: container.Resources{
NanoCPUs: spec.NanoCPUs,
Memory: spec.MemoryBytes,
},
}
var netCfg *network.NetworkingConfig
if spec.NetworkID != "" {
netCfg = &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
spec.NetworkID: {Aliases: spec.NetworkAliases},
},
}
}
resp, err := c.cli.ContainerCreate(ctx, cfg, hostCfg, netCfg, nil, spec.Name)
if err != nil {
return "", fmt.Errorf("docker: container create %s: %w", spec.Name, err)
}
return resp.ID, nil
}
// ContainerStart starts a previously-created container.
func (c *Client) ContainerStart(ctx context.Context, id string) error {
if err := c.cli.ContainerStart(ctx, id, container.StartOptions{}); err != nil {
return fmt.Errorf("docker: container start %s: %w", id, err)
}
return nil
}
// ContainerStop stops a running container (nil timeout uses the daemon
// default grace period).
func (c *Client) ContainerStop(ctx context.Context, id string, timeoutSeconds *int) error {
if err := c.cli.ContainerStop(ctx, id, container.StopOptions{Timeout: timeoutSeconds}); err != nil {
return fmt.Errorf("docker: container stop %s: %w", id, err)
}
return nil
}
// ContainerRemove removes a container. force also kills it if running.
func (c *Client) ContainerRemove(ctx context.Context, id string, force bool) error {
if err := c.cli.ContainerRemove(ctx, id, container.RemoveOptions{Force: force, RemoveVolumes: false}); err != nil {
return fmt.Errorf("docker: container remove %s: %w", id, err)
}
return nil
}
// ContainerInspect returns full container state.
func (c *Client) ContainerInspect(ctx context.Context, id string) (container.InspectResponse, error) {
resp, err := c.cli.ContainerInspect(ctx, id)
if err != nil {
return container.InspectResponse{}, fmt.Errorf("docker: container inspect %s: %w", id, err)
}
return resp, nil
}
// ImageInspect returns full image metadata (used by `list` to show
// per-box image version drift).
func (c *Client) ImageInspect(ctx context.Context, tag string) (image.InspectResponse, error) {
resp, err := c.cli.ImageInspect(ctx, tag)
if err != nil {
return image.InspectResponse{}, fmt.Errorf("docker: image inspect %s: %w", tag, err)
}
return resp, nil
}
// ImageList returns image summaries matching a reference filter (e.g.
// "tx9-box:*"), used by `tx9 prune` to find stale box images.
func (c *Client) ImageList(ctx context.Context, referenceFilter string) ([]image.Summary, error) {
f := filters.NewArgs(filters.Arg("reference", referenceFilter))
images, err := c.cli.ImageList(ctx, image.ListOptions{Filters: f})
if err != nil {
return nil, fmt.Errorf("docker: list images %s: %w", referenceFilter, err)
}
return images, nil
}
// ImageRemove removes an image by ID or tag.
func (c *Client) ImageRemove(ctx context.Context, ref string) error {
if _, err := c.cli.ImageRemove(ctx, ref, image.RemoveOptions{}); err != nil {
return fmt.Errorf("docker: image remove %s: %w", ref, err)
}
return nil
}
// ExecResult is the outcome of a non-interactive Exec call.
type ExecResult struct {
Stdout string
Stderr string
ExitCode int
}
// Exec runs cmd inside containerID (no TTY), waits for completion, and
// returns its combined stdout/stderr and exit code. This is the
// "remote-control" pattern used to invoke `hb` inside the agent container
// (dossier §3, `_guest_hb`) for calls like `up`/`doctor`/`wire-once`.
//
// Interactive/TTY exec (e.g. `tx9 enter`) needs raw terminal handling this
// helper doesn't provide and should use Raw() directly.
func (c *Client) Exec(ctx context.Context, containerID string, cmd []string, env []string, user string) (ExecResult, error) {
created, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: cmd,
Env: env,
User: user,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return ExecResult{}, fmt.Errorf("docker: exec create: %w", err)
}
attach, err := c.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
if err != nil {
return ExecResult{}, fmt.Errorf("docker: exec attach: %w", err)
}
defer attach.Close()
var stdout, stderr bytes.Buffer
if _, err := stdcopy.StdCopy(&stdout, &stderr, attach.Reader); err != nil {
return ExecResult{}, fmt.Errorf("docker: exec read output: %w", err)
}
inspect, err := c.cli.ContainerExecInspect(ctx, created.ID)
if err != nil {
return ExecResult{}, fmt.Errorf("docker: exec inspect: %w", err)
}
return ExecResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: inspect.ExitCode,
}, nil
}