767cc89e6c
github.com/docker/docker is frozen at v28.5.2 — moby publishes v29+ under
github.com/moby/moby/v2, with the API client split out into the standalone
github.com/moby/moby/client and github.com/moby/moby/api modules. That leaves
five Dependabot alerts on the old module path with no reachable fix version:
CVE-2026-42306 high docker cp bind mount redirection <= 28.5.2
CVE-2026-41567 high PUT /containers/{id}/archive <= 28.5.2
CVE-2026-41568 medium docker cp symlink swap <= 28.5.2
CVE-2026-34040 high AuthZ plugin bypass < 29.3.1
CVE-2026-33997 medium plugin privilege off-by-one < 29.3.1
All five are daemon-side defects, and we only ever imported the client. Moving
to the split client module drops the daemon source out of the module graph
entirely, so these alerts resolve and future daemon advisories will not flag us
at all. It also trims six transitive dependencies.
The new client takes an options struct per call and returns a result struct, so
this is a mechanical rewrite of the wrapper. Behavioural notes:
- Ports moved from docker/go-connections/nat to moby/api/types/network, where
Port is opaque and PortBinding.HostIP is a netip.Addr. buildDockerPortBindings
now returns an error for out-of-range ports and unparseable host IPs instead
of handing the daemon a malformed request. An empty HostIP still maps to the
zero address, preserving "publish on all interfaces".
- DiskUsage needs Verbose to get per-image and per-container records; the
aggregate LayersSize is now Images.TotalSize.
- BuildCachePrune no longer takes KeepStorage — the client derives the legacy
keep-storage parameter from ReservedSpace for daemons on API <= 1.47.
- ImagesPrune is now ImagePrune.
- Container state and health are typed string enums, and InspectResponse.Config
and .State are pointers, so inspect mapping guards for nil.
Also corrects the RUSTSEC-2024-0429 note in the cargo-audit ignore list. It
claimed the advisory resolves once Tauri moves to webkit2gtk-4.1, but Tauri v2
is already on those bindings and still resolves glib to 0.18.5; the real
blocker is that gtk3-rs is archived. Backport submitted upstream.
Co-authored-by: Kandev Agent <agent@kandev.dev>
898 lines
26 KiB
Go
898 lines
26 KiB
Go
// Package docker wraps the Docker SDK to provide container lifecycle operations.
|
|
package docker
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net"
|
|
"net/netip"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agent/runtime/activity"
|
|
"github.com/kandev/kandev/internal/common/config"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/moby/moby/api/types/container"
|
|
"github.com/moby/moby/api/types/mount"
|
|
"github.com/moby/moby/api/types/network"
|
|
"github.com/moby/moby/client"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ContainerConfig holds configuration for creating a container.
|
|
type ContainerConfig struct {
|
|
Name string
|
|
Image string
|
|
Entrypoint []string // Overrides the image ENTRYPOINT (nil = use image default)
|
|
Cmd []string
|
|
Env []string // Environment variables
|
|
WorkingDir string
|
|
Mounts []MountConfig
|
|
NetworkMode string
|
|
Memory int64 // Memory limit in bytes
|
|
CPUQuota int64 // CPU quota
|
|
Labels map[string]string
|
|
AutoRemove bool
|
|
PortBindings []PortBindingConfig
|
|
}
|
|
|
|
// PortBindingConfig describes a container port to publish on the Docker host.
|
|
type PortBindingConfig struct {
|
|
ContainerPort int
|
|
HostIP string
|
|
HostPort string
|
|
}
|
|
|
|
// MountConfig holds mount configuration.
|
|
type MountConfig struct {
|
|
Source string // Host path
|
|
Target string // Container path
|
|
ReadOnly bool
|
|
}
|
|
|
|
// ContainerInfo holds information about a running container.
|
|
type ContainerInfo struct {
|
|
ID string
|
|
Name string
|
|
Image string
|
|
State string // created, running, paused, restarting, removing, exited, dead
|
|
Status string // Human-readable status
|
|
StartedAt time.Time
|
|
FinishedAt time.Time
|
|
ExitCode int
|
|
Health string
|
|
Labels map[string]string
|
|
}
|
|
|
|
// Client wraps the Docker client.
|
|
type containerRemover interface {
|
|
ContainerRemove(context.Context, string, client.ContainerRemoveOptions) (client.ContainerRemoveResult, error)
|
|
}
|
|
|
|
type imageBuilder interface {
|
|
ImageBuild(context.Context, io.Reader, client.ImageBuildOptions) (client.ImageBuildResult, error)
|
|
}
|
|
|
|
type Client struct {
|
|
cli *client.Client
|
|
storage storageAPI
|
|
remover containerRemover
|
|
builder imageBuilder
|
|
logger *logger.Logger
|
|
config config.DockerConfig
|
|
activity *activity.Coordinator
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewClient creates a new Docker client.
|
|
func NewClient(cfg config.DockerConfig, log *logger.Logger) (*Client, error) {
|
|
opts := []client.Opt{
|
|
client.WithAPIVersionNegotiation(),
|
|
}
|
|
|
|
if cfg.Host != "" {
|
|
opts = append(opts, client.WithHost(cfg.Host))
|
|
}
|
|
|
|
if cfg.APIVersion != "" {
|
|
opts = append(opts, client.WithVersion(cfg.APIVersion))
|
|
}
|
|
|
|
cli, err := client.New(opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create docker client: %w", err)
|
|
}
|
|
|
|
log.Info("Docker client created",
|
|
zap.String("host", cfg.Host),
|
|
zap.String("api_version", cfg.APIVersion),
|
|
)
|
|
|
|
return &Client{
|
|
cli: cli,
|
|
storage: cli,
|
|
remover: cli,
|
|
builder: cli,
|
|
logger: log,
|
|
config: cfg,
|
|
}, nil
|
|
}
|
|
|
|
// SetActivityCoordinator wires the optional install-wide host activity gate.
|
|
func (c *Client) SetActivityCoordinator(coordinator *activity.Coordinator) {
|
|
c.mu.Lock()
|
|
c.activity = coordinator
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// Close closes the Docker client.
|
|
func (c *Client) Close() error {
|
|
c.logger.Debug("Closing Docker client")
|
|
return c.cli.Close()
|
|
}
|
|
|
|
// PullImage pulls a Docker image.
|
|
func (c *Client) PullImage(ctx context.Context, imageName string) error {
|
|
c.logger.Info("Pulling image", zap.String("image", imageName))
|
|
|
|
reader, err := c.cli.ImagePull(ctx, imageName, client.ImagePullOptions{})
|
|
if err != nil {
|
|
c.logger.Error("Failed to pull image", zap.String("image", imageName), zap.Error(err))
|
|
return fmt.Errorf("failed to pull image %s: %w", imageName, err)
|
|
}
|
|
defer func() {
|
|
if err := reader.Close(); err != nil {
|
|
c.logger.Warn("Failed to close image pull reader", zap.Error(err))
|
|
}
|
|
}()
|
|
|
|
// Read the output to ensure the image is fully pulled
|
|
_, err = io.Copy(io.Discard, reader)
|
|
if err != nil {
|
|
c.logger.Error("Error reading image pull output", zap.String("image", imageName), zap.Error(err))
|
|
return fmt.Errorf("error reading image pull output: %w", err)
|
|
}
|
|
|
|
c.logger.Info("Image pulled successfully", zap.String("image", imageName))
|
|
return nil
|
|
}
|
|
|
|
// BuildImage builds a Docker image from a Dockerfile string.
|
|
// It returns the build output as an io.ReadCloser for streaming.
|
|
// The caller is responsible for closing the returned reader.
|
|
func (c *Client) BuildImage(ctx context.Context, dockerfile string, tag string, buildArgs map[string]*string) (io.ReadCloser, error) {
|
|
c.logger.Info("Building image", zap.String("tag", tag))
|
|
c.mu.RLock()
|
|
coordinator := c.activity
|
|
builder := c.builder
|
|
c.mu.RUnlock()
|
|
if builder == nil {
|
|
builder = c.cli
|
|
}
|
|
var activityLease *activity.TaskLease
|
|
var err error
|
|
if coordinator != nil {
|
|
activityLease, err = coordinator.AcquireTask(ctx, activity.KindDockerImageBuild)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
buildContext, err := createDockerfileTar(dockerfile)
|
|
if err != nil {
|
|
activityLease.Release()
|
|
return nil, fmt.Errorf("failed to create build context: %w", err)
|
|
}
|
|
|
|
resp, err := builder.ImageBuild(ctx, buildContext, client.ImageBuildOptions{
|
|
Tags: []string{tag},
|
|
BuildArgs: buildArgs,
|
|
Dockerfile: "Dockerfile",
|
|
Remove: true,
|
|
})
|
|
if err != nil {
|
|
activityLease.Release()
|
|
c.logger.Error("Failed to build image", zap.String("tag", tag), zap.Error(err))
|
|
return nil, fmt.Errorf("failed to build image %s: %w", tag, err)
|
|
}
|
|
|
|
return &activityReadCloser{ReadCloser: resp.Body, lease: activityLease}, nil
|
|
}
|
|
|
|
type activityReadCloser struct {
|
|
io.ReadCloser
|
|
lease *activity.TaskLease
|
|
once sync.Once
|
|
}
|
|
|
|
func (r *activityReadCloser) Read(p []byte) (int, error) {
|
|
n, err := r.ReadCloser.Read(p)
|
|
if err == io.EOF {
|
|
r.release()
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func (r *activityReadCloser) Close() error {
|
|
err := r.ReadCloser.Close()
|
|
r.release()
|
|
return err
|
|
}
|
|
|
|
func (r *activityReadCloser) release() {
|
|
r.once.Do(func() { r.lease.Release() })
|
|
}
|
|
|
|
// createDockerfileTar creates a tar archive containing a single Dockerfile.
|
|
func createDockerfileTar(dockerfile string) (io.Reader, error) {
|
|
buf := new(bytes.Buffer)
|
|
tw := tar.NewWriter(buf)
|
|
|
|
header := &tar.Header{
|
|
Name: "Dockerfile",
|
|
Size: int64(len(dockerfile)),
|
|
Mode: 0o644,
|
|
}
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return nil, fmt.Errorf("failed to write tar header: %w", err)
|
|
}
|
|
if _, err := tw.Write([]byte(dockerfile)); err != nil {
|
|
return nil, fmt.Errorf("failed to write Dockerfile to tar: %w", err)
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return nil, fmt.Errorf("failed to close tar writer: %w", err)
|
|
}
|
|
|
|
return buf, nil
|
|
}
|
|
|
|
// CreateContainer creates a new container.
|
|
func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (string, error) {
|
|
c.logger.Info("Creating container",
|
|
zap.String("name", cfg.Name),
|
|
zap.String("image", cfg.Image),
|
|
)
|
|
|
|
// Build mounts
|
|
mounts := make([]mount.Mount, 0, len(cfg.Mounts))
|
|
for _, m := range cfg.Mounts {
|
|
mounts = append(mounts, mount.Mount{
|
|
Type: mount.TypeBind,
|
|
Source: m.Source,
|
|
Target: m.Target,
|
|
ReadOnly: m.ReadOnly,
|
|
})
|
|
}
|
|
|
|
// Container configuration
|
|
exposedPorts, portBindings, err := buildDockerPortBindings(cfg.PortBindings)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create container %s: %w", cfg.Name, err)
|
|
}
|
|
containerCfg := &container.Config{
|
|
Image: cfg.Image,
|
|
Entrypoint: cfg.Entrypoint,
|
|
Cmd: cfg.Cmd,
|
|
Env: cfg.Env,
|
|
WorkingDir: cfg.WorkingDir,
|
|
Labels: cfg.Labels,
|
|
ExposedPorts: exposedPorts,
|
|
}
|
|
|
|
// Host configuration
|
|
hostCfg := &container.HostConfig{
|
|
Mounts: mounts,
|
|
NetworkMode: container.NetworkMode(cfg.NetworkMode),
|
|
AutoRemove: cfg.AutoRemove,
|
|
PortBindings: portBindings,
|
|
Resources: container.Resources{
|
|
Memory: cfg.Memory,
|
|
CPUQuota: cfg.CPUQuota,
|
|
},
|
|
}
|
|
|
|
resp, err := c.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
|
Config: containerCfg,
|
|
HostConfig: hostCfg,
|
|
Name: cfg.Name,
|
|
})
|
|
if err != nil {
|
|
c.logger.Error("Failed to create container",
|
|
zap.String("name", cfg.Name),
|
|
zap.Error(err),
|
|
)
|
|
return "", fmt.Errorf("failed to create container %s: %w", cfg.Name, err)
|
|
}
|
|
|
|
c.logger.Info("Container created", zap.String("id", resp.ID), zap.String("name", cfg.Name))
|
|
return resp.ID, nil
|
|
}
|
|
|
|
func buildDockerPortBindings(bindings []PortBindingConfig) (network.PortSet, network.PortMap, error) {
|
|
if len(bindings) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
exposedPorts := network.PortSet{}
|
|
portBindings := network.PortMap{}
|
|
for _, binding := range bindings {
|
|
containerPort, err := tcpPort(binding.ContainerPort)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("invalid container port binding: %w", err)
|
|
}
|
|
hostIP, err := parseDockerHostIP(binding.HostIP)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("invalid host IP for container port %d: %w", binding.ContainerPort, err)
|
|
}
|
|
exposedPorts[containerPort] = struct{}{}
|
|
portBindings[containerPort] = append(portBindings[containerPort], network.PortBinding{
|
|
HostIP: hostIP,
|
|
HostPort: binding.HostPort,
|
|
})
|
|
}
|
|
return exposedPorts, portBindings, nil
|
|
}
|
|
|
|
// tcpPort converts a container port number into the API's opaque TCP port key.
|
|
func tcpPort(port int) (network.Port, error) {
|
|
if port <= 0 || port > math.MaxUint16 {
|
|
return network.Port{}, fmt.Errorf("port %d out of range", port)
|
|
}
|
|
parsed, ok := network.PortFrom(uint16(port), network.TCP)
|
|
if !ok {
|
|
return network.Port{}, fmt.Errorf("port %d is not a valid TCP port", port)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
// parseDockerHostIP converts a configured host IP into the API's address type.
|
|
// An empty value keeps Docker's "publish on all interfaces" behavior.
|
|
func parseDockerHostIP(hostIP string) (netip.Addr, error) {
|
|
if hostIP == "" {
|
|
return netip.Addr{}, nil
|
|
}
|
|
addr, err := netip.ParseAddr(hostIP)
|
|
if err != nil {
|
|
return netip.Addr{}, fmt.Errorf("parse host IP %q: %w", hostIP, err)
|
|
}
|
|
return addr, nil
|
|
}
|
|
|
|
// StartContainer starts a container.
|
|
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
|
|
c.logger.Info("Starting container", zap.String("container_id", containerID))
|
|
|
|
_, err := c.cli.ContainerStart(ctx, containerID, client.ContainerStartOptions{})
|
|
if err != nil {
|
|
c.logger.Error("Failed to start container", zap.String("container_id", containerID), zap.Error(err))
|
|
return fmt.Errorf("failed to start container %s: %w", containerID, err)
|
|
}
|
|
|
|
c.logger.Info("Container started", zap.String("container_id", containerID))
|
|
return nil
|
|
}
|
|
|
|
// StopContainer stops a container with a timeout.
|
|
func (c *Client) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error {
|
|
c.logger.Info("Stopping container",
|
|
zap.String("container_id", containerID),
|
|
zap.Duration("timeout", timeout),
|
|
)
|
|
|
|
timeoutSeconds := int(timeout.Seconds())
|
|
_, err := c.cli.ContainerStop(ctx, containerID, client.ContainerStopOptions{
|
|
Timeout: &timeoutSeconds,
|
|
})
|
|
if err != nil {
|
|
c.logger.Error("Failed to stop container", zap.String("container_id", containerID), zap.Error(err))
|
|
return fmt.Errorf("failed to stop container %s: %w", containerID, err)
|
|
}
|
|
|
|
c.logger.Info("Container stopped", zap.String("container_id", containerID))
|
|
return nil
|
|
}
|
|
|
|
// RemoveContainer removes a container.
|
|
func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error {
|
|
c.logger.Info("Removing container",
|
|
zap.String("container_id", containerID),
|
|
zap.Bool("force", force),
|
|
)
|
|
|
|
_, err := c.containerRemover().ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{
|
|
Force: force,
|
|
RemoveVolumes: true,
|
|
})
|
|
if err != nil {
|
|
c.logger.Error("Failed to remove container", zap.String("container_id", containerID), zap.Error(err))
|
|
return fmt.Errorf("failed to remove container %s: %w", containerID, err)
|
|
}
|
|
|
|
c.logger.Info("Container removed", zap.String("container_id", containerID))
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) containerRemover() containerRemover {
|
|
if c.remover != nil {
|
|
return c.remover
|
|
}
|
|
return c.cli
|
|
}
|
|
|
|
// KillContainer kills a container.
|
|
func (c *Client) KillContainer(ctx context.Context, containerID string, signal string) error {
|
|
c.logger.Info("Killing container",
|
|
zap.String("container_id", containerID),
|
|
zap.String("signal", signal),
|
|
)
|
|
|
|
_, err := c.cli.ContainerKill(ctx, containerID, client.ContainerKillOptions{Signal: signal})
|
|
if err != nil {
|
|
c.logger.Error("Failed to kill container", zap.String("container_id", containerID), zap.Error(err))
|
|
return fmt.Errorf("failed to kill container %s: %w", containerID, err)
|
|
}
|
|
|
|
c.logger.Info("Container killed", zap.String("container_id", containerID))
|
|
return nil
|
|
}
|
|
|
|
// inspectContainer inspects a container and unwraps the typed response.
|
|
func (c *Client) inspectContainer(ctx context.Context, containerID string) (container.InspectResponse, error) {
|
|
result, err := c.cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
|
|
if err != nil {
|
|
return container.InspectResponse{}, err
|
|
}
|
|
return result.Container, nil
|
|
}
|
|
|
|
// GetContainerInfo returns information about a container.
|
|
func (c *Client) GetContainerInfo(ctx context.Context, containerID string) (*ContainerInfo, error) {
|
|
c.logger.Debug("Getting container info", zap.String("container_id", containerID))
|
|
|
|
inspect, err := c.inspectContainer(ctx, containerID)
|
|
if err != nil {
|
|
c.logger.Error("Failed to inspect container", zap.String("container_id", containerID), zap.Error(err))
|
|
return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
|
|
}
|
|
|
|
info := &ContainerInfo{
|
|
ID: inspect.ID,
|
|
Name: inspect.Name,
|
|
}
|
|
if inspect.Config != nil {
|
|
info.Image = inspect.Config.Image
|
|
info.Labels = inspect.Config.Labels
|
|
}
|
|
applyContainerState(info, inspect.State)
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// applyContainerState copies the inspected runtime state onto info. The API
|
|
// models State as a pointer, so a missing state leaves the zero values.
|
|
func applyContainerState(info *ContainerInfo, state *container.State) {
|
|
if state == nil {
|
|
return
|
|
}
|
|
info.State = string(state.Status)
|
|
info.Status = string(state.Status)
|
|
info.ExitCode = state.ExitCode
|
|
|
|
if startedAt, err := time.Parse(time.RFC3339Nano, state.StartedAt); err == nil {
|
|
info.StartedAt = startedAt
|
|
}
|
|
if finishedAt, err := time.Parse(time.RFC3339Nano, state.FinishedAt); err == nil {
|
|
info.FinishedAt = finishedAt
|
|
}
|
|
if state.Health != nil {
|
|
info.Health = string(state.Health.Status)
|
|
}
|
|
}
|
|
|
|
// IsContainerRunning returns true if the container exists and is in "running" state
|
|
func (c *Client) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
|
|
info, err := c.GetContainerInfo(ctx, containerID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return info.State == "running", nil
|
|
}
|
|
|
|
// GetContainerIP returns the IP address of a container
|
|
func (c *Client) GetContainerIP(ctx context.Context, containerID string) (string, error) {
|
|
c.logger.Debug("Getting container IP", zap.String("container_id", containerID))
|
|
|
|
inspect, err := c.inspectContainer(ctx, containerID)
|
|
if err != nil {
|
|
c.logger.Error("Failed to inspect container for IP", zap.String("container_id", containerID), zap.Error(err))
|
|
return "", err
|
|
}
|
|
|
|
if inspect.NetworkSettings != nil {
|
|
// Check available networks for an IP address.
|
|
for netName, netSettings := range inspect.NetworkSettings.Networks {
|
|
if netSettings == nil || !netSettings.IPAddress.IsValid() {
|
|
continue
|
|
}
|
|
ip := netSettings.IPAddress.String()
|
|
c.logger.Debug("Found container IP",
|
|
zap.String("container_id", containerID),
|
|
zap.String("network", netName),
|
|
zap.String("ip", ip))
|
|
return ip, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no IP address found for container %s", containerID)
|
|
}
|
|
|
|
// GetContainerHostPort returns the Docker host endpoint for a published TCP port.
|
|
func (c *Client) GetContainerHostPort(ctx context.Context, containerID string, containerPort int) (string, int, error) {
|
|
c.logger.Debug("Getting container host port",
|
|
zap.String("container_id", containerID),
|
|
zap.Int("container_port", containerPort))
|
|
|
|
inspect, err := c.inspectContainer(ctx, containerID)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
|
|
}
|
|
if inspect.NetworkSettings == nil || inspect.NetworkSettings.Ports == nil {
|
|
return "", 0, fmt.Errorf("container %s has no published ports", containerID)
|
|
}
|
|
|
|
port, err := tcpPort(containerPort)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("container %s: %w", containerID, err)
|
|
}
|
|
bindings := inspect.NetworkSettings.Ports[port]
|
|
if len(bindings) == 0 {
|
|
return "", 0, fmt.Errorf("container %s port %d/tcp is not published", containerID, containerPort)
|
|
}
|
|
hostPort, err := parseHostPort(bindings[0].HostPort)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("invalid host port for container %s port %d/tcp: %w", containerID, containerPort, err)
|
|
}
|
|
hostIP := normalizeDockerHostIP(bindings[0].HostIP)
|
|
return hostIP, hostPort, nil
|
|
}
|
|
|
|
// parseHostPort validates a host-side port number reported by the daemon.
|
|
func parseHostPort(value string) (int, error) {
|
|
port, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse port %q: %w", value, err)
|
|
}
|
|
if port < 0 || port > math.MaxUint16 {
|
|
return 0, fmt.Errorf("port %d out of range", port)
|
|
}
|
|
return port, nil
|
|
}
|
|
|
|
// normalizeDockerHostIP maps an unset or wildcard publish address onto loopback
|
|
// so callers get an address they can actually dial.
|
|
func normalizeDockerHostIP(hostIP netip.Addr) string {
|
|
if !hostIP.IsValid() || hostIP.IsUnspecified() {
|
|
return "127.0.0.1"
|
|
}
|
|
return hostIP.String()
|
|
}
|
|
|
|
// GetContainerLabels returns the labels of a container
|
|
func (c *Client) GetContainerLabels(ctx context.Context, containerID string) (map[string]string, error) {
|
|
c.logger.Debug("Getting container labels", zap.String("container_id", containerID))
|
|
|
|
inspect, err := c.inspectContainer(ctx, containerID)
|
|
if err != nil {
|
|
c.logger.Error("Failed to inspect container for labels", zap.String("container_id", containerID), zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
if inspect.Config != nil && inspect.Config.Labels != nil {
|
|
return inspect.Config.Labels, nil
|
|
}
|
|
|
|
return make(map[string]string), nil
|
|
}
|
|
|
|
// GetContainerLogs returns logs from a container.
|
|
func (c *Client) GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) {
|
|
c.logger.Debug("Getting container logs",
|
|
zap.String("container_id", containerID),
|
|
zap.Bool("follow", follow),
|
|
zap.String("tail", tail),
|
|
)
|
|
|
|
opts := client.ContainerLogsOptions{
|
|
ShowStdout: true,
|
|
ShowStderr: true,
|
|
Follow: follow,
|
|
Tail: tail,
|
|
Timestamps: false,
|
|
}
|
|
|
|
reader, err := c.cli.ContainerLogs(ctx, containerID, opts)
|
|
if err != nil {
|
|
c.logger.Error("Failed to get container logs", zap.String("container_id", containerID), zap.Error(err))
|
|
return nil, fmt.Errorf("failed to get container logs for %s: %w", containerID, err)
|
|
}
|
|
|
|
return reader, nil
|
|
}
|
|
|
|
// WaitContainer waits for a container to stop and returns the exit code.
|
|
func (c *Client) WaitContainer(ctx context.Context, containerID string) (int64, error) {
|
|
c.logger.Info("Waiting for container", zap.String("container_id", containerID))
|
|
|
|
wait := c.cli.ContainerWait(ctx, containerID, client.ContainerWaitOptions{
|
|
Condition: container.WaitConditionNotRunning,
|
|
})
|
|
|
|
select {
|
|
case err := <-wait.Error:
|
|
if err != nil {
|
|
c.logger.Error("Error waiting for container", zap.String("container_id", containerID), zap.Error(err))
|
|
return -1, fmt.Errorf("error waiting for container %s: %w", containerID, err)
|
|
}
|
|
case status := <-wait.Result:
|
|
c.logger.Info("Container exited",
|
|
zap.String("container_id", containerID),
|
|
zap.Int64("exit_code", status.StatusCode),
|
|
)
|
|
return status.StatusCode, nil
|
|
case <-ctx.Done():
|
|
c.logger.Warn("Context cancelled while waiting for container", zap.String("container_id", containerID))
|
|
return -1, ctx.Err()
|
|
}
|
|
|
|
return -1, nil
|
|
}
|
|
|
|
// ListContainers lists containers with optional filters.
|
|
func (c *Client) ListContainers(ctx context.Context, labels map[string]string) ([]ContainerInfo, error) {
|
|
c.logger.Debug("Listing containers", zap.Any("labels", labels))
|
|
|
|
// Build filters from labels
|
|
listFilters := make(client.Filters)
|
|
for key, value := range labels {
|
|
listFilters.Add("label", fmt.Sprintf("%s=%s", key, value))
|
|
}
|
|
|
|
result, err := c.cli.ContainerList(ctx, client.ContainerListOptions{
|
|
All: true,
|
|
Filters: listFilters,
|
|
})
|
|
if err != nil {
|
|
c.logger.Error("Failed to list containers", zap.Error(err))
|
|
return nil, fmt.Errorf("failed to list containers: %w", err)
|
|
}
|
|
|
|
infos := make([]ContainerInfo, 0, len(result.Items))
|
|
for _, ctr := range result.Items {
|
|
name := ""
|
|
if len(ctr.Names) > 0 {
|
|
name = ctr.Names[0]
|
|
// Remove leading slash from container name
|
|
if len(name) > 0 && name[0] == '/' {
|
|
name = name[1:]
|
|
}
|
|
}
|
|
|
|
info := ContainerInfo{
|
|
ID: ctr.ID,
|
|
Name: name,
|
|
Image: ctr.Image,
|
|
State: string(ctr.State),
|
|
Status: ctr.Status,
|
|
Labels: ctr.Labels,
|
|
}
|
|
infos = append(infos, info)
|
|
}
|
|
|
|
c.logger.Debug("Listed containers", zap.Int("count", len(infos)))
|
|
return infos, nil
|
|
}
|
|
|
|
// Ping checks if Docker is available.
|
|
func (c *Client) Ping(ctx context.Context) error {
|
|
c.logger.Debug("Pinging Docker daemon")
|
|
|
|
_, err := c.cli.Ping(ctx, client.PingOptions{})
|
|
if err != nil {
|
|
c.logger.Debug("Docker ping failed", zap.Error(err))
|
|
return fmt.Errorf("docker ping failed: %w", err)
|
|
}
|
|
|
|
c.logger.Debug("Docker daemon is available")
|
|
return nil
|
|
}
|
|
|
|
// AttachResult contains the streams for container I/O
|
|
type AttachResult struct {
|
|
Stdin io.WriteCloser
|
|
Stdout io.Reader
|
|
Stderr io.Reader
|
|
Conn net.Conn
|
|
}
|
|
|
|
// CreateContainerInteractive creates a container with stdin attached for interactive use
|
|
func (c *Client) CreateContainerInteractive(ctx context.Context, cfg ContainerConfig) (string, error) {
|
|
c.logger.Info("Creating interactive container",
|
|
zap.String("name", cfg.Name),
|
|
zap.String("image", cfg.Image),
|
|
)
|
|
|
|
// Build mounts
|
|
mounts := make([]mount.Mount, 0, len(cfg.Mounts))
|
|
for _, m := range cfg.Mounts {
|
|
mounts = append(mounts, mount.Mount{
|
|
Type: mount.TypeBind,
|
|
Source: m.Source,
|
|
Target: m.Target,
|
|
ReadOnly: m.ReadOnly,
|
|
})
|
|
}
|
|
|
|
// Container configuration with stdin attached. Mirror CreateContainer's
|
|
// handling of ContainerConfig fields — including Entrypoint — so the same
|
|
// config struct produces consistent container behavior on both paths.
|
|
exposedPorts, portBindings, err := buildDockerPortBindings(cfg.PortBindings)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create interactive container %s: %w", cfg.Name, err)
|
|
}
|
|
containerCfg := &container.Config{
|
|
Image: cfg.Image,
|
|
Entrypoint: cfg.Entrypoint,
|
|
Cmd: cfg.Cmd,
|
|
Env: cfg.Env,
|
|
WorkingDir: cfg.WorkingDir,
|
|
Labels: cfg.Labels,
|
|
OpenStdin: true,
|
|
StdinOnce: false,
|
|
AttachStdin: true,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
ExposedPorts: exposedPorts,
|
|
Tty: false, // Important: no TTY for JSON-RPC
|
|
}
|
|
|
|
// Host configuration
|
|
hostCfg := &container.HostConfig{
|
|
Mounts: mounts,
|
|
NetworkMode: container.NetworkMode(cfg.NetworkMode),
|
|
AutoRemove: cfg.AutoRemove,
|
|
PortBindings: portBindings,
|
|
Resources: container.Resources{
|
|
Memory: cfg.Memory,
|
|
CPUQuota: cfg.CPUQuota,
|
|
},
|
|
}
|
|
|
|
resp, err := c.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
|
Config: containerCfg,
|
|
HostConfig: hostCfg,
|
|
Name: cfg.Name,
|
|
})
|
|
if err != nil {
|
|
c.logger.Error("Failed to create interactive container",
|
|
zap.String("name", cfg.Name),
|
|
zap.Error(err),
|
|
)
|
|
return "", fmt.Errorf("failed to create interactive container %s: %w", cfg.Name, err)
|
|
}
|
|
|
|
c.logger.Info("Interactive container created", zap.String("id", resp.ID), zap.String("name", cfg.Name))
|
|
return resp.ID, nil
|
|
}
|
|
|
|
// AttachContainer attaches to a container's stdin, stdout, and stderr
|
|
func (c *Client) AttachContainer(ctx context.Context, containerID string) (*AttachResult, error) {
|
|
c.logger.Info("Attaching to container", zap.String("container_id", containerID))
|
|
|
|
opts := client.ContainerAttachOptions{
|
|
Stream: true,
|
|
Stdin: true,
|
|
Stdout: true,
|
|
Stderr: true,
|
|
}
|
|
|
|
resp, err := c.cli.ContainerAttach(ctx, containerID, opts)
|
|
if err != nil {
|
|
c.logger.Error("Failed to attach to container", zap.String("container_id", containerID), zap.Error(err))
|
|
return nil, fmt.Errorf("failed to attach to container %s: %w", containerID, err)
|
|
}
|
|
|
|
// Create a pipe for stdin
|
|
stdinReader, stdinWriter := io.Pipe()
|
|
|
|
// Start goroutine to copy from pipe to container
|
|
go func() {
|
|
if _, err := io.Copy(resp.Conn, stdinReader); err != nil {
|
|
c.logger.Debug("failed to copy stdin to container", zap.Error(err))
|
|
}
|
|
}()
|
|
|
|
// Create a pipe for demultiplexed stdout
|
|
// Docker sends multiplexed streams when Tty=false with 8-byte headers
|
|
stdoutReader, stdoutWriter := io.Pipe()
|
|
|
|
// Start goroutine to demultiplex stdout/stderr
|
|
go func() {
|
|
defer func() {
|
|
if err := stdoutWriter.Close(); err != nil {
|
|
c.logger.Debug("failed to close stdout writer", zap.Error(err))
|
|
}
|
|
}()
|
|
c.demultiplexStream(resp.Reader, stdoutWriter)
|
|
}()
|
|
|
|
c.logger.Info("Attached to container", zap.String("container_id", containerID))
|
|
|
|
return &AttachResult{
|
|
Stdin: stdinWriter,
|
|
Stdout: stdoutReader, // Demultiplexed stdout stream
|
|
Conn: resp.Conn,
|
|
}, nil
|
|
}
|
|
|
|
// demultiplexStream reads Docker's multiplexed stream format and writes only stdout to the writer.
|
|
// Docker stream format when Tty=false:
|
|
// - Byte 0: Stream type (0=stdin, 1=stdout, 2=stderr)
|
|
// - Bytes 1-3: Reserved
|
|
// - Bytes 4-7: Frame size (big endian uint32)
|
|
// - Bytes 8+: Frame data
|
|
func (c *Client) demultiplexStream(reader io.Reader, writer io.Writer) {
|
|
header := make([]byte, 8)
|
|
for {
|
|
// Read the 8-byte header
|
|
_, err := io.ReadFull(reader, header)
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
c.logger.Debug("demultiplex stream ended", zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
// Parse header
|
|
streamType := header[0]
|
|
size := binary.BigEndian.Uint32(header[4:8])
|
|
|
|
// Read the frame data
|
|
if size > 0 {
|
|
data := make([]byte, size)
|
|
_, err := io.ReadFull(reader, data)
|
|
if err != nil {
|
|
c.logger.Debug("failed to read frame data", zap.Error(err))
|
|
return
|
|
}
|
|
|
|
// Write stdout (type 1) and stderr (type 2) to writer
|
|
// We want both for ACP since errors should be visible
|
|
if streamType == 1 || streamType == 2 {
|
|
if _, err := writer.Write(data); err != nil {
|
|
c.logger.Debug("failed to write demultiplexed data", zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close closes the attach result
|
|
func (a *AttachResult) Close() error {
|
|
if a.Stdin != nil {
|
|
if err := a.Stdin.Close(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if a.Conn != nil {
|
|
if err := a.Conn.Close(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|