// Package docker implements the container runtime adapter using Docker API. package docker import ( "archive/tar" "bytes" "context" "encoding/base64" "encoding/binary" "encoding/json" "errors" "fmt" "io" "maps" "math" "net" "net/http" "net/netip" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/bnema/zerowrap" cerrdefs "github.com/containerd/errdefs" "github.com/docker/go-units" "github.com/moby/moby/api/pkg/stdcopy" "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/api/types/registry" "github.com/moby/moby/client" "github.com/bnema/gordon/internal/boundaries/out" "github.com/bnema/gordon/internal/domain" runtimepkg "github.com/bnema/gordon/pkg/runtime" ) // Runtime implements the ContainerRuntime interface using Docker API. type Runtime struct { client *client.Client runtimeName string } var _ out.ContainerRuntime = (*Runtime)(nil) var _ out.VolumeArchiveExporter = (*Runtime)(nil) type pipeReadCloser struct { pr *io.PipeReader pw *io.PipeWriter original io.Closer cleanup func() error once sync.Once } type removeOnCloseFile struct { *os.File path string } func (p *pipeReadCloser) Read(b []byte) (int, error) { return p.pr.Read(b) } func (p *pipeReadCloser) Close() error { var closeErr error p.once.Do(func() { if err := p.pw.CloseWithError(errors.New("reader closed")); err != nil && !errors.Is(err, io.ErrClosedPipe) { closeErr = err } if err := p.pr.Close(); err != nil && closeErr == nil { closeErr = err } if p.original != nil { if err := p.original.Close(); err != nil && closeErr == nil { closeErr = err } } if p.cleanup != nil { if err := p.cleanup(); err != nil && closeErr == nil { closeErr = err } } }) return closeErr } func (f *removeOnCloseFile) Close() error { closeErr := f.File.Close() removeErr := os.Remove(f.path) if closeErr != nil { return closeErr } return removeErr } // NewRuntime creates a new Docker runtime instance. func NewRuntime() (*Runtime, error) { cli, err := client.New(client.FromEnv) if err != nil { return nil, fmt.Errorf("failed to create Docker client: %w", err) } return &Runtime{ client: cli, }, nil } // NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path. func NewRuntimeWithSocket(socketPath string) (*Runtime, error) { host := "unix://" + socketPath cli, err := client.New(client.WithHost(host)) if err != nil { return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err) } return &Runtime{client: cli, runtimeName: guessRuntimeName(socketPath)}, nil } // NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing). func NewRuntimeWithClient(cli *client.Client) *Runtime { return &Runtime{ client: cli, } } // CreateContainer creates a new container. func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "CreateContainer", "container_name": config.Name, "image": config.Image, }) log := zerowrap.FromCtx(ctx) exposedPorts, portBindings, err := buildPortBindings(config) if err != nil { return nil, err } binds := buildVolumeBinds(config, log) mounts, err := buildBindMounts(config) if err != nil { return nil, err } // Device-bearing creates require a CDI-capable engine. The gate runs // only when devices are requested: ordinary apps never pay for it // and never fail it. if len(config.CDIDevices) > 0 { if err := r.requireCDISupport(ctx); err != nil { return nil, err } } deviceRequests := buildDeviceRequests(config) // Create container configuration containerConfig := &container.Config{ Image: config.Image, Hostname: config.Hostname, Env: config.Env, ExposedPorts: exposedPorts, WorkingDir: config.WorkingDir, Cmd: config.Cmd, Entrypoint: config.Entrypoint, Labels: config.Labels, User: config.User, } resources := container.Resources{ Memory: config.MemoryLimit, NanoCPUs: config.NanoCPUs, Ulimits: []*units.Ulimit{ {Name: "nofile", Soft: 65536, Hard: 65536}, }, DeviceRequests: deviceRequests, } if config.PidsLimit > 0 { resources.PidsLimit = &config.PidsLimit } capDrop := []string{"ALL"} capAdd := []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"} if config.CapDrop != nil { capDrop = config.CapDrop } if config.CapAdd != nil { capAdd = config.CapAdd } hostConfig := &container.HostConfig{ PortBindings: portBindings, AutoRemove: config.AutoRemove, Binds: binds, Mounts: mounts, NetworkMode: container.NetworkMode(config.NetworkMode), Resources: resources, SecurityOpt: []string{"no-new-privileges:true"}, CapDrop: capDrop, CapAdd: capAdd, ReadonlyRootfs: config.ReadOnlyRootFS, } if config.RestartPolicy != "" { hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)} } // Create network configuration for container var networkConfig *network.NetworkingConfig if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" { networkConfig = &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ config.NetworkMode: { Aliases: config.Aliases, }, }, } if config.Hostname != "" { networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode } } // Create the container. resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: containerConfig, HostConfig: hostConfig, NetworkingConfig: networkConfig, Name: config.Name, }) if err != nil { return nil, log.WrapErr(err, "failed to create container") } log.Info().Str(zerowrap.FieldEntityID, resp.ID).Msg("container created") // Inspect the created container to get full details return r.InspectContainer(ctx, resp.ID) } func buildPortBindings(config *domain.ContainerConfig) (network.PortSet, network.PortMap, error) { exposedPorts := make(network.PortSet) portBindings := make(network.PortMap) if err := addLegacyPortBindings(config.Ports, exposedPorts, portBindings); err != nil { return nil, nil, err } if err := addExplicitPortBindings(config.PortPublishes, exposedPorts, portBindings); err != nil { return nil, nil, err } return exposedPorts, portBindings, nil } func addLegacyPortBindings(ports []int, exposedPorts network.PortSet, portBindings network.PortMap) error { for _, port := range ports { containerPort, err := network.ParsePort(fmt.Sprintf("%d/tcp", port)) if err != nil { return fmt.Errorf("invalid container port %d: %w", port, err) } exposedPorts[containerPort] = struct{}{} // Bind to random available port on localhost only. // SECURITY: Using 127.0.0.1 prevents direct access from the network, // forcing all traffic through Gordon's reverse proxy where auth, // rate limiting, and security headers are applied. portBindings[containerPort] = []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "0"}} } return nil } func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts network.PortSet, portBindings network.PortMap) error { for _, publish := range publishes { if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP { return fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol) } containerPort, err := network.ParsePort(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol)) if err != nil { return fmt.Errorf("invalid container port %d: %w", publish.ContainerPort, err) } var hostIP netip.Addr if publish.HostIP != "" { hostIP, err = netip.ParseAddr(publish.HostIP) if err != nil { return fmt.Errorf("invalid host IP %q: %w", publish.HostIP, err) } } exposedPorts[containerPort] = struct{}{} portBindings[containerPort] = append(portBindings[containerPort], network.PortBinding{ HostIP: hostIP, HostPort: strconv.Itoa(publish.HostPort), }) } return nil } func buildVolumeBinds(config *domain.ContainerConfig, log zerowrap.Logger) []string { binds := make([]string, 0, len(config.Volumes)+len(config.ReadOnlyVolumes)) for containerPath, volumeName := range config.Volumes { bind := fmt.Sprintf("%s:%s", volumeName, containerPath) binds = append(binds, bind) log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount") } for containerPath, volumeName := range config.ReadOnlyVolumes { bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath) binds = append(binds, bind) log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount") } return binds } // buildBindMounts validates ephemeral host binds defensively and translates // them into Docker bind mounts. The result is sorted by destination so the // create request is deterministic. Host source paths are never logged or // echoed in errors. func buildBindMounts(config *domain.ContainerConfig) ([]mount.Mount, error) { if len(config.Binds) == 0 { return nil, nil } mounts := make([]mount.Mount, 0, len(config.Binds)) seen := make(map[string]string, len(config.Binds)) for _, bind := range config.Binds { if bind.Source == "" || !filepath.IsAbs(bind.Source) || filepath.Clean(bind.Source) != bind.Source { return nil, fmt.Errorf("invalid bind %q: source must be an absolute clean path", bind.Name) } if err := domain.ValidateBindDestination(bind.Destination); err != nil { return nil, fmt.Errorf("invalid bind %q: %w", bind.Name, err) } if prev, ok := seen[bind.Destination]; ok { return nil, fmt.Errorf("invalid bind %q: destination %q already used by bind %q", bind.Name, bind.Destination, prev) } seen[bind.Destination] = bind.Name mounts = append(mounts, mount.Mount{ Type: mount.TypeBind, Source: bind.Source, Target: bind.Destination, ReadOnly: bind.ReadOnly, }) } sort.SliceStable(mounts, func(i, j int) bool { if mounts[i].Target != mounts[j].Target { return mounts[i].Target < mounts[j].Target } return mounts[i].Source < mounts[j].Source }) return mounts, nil } // requireCDISupport fails closed when the connected engine cannot serve // native CDI device requests. Family and version come from the daemon's // own /version response; Gordon requires Podman 5.4+ or Docker 28.3+ for // device-bearing creates. The version is a capability gate, not hardware // proof: CDI specs, toolkit, and node permissions must still be correct // on the host. func (r *Runtime) requireCDISupport(ctx context.Context) error { version, err := r.client.ServerVersion(ctx, client.ServerVersionOptions{}) if err != nil { return engineProbeError(ctx, err) } if err := checkCDISupport(version, r.runtimeName); err != nil { return err } return nil } // engineProbeError maps a failed /version probe onto the CDI capability // gate. Cancellation and deadlines survive so callers can still tell an // aborted probe from an incapable engine; every other cause collapses to // ErrRuntimeUnsupported with the cause text dropped, because transport // errors embed the daemon endpoint and the operator's home path. func engineProbeError(ctx context.Context, err error) error { var ctxErr error switch { case errors.Is(err, context.Canceled), errors.Is(ctx.Err(), context.Canceled): ctxErr = context.Canceled case errors.Is(err, context.DeadlineExceeded), errors.Is(ctx.Err(), context.DeadlineExceeded): ctxErr = context.DeadlineExceeded } if ctxErr != nil { return fmt.Errorf("%w: cannot verify engine version: %w", domain.ErrRuntimeUnsupported, ctxErr) } return fmt.Errorf("%w: cannot verify engine version", domain.ErrRuntimeUnsupported) } // SupportsCDIDevices implements out.ContainerRuntime. Deployment calls it // in preflight for device-bearing revisions so an unsupported engine // fails before any workload mutation; CreateContainer keeps the same gate // as defense in depth. func (r *Runtime) SupportsCDIDevices(ctx context.Context) error { return r.requireCDISupport(ctx) } // checkCDISupport verifies one daemon version response against the CDI // support matrix. Family detection prefers the daemon's own components // (Podman Engine vs Engine) and falls back to the socket-path hint only // when the response names nothing recognizable. func checkCDISupport(version client.ServerVersionResult, socketHint string) error { family, daemonVersion := cdiEngineIdentity(version, socketHint) var minimum string switch family { case "podman": minimum = "5.4" case "docker": minimum = "28.3" default: return fmt.Errorf("%w: unrecognized engine %q version %q: device requests require Podman 5.4+ or Docker 28.3+ with native CDI configured", domain.ErrRuntimeUnsupported, family, daemonVersion) } if compareEngineVersion(daemonVersion, minimum) < 0 { return fmt.Errorf("%w: %s %s below minimum %s: device requests require Podman 5.4+ or Docker 28.3+ with native CDI configured (check engine upgrade, CDI specs, toolkit, and device permissions)", domain.ErrRuntimeUnsupported, family, daemonVersion, minimum) } return nil } // cdiEngineIdentity names the daemon family and its version from the // /version response. Podman answers with a "Podman Engine" component; // Docker answers with an "Engine" component and its version at top // level. Unknown responses keep the raw version with an "unknown" // family so the caller fails closed. func cdiEngineIdentity(version client.ServerVersionResult, socketHint string) (family, daemonVersion string) { for _, component := range version.Components { switch component.Name { case "Podman Engine": return "podman", component.Version case "Engine": return "docker", component.Version } } if strings.Contains(strings.ToLower(version.Platform.Name), "podman") { return "podman", version.Version } switch socketHint { case "podman", "docker": return socketHint, version.Version } return "unknown", version.Version } // compareEngineVersion compares dotted major.minor versions. A missing // or unparsable version compares below any minimum: the gate fails // closed rather than trusting an engine it cannot identify. func compareEngineVersion(version, minimum string) int { parse := func(value string) (int, int) { parts := strings.SplitN(value, ".", 3) if len(parts) < 2 { return -1, -1 } major, ok := parseEngineVersionComponent(parts[0]) if !ok { return -1, -1 } minor, ok := parseEngineVersionComponent(parts[1]) if !ok { return -1, -1 } return major, minor } major, minor := parse(version) minMajor, minMinor := parse(minimum) switch { case major != minMajor: return major - minMajor default: return minor - minMinor } } // parseEngineVersionComponent parses one numeric major/minor component. // Anything but digits is refused, so prerelease suffixes such as "-rc1" // and malformed versions can never lift an engine above the gate. func parseEngineVersionComponent(value string) (int, bool) { value = strings.TrimSpace(value) if value == "" { return 0, false } for _, r := range value { if r < '0' || r > '9' { return 0, false } } n, err := strconv.Atoi(value) if err != nil { return 0, false } return n, true } // buildDeviceRequests encodes ephemeral CDI device IDs as one native CDI // DeviceRequest. Capabilities and Options stay empty; Count stays 0 so the // explicit DeviceIDs are the only grant. CDI IDs are never logged or echoed // in errors: they are host inventory. func buildDeviceRequests(config *domain.ContainerConfig) []container.DeviceRequest { if len(config.CDIDevices) == 0 { return nil } ids := append([]string(nil), config.CDIDevices...) sort.Strings(ids) return []container.DeviceRequest{ { Driver: "cdi", DeviceIDs: ids, }, } } // StartContainer starts a container. func (r *Runtime) StartContainer(ctx context.Context, containerID string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "StartContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) _, err := r.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{}) if err != nil { if cerrdefs.IsNotFound(err) { return log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to start container") } return log.WrapErr(err, "failed to start container") } log.Info().Msg("container started") return nil } // WaitForContainer waits for a container to stop running. func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "WaitForContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}) statusCh, errCh := waitResult.Result, waitResult.Error select { case err := <-errCh: if err != nil { return log.WrapErr(err, "failed to wait for container") } case <-statusCh: } log.Debug().Msg("container wait completed") return nil } // StopContainer stops a container, giving it grace to exit before the // runtime kills it. A non-positive grace keeps the runtime default. func (r *Runtime) StopContainer(ctx context.Context, containerID string, grace time.Duration) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "StopContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) _, err := r.client.ContainerStop(ctx, containerID, client.ContainerStopOptions{Timeout: stopTimeout(grace)}) if err != nil { if cerrdefs.IsNotFound(err) { return log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to stop container") } return log.WrapErr(err, "failed to stop container") } log.Info().Msg("container stopped") return nil } // RestartContainer restarts a container, giving it grace to exit before // the runtime kills it. A non-positive grace keeps the runtime default. func (r *Runtime) RestartContainer(ctx context.Context, containerID string, grace time.Duration) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RestartContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) _, err := r.client.ContainerRestart(ctx, containerID, client.ContainerRestartOptions{Timeout: stopTimeout(grace)}) if err != nil { if cerrdefs.IsNotFound(err) { return log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to restart container") } return log.WrapErr(err, "failed to restart container") } log.Info().Msg("container restarted") return nil } // stopTimeout converts a stop grace into the runtime API's whole-second // timeout. A non-positive grace returns nil, which keeps the runtime's // own default instead of an immediate kill. A fractional grace rounds up // so the container never receives less time than the spec asked for. func stopTimeout(grace time.Duration) *int { if grace <= 0 { return nil } seconds := int(math.Ceil(grace.Seconds())) if seconds < 1 { seconds = 1 } return &seconds } // RemoveContainer removes a container. func (r *Runtime) RemoveContainer(ctx context.Context, containerID string, force bool) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RemoveContainer", zerowrap.FieldEntityID: containerID, "force": force, }) log := zerowrap.FromCtx(ctx) _, err := r.client.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{Force: force}) if err != nil { if cerrdefs.IsNotFound(err) { log.Debug().Msg("container not found, already removed") return nil } return log.WrapErr(err, "failed to remove container") } log.Info().Msg("container removed") return nil } // RenameContainer renames a container. func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RenameContainer", zerowrap.FieldEntityID: containerID, "new_name": newName, }) log := zerowrap.FromCtx(ctx) _, err := r.client.ContainerRename(ctx, containerID, client.ContainerRenameOptions{NewName: newName}) if err != nil { return log.WrapErr(err, "failed to rename container") } log.Info().Msg("container renamed") return nil } // ListContainers lists containers. func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ListContainers", "all": all, }) log := zerowrap.FromCtx(ctx) containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: all}) if err != nil { return nil, log.WrapErr(err, "failed to list containers") } var result []*domain.Container for _, c := range containerList.Items { // Extract ports var ports []int for _, port := range c.Ports { if port.PublicPort > 0 { ports = append(ports, int(port.PublicPort)) } } // Get the primary name (remove leading slash) name := "" if len(c.Names) > 0 { name = strings.TrimPrefix(c.Names[0], "/") } volumeMounts := make([]domain.ContainerVolumeMount, 0, len(c.Mounts)) for _, m := range c.Mounts { volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{ Name: m.Name, Type: string(m.Type), Source: m.Source, Destination: m.Destination, ReadOnly: !m.RW, }) } result = append(result, &domain.Container{ ID: c.ID, Image: c.Image, ImageID: c.ImageID, Name: name, Status: string(c.State), // Use State (e.g., "running") not Status (e.g., "Up 2 days") Ports: ports, Labels: c.Labels, VolumeMounts: volumeMounts, Created: time.Unix(c.Created, 0), }) } return result, nil } // InspectContainer inspects a container. func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*domain.Container, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "InspectContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) if err != nil { if cerrdefs.IsNotFound(err) { return nil, log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to inspect container") } return nil, log.WrapErr(err, "failed to inspect container") } resp := inspectResult.Container // Extract published ports var ports []int if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { for _, bindings := range resp.NetworkSettings.Ports { for _, binding := range bindings { if binding.HostPort != "" { if port, err := strconv.Atoi(binding.HostPort); err == nil { ports = append(ports, port) } } } } } // Get container name (remove leading slash) name := strings.TrimPrefix(resp.Name, "/") created, _ := time.Parse(time.RFC3339Nano, resp.Created) // StartedAt scopes log readiness to the current execution: markers // from a previous execution of the same container ID must not // satisfy a probe. An unparsable value stays zero and is rejected by // the readiness probe rather than silently widening the scope. startedAt, _ := time.Parse(time.RFC3339Nano, resp.State.StartedAt) if startedAt.Year() <= 1 { startedAt = time.Time{} } volumeMounts := make([]domain.ContainerVolumeMount, 0, len(resp.Mounts)) for _, m := range resp.Mounts { volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{ Name: m.Name, Type: string(m.Type), Source: m.Source, Destination: m.Destination, ReadOnly: !m.RW, }) } return &domain.Container{ ID: resp.ID, Image: resp.Config.Image, ImageID: resp.Image, Name: name, Status: string(resp.State.Status), ExitCode: resp.State.ExitCode, Ports: ports, Labels: resp.Config.Labels, VolumeMounts: volumeMounts, Created: created, StartedAt: startedAt, Env: resp.Config.Env, }, nil } // GetContainerLogsSince gets container logs emitted at or after since. // Readiness uses it with the observed execution start so markers from a // previous execution of the same container ID cannot match. func (r *Runtime) GetContainerLogsSince(ctx context.Context, containerID string, since time.Time, follow bool) (io.ReadCloser, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerLogsSince", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) options := client.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, Follow: follow, Timestamps: true, Tail: "10000", } if !since.IsZero() { // RFC3339Nano keeps the sub-second part of the execution start: // a marker emitted in the same second as a previous execution // must not satisfy the probe. options.Since = since.UTC().Format(time.RFC3339Nano) } logs, err := r.client.ContainerLogs(ctx, containerID, options) if err != nil { if cerrdefs.IsNotFound(err) { return nil, log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to get container logs") } return nil, log.WrapErr(err, "failed to get container logs") } return logs, nil } // GetContainerLogs gets container logs. func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerLogs", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, Follow: follow, Timestamps: true, Tail: "10000", }) if err != nil { return nil, log.WrapErr(err, "failed to get container logs") } return logs, nil } func (r *Runtime) PullImageWithOptions(ctx context.Context, request domain.ImagePullRequest) error { if request.Transport == domain.ImagePullTransportHTTP { if r.runtimeName == "podman" || strings.Contains(strings.ToLower(r.client.DaemonHost()), "podman") { return r.pullPodmanHTTP(ctx, request) } // Docker has no per-pull HTTP switch. Its daemon honors HTTP only for // endpoints configured in insecure-registries; retain the compatible // pull API and let a missing daemon policy fail explicitly. if request.Username != "" || request.Password != "" { return r.PullImageWithAuth(ctx, request.Reference, request.Username, request.Password) } return r.PullImage(ctx, request.Reference) } if request.Username != "" || request.Password != "" { return r.PullImageWithAuth(ctx, request.Reference, request.Username, request.Password) } return r.PullImage(ctx, request.Reference) } func (r *Runtime) pullPodmanHTTP(ctx context.Context, request domain.ImagePullRequest) error { host := r.client.DaemonHost() if !strings.HasPrefix(host, "unix://") { return fmt.Errorf("unsupported Podman endpoint %q for HTTP pull", host) } httpClient := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", strings.TrimPrefix(host, "unix://")) }}} query := url.Values{"reference": {request.Reference}, "tlsVerify": {"false"}, "quiet": {"false"}} req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://podman/v5.0.0/libpod/images/pull?"+query.Encode(), nil) if err != nil { return err } if request.Username != "" || request.Password != "" { auth, err := json.Marshal(registry.AuthConfig{Username: request.Username, Password: request.Password}) if err != nil { return err } req.Header.Set(registry.AuthHeader, base64.StdEncoding.EncodeToString(auth)) } resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("podman HTTP image pull: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("podman HTTP image pull returned %s: %s: %w", resp.Status, strings.TrimSpace(string(body)), domain.ErrImagePullFailed) } decoder := json.NewDecoder(resp.Body) for { var event struct { Error string `json:"error"` } if err := decoder.Decode(&event); errors.Is(err, io.EOF) { break } else if err != nil { return fmt.Errorf("decode Podman pull response: %w", err) } else if event.Error != "" { return fmt.Errorf("podman image pull: %s: %w", event.Error, domain.ErrImagePullFailed) } } return nil } // PullImage pulls an image. func (r *Runtime) PullImage(ctx context.Context, imageRef string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "PullImage", "image": imageRef, }) log := zerowrap.FromCtx(ctx) log.Info().Msg("pulling image") reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{}) if err != nil { return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") } defer reader.Close() if err := reader.Wait(ctx); err != nil { return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete image pull") } log.Info().Msg("image pulled successfully") return nil } // PullImageWithAuth pulls an image with authentication. func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "PullImageWithAuth", "image": imageRef, "username": username, }) log := zerowrap.FromCtx(ctx) log.Info().Msg("pulling image with authentication") // Extract registry server address from image reference // e.g., "registry.bnema.dev/image:tag" -> "registry.bnema.dev" serverAddress := imageRef if idx := strings.Index(imageRef, "/"); idx > 0 { serverAddress = imageRef[:idx] } // Create authentication configuration authConfig := registry.AuthConfig{ Username: username, Password: password, ServerAddress: serverAddress, } // Encode authentication to base64 JSON // Note: Docker API accepts both StdEncoding and URLEncoding, but Podman // may be more strict. Using StdEncoding for maximum compatibility. authConfigBytes, err := json.Marshal(authConfig) if err != nil { return log.WrapErr(err, "failed to marshal auth config") } authStr := base64.StdEncoding.EncodeToString(authConfigBytes) log.Debug(). Str("server_address", serverAddress). Bool("has_auth", username != "" || password != ""). Msg("auth config for pull") // Pull with authentication reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{ RegistryAuth: authStr, }) if err != nil { return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth") } defer reader.Close() if err := reader.Wait(ctx); err != nil { return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete authenticated image pull") } log.Info().Msg("image pulled successfully with authentication") return nil } // TagImage tags a locally available image with a new reference. func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "TagImage", "source": sourceRef, "target": targetRef, }) log := zerowrap.FromCtx(ctx) if _, err := r.client.ImageTag(ctx, client.ImageTagOptions{Source: sourceRef, Target: targetRef}); err != nil { return log.WrapErr(err, "failed to tag image") } log.Info().Msg("image tagged successfully") return nil } // UntagImage removes a tag from an image without deleting the underlying image layers. func (r *Runtime) UntagImage(ctx context.Context, imageRef string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "UntagImage", "image": imageRef, }) log := zerowrap.FromCtx(ctx) // ImageRemove with PruneChildren=false only removes the tag, not the image layers _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{ Force: false, PruneChildren: false, }) if err != nil { return log.WrapErr(err, "failed to untag image") } log.Debug().Msg("image untagged successfully") return nil } // RemoveImage removes an image. func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RemoveImage", "image": imageRef, "force": force, }) log := zerowrap.FromCtx(ctx) _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{Force: force}) if err != nil { return log.WrapErr(err, "failed to remove image") } log.Info().Msg("image removed") return nil } // ListImages lists images. func (r *Runtime) ListImages(ctx context.Context) ([]string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ListImages", }) log := zerowrap.FromCtx(ctx) imageList, err := r.client.ImageList(ctx, client.ImageListOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to list images") } var result []string for _, img := range imageList.Items { for _, tag := range img.RepoTags { if tag != ":" { result = append(result, tag) } } } return result, nil } // ListImagesDetailed lists images with metadata. func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDetail, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ListImagesDetailed", }) log := zerowrap.FromCtx(ctx) imageList, err := r.client.ImageList(ctx, client.ImageListOptions{All: true}) if err != nil { return nil, log.WrapErr(err, "failed to list detailed images") } result := make([]runtimepkg.ImageDetail, 0, len(imageList.Items)) for _, img := range imageList.Items { result = append(result, runtimepkg.ImageDetail{ ID: img.ID, RepoTags: img.RepoTags, Size: img.Size, Created: time.Unix(img.Created, 0), }) } return result, nil } // Ping checks if Docker is responsive. func (r *Runtime) Ping(ctx context.Context) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "Ping", }) log := zerowrap.FromCtx(ctx) _, err := r.client.Ping(ctx, client.PingOptions{}) if err != nil { return log.WrapErr(err, "Docker ping failed") } return nil } // Version returns Docker version. func (r *Runtime) Version(ctx context.Context) (string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "Version", }) log := zerowrap.FromCtx(ctx) version, err := r.client.ServerVersion(ctx, client.ServerVersionOptions{}) if err != nil { return "", log.WrapErr(err, "failed to get Docker version") } return version.Version, nil } // IsContainerRunning checks if a container is running. func (r *Runtime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) { ctr, err := r.InspectContainer(ctx, containerID) if err != nil { return false, err } return ctr.Status == string(domain.ContainerStatusRunning), nil } // GetContainerHealthStatus returns Docker healthcheck status when available. func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerHealthStatus", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) if err != nil { return "", false, log.WrapErr(err, "failed to inspect container") } resp := inspectResult.Container // Detect configured healthchecks from container config rather than relying on // State.Health presence. Some runtimes can expose an empty State.Health struct // even when no healthcheck is configured. hasHealthcheck := hasConfiguredHealthcheck(resp.Config) if !hasHealthcheck { return "", false, nil } if resp.State == nil || resp.State.Health == nil { return "", true, nil } return string(resp.State.Health.Status), true, nil } func hasConfiguredHealthcheck(cfg *container.Config) bool { if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 { return false } // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images. return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE") } // GetContainerBackendBinds resolves protocol-specific container ports to // their host binds in a single container inspection. func (r *Runtime) GetContainerBackendBinds(ctx context.Context, containerID string, ports []domain.ContainerBackendPort) ([]domain.ContainerBackendBind, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerBackendBinds", zerowrap.FieldEntityID: containerID, "ports": len(ports), }) log := zerowrap.FromCtx(ctx) inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to inspect container") } resp := inspectResult.Container if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil { return nil, fmt.Errorf("no port mappings found for container %s", containerID) } binds := make([]domain.ContainerBackendBind, 0, len(ports)) for _, port := range ports { containerPort, err := network.ParsePort(fmt.Sprintf("%d/%s", port.ContainerPort, port.Protocol)) if err != nil { return nil, fmt.Errorf("invalid container port %d: %w", port.ContainerPort, err) } bindings, exists := resp.NetworkSettings.Ports[containerPort] if !exists || len(bindings) == 0 { return nil, fmt.Errorf("port %d/%s not mapped for container %s", port.ContainerPort, port.Protocol, containerID) } // Exactly one loopback binding is required: a wildcard, multiple, // or non-loopback mapping would expose the backend beyond the // loopback contract the proxy and readiness rely on. if len(bindings) != 1 { return nil, fmt.Errorf("port %d/%s has %d host bindings for container %s; exactly one loopback binding is required", port.ContainerPort, port.Protocol, len(bindings), containerID) } binding := bindings[0] if binding.HostIP != netip.MustParseAddr("127.0.0.1") { return nil, fmt.Errorf("port %d/%s is published on host IP %q for container %s; loopback is required", port.ContainerPort, port.Protocol, binding.HostIP, containerID) } hostPort, err := strconv.Atoi(binding.HostPort) if err != nil || hostPort < 1 || hostPort > 65535 { return nil, fmt.Errorf("invalid host port %q for container %s", binding.HostPort, containerID) } binds = append(binds, domain.ContainerBackendBind{ ContainerPort: port.ContainerPort, HostPort: hostPort, Protocol: port.Protocol, }) } return binds, nil } // GetImageExposedPorts gets the exposed ports from an image. func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetImageExposedPorts", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return nil, log.WrapErr(err, "failed to inspect image") } var ports []int if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil { for portSpec := range imageInspect.Config.ExposedPorts { // Parse port from format like "80/tcp" portStr := strings.Split(portSpec, "/")[0] if port, err := strconv.Atoi(portStr); err == nil { ports = append(ports, port) } } } if len(ports) == 0 { return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef) } // Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.) // Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending sortPortsHTTPFirst(ports) log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image") return ports, nil } // sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22). func sortPortsHTTPFirst(ports []int) { priority := map[int]int{ 80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6, } sort.Slice(ports, func(i, j int) bool { pi, oki := priority[ports[i]] pj, okj := priority[ports[j]] if oki && okj { return pi < pj } if oki { return true } if okj { return false } // Neither is a priority port - sort ascending but push 22 to the end if ports[i] == 22 { return false } if ports[j] == 22 { return true } return ports[i] < ports[j] }) } // GetContainerExposedPorts gets all exposed ports from a running container. func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerExposedPorts", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to inspect container") } resp := inspectResult.Container var ports []int if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { for portSpec := range resp.NetworkSettings.Ports { // Parse port from format like "80/tcp". portStr := portSpec.Port() if port, err := strconv.Atoi(portStr); err == nil { ports = append(ports, port) } } } return ports, nil } // GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use. func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerNetworkInfo", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) if err != nil { return "", 0, log.WrapErr(err, "failed to inspect container") } resp := inspectResult.Container // Get container's internal IP address var containerIP string if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil { // Use the first available network (usually bridge or custom network) for _, net := range resp.NetworkSettings.Networks { if net.IPAddress.IsValid() { containerIP = net.IPAddress.String() break } } } if containerIP == "" { return "", 0, fmt.Errorf("no IP address found for container %s", containerID) } // Get exposed ports from container var exposedPorts []int if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { for portSpec := range resp.NetworkSettings.Ports { // Parse port from format like "80/tcp". if port, err := strconv.Atoi(portSpec.Port()); err == nil { exposedPorts = append(exposedPorts, port) } } } if len(exposedPorts) == 0 { return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID) } // Sort ports with HTTP-friendly ports first, pushing SSH (22) to the end sortPortsHTTPFirst(exposedPorts) selectedPort := exposedPorts[0] log.Info(). Str("ip", containerIP). Int("selected_port", selectedPort). Ints("exposed_ports", exposedPorts). Msg("container network info detected") return containerIP, selectedPort, nil } // GetContainerNetwork returns the network name a container is connected to. func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerNetwork", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) if err != nil { return "", log.WrapErr(err, "failed to inspect container") } networks := inspectResult.Container.NetworkSettings if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 { return "bridge", nil } // Priority: gordon-* networks first, then bridge, then any other selected := "" hasBridge := false for name := range networks.Networks { if strings.HasPrefix(name, "gordon-") { selected = name break } if name == "bridge" { hasBridge = true } else if selected == "" { selected = name } } // If no gordon-* network found, prefer bridge over random network if selected == "" || (hasBridge && !strings.HasPrefix(selected, "gordon-")) { if hasBridge { selected = "bridge" } } if selected == "" { return "bridge", nil } log.Debug().Str("network", selected).Msg("container network detected") return selected, nil } // InspectImageVolumes gets the volume mount points declared in the image. func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "InspectImageVolumes", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return nil, log.WrapErr(err, "failed to inspect image") } var volumes []string if imageInspect.Config != nil && imageInspect.Config.Volumes != nil { for volumePath := range imageInspect.Config.Volumes { volumes = append(volumes, volumePath) } } if len(volumes) > 0 { log.Info().Strs("volumes", volumes).Msg("found VOLUME directives in image") } else { log.Debug().Msg("no VOLUME directives found in image") } return volumes, nil } // VolumeExists checks if a Docker volume exists. func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "VolumeExists", "volume": volumeName, }) log := zerowrap.FromCtx(ctx) _, err := r.client.VolumeInspect(ctx, volumeName, client.VolumeInspectOptions{}) if err != nil { if cerrdefs.IsNotFound(err) { return false, nil } return false, log.WrapErr(err, "failed to inspect volume") } return true, nil } // CreateVolume creates a new Docker volume. func (r *Runtime) CreateVolume(ctx context.Context, volumeName string, labels map[string]string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "CreateVolume", "volume": volumeName, }) log := zerowrap.FromCtx(ctx) // The managed marker and creation time are adapter-owned; every // caller-supplied label is preserved so ownership provenance is // stamped at creation, not reconstructed later. merged := make(map[string]string, len(labels)+2) maps.Copy(merged, labels) merged[domain.LabelManaged] = "true" merged[domain.LabelCreated] = time.Now().UTC().Format(time.RFC3339) _, err := r.client.VolumeCreate(ctx, client.VolumeCreateOptions{ Name: volumeName, Labels: merged, }) if err != nil { return log.WrapErr(err, "failed to create volume") } log.Info().Msg("volume created") return nil } // RemoveVolume removes a Docker volume. func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force bool) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RemoveVolume", "volume": volumeName, "force": force, }) log := zerowrap.FromCtx(ctx) _, err := r.client.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force}) if err != nil { if cerrdefs.IsNotFound(err) { log.Debug().Msg("volume not found, already removed") return nil } return log.WrapErr(err, "failed to remove volume") } log.Info().Msg("volume removed") return nil } // ListVolumes returns all named volumes with their usage status. func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ListVolumes", }) log := zerowrap.FromCtx(ctx) volumeList, err := r.client.VolumeList(ctx, client.VolumeListOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to list volumes") } // Get all containers to determine volume usage. containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: true}) if err != nil { return nil, log.WrapErr(err, "failed to list containers for volume usage") } // Build a map of volume name -> container names. volumeUsers := make(map[string][]string) for _, c := range containerList.Items { for _, m := range c.Mounts { if m.Type == "volume" { name := "" if len(c.Names) > 0 { name = strings.TrimPrefix(c.Names[0], "/") } volumeUsers[m.Name] = append(volumeUsers[m.Name], name) } } } var result []*domain.VolumeInfo for _, v := range volumeList.Items { var size int64 if v.UsageData != nil { size = v.UsageData.Size } created, _ := time.Parse(time.RFC3339, v.CreatedAt) users := volumeUsers[v.Name] info := &domain.VolumeInfo{ Name: v.Name, Driver: v.Driver, MountPoint: v.Mountpoint, Size: size, CreatedAt: created, InUse: len(users) > 0, Containers: users, Labels: v.Labels, } result = append(result, info) } return result, nil } // ExportVolumeArchive exports a named volume as a compressed tar archive stream. func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ExportVolumeArchive", "volume": req.VolumeName, }) log := zerowrap.FromCtx(ctx) if err := validateVolumeArchiveRequest(req); err != nil { return nil, err } if err := r.ensureVolumeArchiveHelperImage(ctx, req.HelperImage, log); err != nil { return nil, err } created, cleanup, err := r.createVolumeArchiveHelper(ctx, req, log) if err != nil { return nil, err } if _, err := r.client.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil { _ = cleanup() return nil, log.WrapErr(err, "failed to start volume archive helper container") } stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log) if err != nil { _ = cleanup() return nil, err } return &domain.VolumeArchiveResult{Stream: stream}, nil } func validateVolumeArchiveRequest(req domain.VolumeArchiveRequest) error { if strings.TrimSpace(req.VolumeName) == "" { return fmt.Errorf("volume name is required") } if strings.TrimSpace(req.HelperImage) == "" { return fmt.Errorf("helper image is required") } return nil } func (r *Runtime) ensureVolumeArchiveHelperImage(ctx context.Context, imageRef string, log zerowrap.Logger) error { if _, err := r.client.ImageInspect(ctx, imageRef); err != nil { if !cerrdefs.IsNotFound(err) { return log.WrapErr(err, "failed to inspect volume archive helper image") } return r.PullImage(ctx, imageRef) } return nil } func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (client.ContainerCreateResult, func() error, error) { cmd, err := volumeArchiveCommand(req.Compression) if err != nil { return client.ContainerCreateResult{}, nil, err } created, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: volumeArchiveContainerConfig(req.HelperImage, cmd), HostConfig: volumeArchiveHostConfig(req.VolumeName), Name: fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()), }) if err != nil { return client.ContainerCreateResult{}, nil, log.WrapErr(err, "failed to create volume archive helper container") } var cleanupOnce sync.Once cleanup := func() error { var cleanupErr error cleanupOnce.Do(func() { removeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cleanupErr = r.RemoveContainer(removeCtx, created.ID, true) }) return cleanupErr } return created, cleanup, nil } func volumeArchiveContainerConfig(imageRef, cmd string) *container.Config { return &container.Config{ Image: imageRef, Cmd: []string{"sh", "-c", cmd}, AttachStdout: true, AttachStderr: true, NetworkDisabled: true, Labels: map[string]string{ domain.LabelManaged: "true", "gordon.purpose": "volume-backup", }, } } func volumeArchiveHostConfig(volumeName string) *container.HostConfig { return &container.HostConfig{ AutoRemove: false, Mounts: []mount.Mount{{ Type: mount.TypeVolume, Source: volumeName, Target: "/volume", ReadOnly: true, }}, } } func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) { logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true}) if err != nil { return nil, log.WrapErr(err, "failed to attach volume archive stream") } waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}) statusCh, errCh := waitResult.Result, waitResult.Error pr, pw := io.Pipe() go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log) return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil } func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) { defer logs.Close() defer func() { if err := cleanup(); err != nil { log.Warn().Err(err).Msg("failed to clean up volume archive helper container") } }() var stderr bytes.Buffer _, copyErr := stdcopy.StdCopy(pw, &stderr, logs) statusCode, waitErr := waitForVolumeArchiveContainer(statusCh, errCh) closeVolumeArchivePipe(pw, copyErr, waitErr, statusCode, stderr.String()) } func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) { for statusCh != nil || errCh != nil { select { case err, ok := <-errCh: if !ok { errCh = nil continue } if err != nil { return 0, err } case status, ok := <-statusCh: if !ok { return 0, fmt.Errorf("volume archive helper container wait channel closed") } return status.StatusCode, nil } } return 0, fmt.Errorf("volume archive helper container wait channels closed") } func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) { switch { case copyErr != nil: _ = pw.CloseWithError(copyErr) case waitErr != nil: _ = pw.CloseWithError(waitErr) case statusCode != 0: _ = pw.CloseWithError(fmt.Errorf("volume archive helper exited with code %d: %s", statusCode, strings.TrimSpace(stderr))) default: _ = pw.Close() } } func volumeArchiveCommand(compression domain.VolumeBackupCompression) (string, error) { switch compression { case domain.VolumeBackupCompressionGzip: return "cd /volume && tar -czf - .", nil case domain.VolumeBackupCompressionZstd: return "cd /volume && fifo=/tmp/gordon-volume-backup.tar && mkfifo $fifo && zstd -T0 -c $fifo & zstd_pid=$! && tar -cf $fifo .; tar_status=$?; wait $zstd_pid; zstd_status=$?; if [ $tar_status -ne 0 ]; then exit $tar_status; fi; exit $zstd_status", nil default: return "", fmt.Errorf("unsupported volume backup compression: %s", compression) } } // InspectImageEnv gets the environment variables declared in the image. func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "InspectImageEnv", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return nil, log.WrapErr(err, "failed to inspect image") } var envVars []string if imageInspect.Config != nil && imageInspect.Config.Env != nil { envVars = imageInspect.Config.Env } if len(envVars) > 0 { log.Debug(). Int(zerowrap.FieldCount, len(envVars)). Strs("env_keys", imageEnvKeys(envVars)). Msg("found ENV directives in image") } else { log.Debug().Msg("no ENV directives found in image") } return envVars, nil } // GetImageLabels returns the labels defined on an image. func imageEnvKeys(envVars []string) []string { keys := make([]string, 0, len(envVars)) for _, envVar := range envVars { key, _, ok := strings.Cut(envVar, "=") if !ok || key == "" { keys = append(keys, "[malformed]") continue } keys = append(keys, key) } sort.Strings(keys) return keys } func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetImageLabels", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return nil, log.WrapErr(err, "failed to inspect image") } labels := make(map[string]string) if imageInspect.Config != nil && imageInspect.Config.Labels != nil { labels = imageInspect.Config.Labels } log.Debug().Int("label_count", len(labels)).Msg("retrieved image labels") return labels, nil } // GetImageID returns the unique image ID (sha256 digest) for the given image reference. func (r *Runtime) VerifyImageDigest(ctx context.Context, imageRef, digest string) error { inspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return fmt.Errorf("inspect image digest: %w", err) } for _, repoDigest := range inspect.RepoDigests { if strings.HasSuffix(repoDigest, "@"+digest) { return nil } } return fmt.Errorf("expected digest %s is absent from local image metadata: %w", digest, domain.ErrImagePullFailed) } func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetImageID", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return "", log.WrapErr(err, "failed to inspect image") } return imageInspect.ID, nil } // CreateNetwork creates a new Docker network. func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "CreateNetwork", "network": name, }) log := zerowrap.FromCtx(ctx) driver := config.Driver if driver == "" { driver = "bridge" } labels := make(map[string]string, len(config.Labels)+1) maps.Copy(labels, config.Labels) labels[domain.LabelManaged] = "true" createOptions := client.NetworkCreateOptions{ Driver: driver, Internal: config.Internal, Labels: labels, } _, err := r.client.NetworkCreate(ctx, name, createOptions) if err != nil { return log.WrapErr(err, "failed to create network") } log.Info().Str("driver", driver).Msg("network created") return nil } // RemoveNetwork removes a Docker network. func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RemoveNetwork", "network": name, }) log := zerowrap.FromCtx(ctx) _, err := r.client.NetworkRemove(ctx, name, client.NetworkRemoveOptions{}) if err != nil { if cerrdefs.IsNotFound(err) { log.Debug().Msg("network not found, already removed") return nil } return log.WrapErr(err, "failed to remove network") } log.Info().Msg("network removed") return nil } // ListNetworks lists all Docker networks. func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ListNetworks", }) log := zerowrap.FromCtx(ctx) networkList, err := r.client.NetworkList(ctx, client.NetworkListOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to list networks") } var result []*domain.NetworkInfo for _, net := range networkList.Items { networkInspect, err := r.client.NetworkInspect(ctx, net.ID, client.NetworkInspectOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to inspect network") } var containers []string for containerID := range networkInspect.Network.Containers { containers = append(containers, containerID) } result = append(result, &domain.NetworkInfo{ ID: net.ID, Name: net.Name, Driver: net.Driver, Containers: containers, Labels: net.Labels, }) } return result, nil } // NetworkExists checks if a Docker network exists. func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "NetworkExists", "network": name, }) log := zerowrap.FromCtx(ctx) _, err := r.client.NetworkInspect(ctx, name, client.NetworkInspectOptions{}) if err != nil { if cerrdefs.IsNotFound(err) { return false, nil } return false, log.WrapErr(err, "failed to inspect network") } return true, nil } // ConnectContainerToNetwork connects a container to a network. func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ConnectContainerToNetwork", "container": containerName, "network": networkName, }) log := zerowrap.FromCtx(ctx) _, err := r.client.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{ Container: containerName, EndpointConfig: &network.EndpointSettings{}, }) if err != nil { return log.WrapErr(err, "failed to connect container to network") } log.Info().Msg("container connected to network") return nil } // DisconnectContainerFromNetwork disconnects a container from a network. func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "DisconnectContainerFromNetwork", "container": containerName, "network": networkName, }) log := zerowrap.FromCtx(ctx) _, err := r.client.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{ Container: containerName, }) if err != nil { return log.WrapErr(err, "failed to disconnect container from network") } log.Info().Msg("container disconnected from network") return nil } // ExecInContainer executes a command in a running container. func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) { if len(cmd) == 0 { return nil, fmt.Errorf("command cannot be empty") } ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ExecInContainer", zerowrap.FieldEntityID: containerID, "command": cmd[0], "arg_count": len(cmd) - 1, }) log := zerowrap.FromCtx(ctx) execResp, err := r.client.ExecCreate(ctx, containerID, client.ExecCreateOptions{ Cmd: cmd, AttachStdout: true, AttachStderr: true, }) if err != nil { return nil, log.WrapErr(err, "failed to create exec") } attachCtx := ctx var cancel context.CancelFunc if _, hasDeadline := ctx.Deadline(); !hasDeadline { attachCtx, cancel = context.WithTimeout(ctx, 5*time.Minute) defer cancel() } attachResp, err := r.client.ExecAttach(attachCtx, execResp.ID, client.ExecAttachOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to attach to exec") } defer attachResp.Close() if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil { _ = attachResp.Conn.SetReadDeadline(deadline) } stdout, stderr, err := parseExecOutput(attachResp.Reader) if err != nil { return nil, log.WrapErr(err, "failed to read exec output") } inspectResp, err := r.client.ExecInspect(ctx, execResp.ID, client.ExecInspectOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to inspect exec result") } return &out.ExecResult{ ExitCode: inspectResp.ExitCode, Stdout: stdout, Stderr: stderr, }, nil } const maxExecOutputSize = 8 << 20 // 8 MiB per stream type boundedBuffer struct { bytes.Buffer limit int } func (w *boundedBuffer) Write(p []byte) (int, error) { remaining := w.limit - w.Len() if remaining <= 0 { return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) } if len(p) > remaining { _, _ = w.Buffer.Write(p[:remaining]) return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) } return w.Buffer.Write(p) } func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { stdout := boundedBuffer{limit: maxExecOutputSize} stderr := boundedBuffer{limit: maxExecOutputSize} buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr} var header [8]byte for { if _, err := io.ReadFull(reader, header[:]); err != nil { if errors.Is(err, io.EOF) { break } return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err) } output, ok := buffers[header[0]] if !ok { return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0]) } payloadSize := int64(binary.BigEndian.Uint32(header[4:])) remaining := int64(output.limit - output.Len()) if payloadSize > remaining { return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining) } if _, err := io.CopyN(output, reader, payloadSize); err != nil { return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err) } } return stdout.Bytes(), stderr.Bytes(), nil } // CopyFromContainer copies a file from a container and returns a content stream. func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "CopyFromContainer", zerowrap.FieldEntityID: containerID, "src_path": srcPath, }) log := zerowrap.FromCtx(ctx) copyResult, err := r.client.CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{SourcePath: srcPath}) if err != nil { return nil, log.WrapErr(err, "failed to copy from container") } // The response is a tar archive - we need to extract the requested file stream. reader, err := extractFileFromTar(copyResult.Content, srcPath) if err != nil { _ = copyResult.Content.Close() return nil, log.WrapErr(err, "failed to extract file from tar") } return reader, nil } // extractFileFromTar extracts a single file from a tar archive. func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) { tr := tar.NewReader(reader) targetName := normalizedTarPath(targetPath) targetBase := filepath.Base(targetName) var fallback *removeOnCloseFile fallbackMatches := 0 for { header, err := tr.Next() if err == io.EOF { break } if err != nil { cleanupFallback(fallback) _ = reader.Close() return nil, err } if header.Typeflag != tar.TypeReg { continue } headerName := normalizedTarPath(header.Name) if headerName == targetName { cleanupFallback(fallback) return streamCurrentTarFile(reader, tr, header.Size), nil } if headerName == targetBase { fallbackMatches++ if fallbackMatches == 1 { fallback, err = bufferCurrentTarFile(tr, header.Size) if err != nil { _ = reader.Close() return nil, err } } } } _ = reader.Close() if fallbackMatches == 1 { if _, err := fallback.Seek(0, io.SeekStart); err != nil { cleanupFallback(fallback) return nil, err } return fallback, nil } cleanupFallback(fallback) if fallbackMatches > 1 { return nil, fmt.Errorf("ambiguous basename matches in container archive for: %s", targetPath) } return nil, fmt.Errorf("file not found in container: %s", targetPath) } func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser { pr, pw := io.Pipe() go func() { defer reader.Close() _, copyErr := io.CopyN(pw, tr, size) if copyErr != nil { _ = pw.CloseWithError(copyErr) return } _ = pw.Close() }() return &pipeReadCloser{pr: pr, pw: pw, original: reader} } func bufferCurrentTarFile(tr *tar.Reader, size int64) (*removeOnCloseFile, error) { file, err := os.CreateTemp("", "gordon-copy-from-container-*") if err != nil { return nil, err } fallback := &removeOnCloseFile{File: file, path: file.Name()} if _, err := io.CopyN(fallback, tr, size); err != nil { cleanupFallback(fallback) return nil, err } return fallback, nil } func cleanupFallback(fallback *removeOnCloseFile) { if fallback != nil { _ = fallback.Close() } } func normalizedTarPath(path string) string { path = filepath.ToSlash(filepath.Clean(path)) return strings.TrimPrefix(path, "/") } const maxExtractedEnvFileSize = 1 << 20 // 1 MiB func readExtractedEnvFile(reader io.Reader) ([]byte, error) { limited := io.LimitReader(reader, maxExtractedEnvFileSize+1) data, err := io.ReadAll(limited) if err != nil { return nil, err } if len(data) > maxExtractedEnvFileSize { return nil, fmt.Errorf("extracted env file exceeds %d bytes", maxExtractedEnvFileSize) } return data, nil } // ExtractEnvFileFromImage extracts an env file from an image. // This creates a temporary container, copies the file, and removes the container. func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ExtractEnvFileFromImage", "image": imageRef, "env_file": envFilePath, }) log := zerowrap.FromCtx(ctx) log.Info().Msg("extracting env file from image") // Create a temporary container without starting it containerConfig := &container.Config{ Image: imageRef, Cmd: []string{"true"}, // Dummy command } resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{Config: containerConfig}) if err != nil { return nil, log.WrapErr(err, "failed to create temporary container") } tempContainerID := resp.ID log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container") // Ensure cleanup defer func() { if _, err := r.client.ContainerRemove(ctx, tempContainerID, client.ContainerRemoveOptions{Force: true}); err != nil { log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container") } }() // Copy the env file from the container reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath) if err != nil { return nil, err } defer reader.Close() data, err := readExtractedEnvFile(reader) if err != nil { return nil, log.WrapErr(err, "failed to read extracted env file") } log.Info().Int("size", len(data)).Msg("env file extracted from image") return data, nil }