// 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 }