v3.0.0 f19f453

runtime.go RawHistoryFolder

1// Package docker implements the container runtime adapter using Docker API.
2package docker
3
4import (
5	"archive/tar"
6	"bytes"
7	"context"
8	"encoding/base64"
9	"encoding/binary"
10	"encoding/json"
11	"errors"
12	"fmt"
13	"io"
14	"maps"
15	"math"
16	"net"
17	"net/http"
18	"net/netip"
19	"net/url"
20	"os"
21	"path/filepath"
22	"sort"
23	"strconv"
24	"strings"
25	"sync"
26	"time"
27
28	"github.com/bnema/zerowrap"
29	cerrdefs "github.com/containerd/errdefs"
30	"github.com/docker/go-units"
31	"github.com/moby/moby/api/pkg/stdcopy"
32	"github.com/moby/moby/api/types/container"
33	"github.com/moby/moby/api/types/mount"
34	"github.com/moby/moby/api/types/network"
35	"github.com/moby/moby/api/types/registry"
36	"github.com/moby/moby/client"
37
38	"github.com/bnema/gordon/internal/boundaries/out"
39	"github.com/bnema/gordon/internal/domain"
40	runtimepkg "github.com/bnema/gordon/pkg/runtime"
41)
42
43// Runtime implements the ContainerRuntime interface using Docker API.
44type Runtime struct {
45	client      *client.Client
46	runtimeName string
47}
48
49var _ out.ContainerRuntime = (*Runtime)(nil)
50var _ out.VolumeArchiveExporter = (*Runtime)(nil)
51
52type pipeReadCloser struct {
53	pr       *io.PipeReader
54	pw       *io.PipeWriter
55	original io.Closer
56	cleanup  func() error
57	once     sync.Once
58}
59
60type removeOnCloseFile struct {
61	*os.File
62	path string
63}
64
65func (p *pipeReadCloser) Read(b []byte) (int, error) {
66	return p.pr.Read(b)
67}
68
69func (p *pipeReadCloser) Close() error {
70	var closeErr error
71	p.once.Do(func() {
72		if err := p.pw.CloseWithError(errors.New("reader closed")); err != nil && !errors.Is(err, io.ErrClosedPipe) {
73			closeErr = err
74		}
75		if err := p.pr.Close(); err != nil && closeErr == nil {
76			closeErr = err
77		}
78		if p.original != nil {
79			if err := p.original.Close(); err != nil && closeErr == nil {
80				closeErr = err
81			}
82		}
83		if p.cleanup != nil {
84			if err := p.cleanup(); err != nil && closeErr == nil {
85				closeErr = err
86			}
87		}
88	})
89	return closeErr
90}
91
92func (f *removeOnCloseFile) Close() error {
93	closeErr := f.File.Close()
94	removeErr := os.Remove(f.path)
95	if closeErr != nil {
96		return closeErr
97	}
98	return removeErr
99}
100
101// NewRuntime creates a new Docker runtime instance.
102func NewRuntime() (*Runtime, error) {
103	cli, err := client.New(client.FromEnv)
104	if err != nil {
105		return nil, fmt.Errorf("failed to create Docker client: %w", err)
106	}
107
108	return &Runtime{
109		client: cli,
110	}, nil
111}
112
113// NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path.
114func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {
115	host := "unix://" + socketPath
116	cli, err := client.New(client.WithHost(host))
117	if err != nil {
118		return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)
119	}
120
121	return &Runtime{client: cli, runtimeName: guessRuntimeName(socketPath)}, nil
122}
123
124// NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).
125func NewRuntimeWithClient(cli *client.Client) *Runtime {
126	return &Runtime{
127		client: cli,
128	}
129}
130
131// CreateContainer creates a new container.
132func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) {
133	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
134		zerowrap.FieldLayer:   "adapter",
135		zerowrap.FieldAdapter: "docker",
136		zerowrap.FieldAction:  "CreateContainer",
137		"container_name":      config.Name,
138		"image":               config.Image,
139	})
140	log := zerowrap.FromCtx(ctx)
141
142	exposedPorts, portBindings, err := buildPortBindings(config)
143	if err != nil {
144		return nil, err
145	}
146	binds := buildVolumeBinds(config, log)
147	mounts, err := buildBindMounts(config)
148	if err != nil {
149		return nil, err
150	}
151	// Device-bearing creates require a CDI-capable engine. The gate runs
152	// only when devices are requested: ordinary apps never pay for it
153	// and never fail it.
154	if len(config.CDIDevices) > 0 {
155		if err := r.requireCDISupport(ctx); err != nil {
156			return nil, err
157		}
158	}
159	deviceRequests := buildDeviceRequests(config)
160
161	// Create container configuration
162	containerConfig := &container.Config{
163		Image:        config.Image,
164		Hostname:     config.Hostname,
165		Env:          config.Env,
166		ExposedPorts: exposedPorts,
167		WorkingDir:   config.WorkingDir,
168		Cmd:          config.Cmd,
169		Entrypoint:   config.Entrypoint,
170		Labels:       config.Labels,
171		User:         config.User,
172	}
173
174	resources := container.Resources{
175		Memory:   config.MemoryLimit,
176		NanoCPUs: config.NanoCPUs,
177		Ulimits: []*units.Ulimit{
178			{Name: "nofile", Soft: 65536, Hard: 65536},
179		},
180		DeviceRequests: deviceRequests,
181	}
182	if config.PidsLimit > 0 {
183		resources.PidsLimit = &config.PidsLimit
184	}
185	capDrop := []string{"ALL"}
186	capAdd := []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
187	if config.CapDrop != nil {
188		capDrop = config.CapDrop
189	}
190	if config.CapAdd != nil {
191		capAdd = config.CapAdd
192	}
193	hostConfig := &container.HostConfig{
194		PortBindings:   portBindings,
195		AutoRemove:     config.AutoRemove,
196		Binds:          binds,
197		Mounts:         mounts,
198		NetworkMode:    container.NetworkMode(config.NetworkMode),
199		Resources:      resources,
200		SecurityOpt:    []string{"no-new-privileges:true"},
201		CapDrop:        capDrop,
202		CapAdd:         capAdd,
203		ReadonlyRootfs: config.ReadOnlyRootFS,
204	}
205	if config.RestartPolicy != "" {
206		hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)}
207	}
208
209	// Create network configuration for container
210	var networkConfig *network.NetworkingConfig
211	if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
212		networkConfig = &network.NetworkingConfig{
213			EndpointsConfig: map[string]*network.EndpointSettings{
214				config.NetworkMode: {
215					Aliases: config.Aliases,
216				},
217			},
218		}
219		if config.Hostname != "" {
220			networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
221		}
222	}
223
224	// Create the container.
225	resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
226		Config:           containerConfig,
227		HostConfig:       hostConfig,
228		NetworkingConfig: networkConfig,
229		Name:             config.Name,
230	})
231	if err != nil {
232		return nil, log.WrapErr(err, "failed to create container")
233	}
234
235	log.Info().Str(zerowrap.FieldEntityID, resp.ID).Msg("container created")
236
237	// Inspect the created container to get full details
238	return r.InspectContainer(ctx, resp.ID)
239}
240
241func buildPortBindings(config *domain.ContainerConfig) (network.PortSet, network.PortMap, error) {
242	exposedPorts := make(network.PortSet)
243	portBindings := make(network.PortMap)
244	if err := addLegacyPortBindings(config.Ports, exposedPorts, portBindings); err != nil {
245		return nil, nil, err
246	}
247	if err := addExplicitPortBindings(config.PortPublishes, exposedPorts, portBindings); err != nil {
248		return nil, nil, err
249	}
250	return exposedPorts, portBindings, nil
251}
252
253func addLegacyPortBindings(ports []int, exposedPorts network.PortSet, portBindings network.PortMap) error {
254	for _, port := range ports {
255		containerPort, err := network.ParsePort(fmt.Sprintf("%d/tcp", port))
256		if err != nil {
257			return fmt.Errorf("invalid container port %d: %w", port, err)
258		}
259		exposedPorts[containerPort] = struct{}{}
260
261		// Bind to random available port on localhost only.
262		// SECURITY: Using 127.0.0.1 prevents direct access from the network,
263		// forcing all traffic through Gordon's reverse proxy where auth,
264		// rate limiting, and security headers are applied.
265		portBindings[containerPort] = []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "0"}}
266	}
267	return nil
268}
269
270func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts network.PortSet, portBindings network.PortMap) error {
271	for _, publish := range publishes {
272		if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP {
273			return fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol)
274		}
275		containerPort, err := network.ParsePort(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))
276		if err != nil {
277			return fmt.Errorf("invalid container port %d: %w", publish.ContainerPort, err)
278		}
279		var hostIP netip.Addr
280		if publish.HostIP != "" {
281			hostIP, err = netip.ParseAddr(publish.HostIP)
282			if err != nil {
283				return fmt.Errorf("invalid host IP %q: %w", publish.HostIP, err)
284			}
285		}
286		exposedPorts[containerPort] = struct{}{}
287		portBindings[containerPort] = append(portBindings[containerPort], network.PortBinding{
288			HostIP:   hostIP,
289			HostPort: strconv.Itoa(publish.HostPort),
290		})
291	}
292	return nil
293}
294
295func buildVolumeBinds(config *domain.ContainerConfig, log zerowrap.Logger) []string {
296	binds := make([]string, 0, len(config.Volumes)+len(config.ReadOnlyVolumes))
297	for containerPath, volumeName := range config.Volumes {
298		bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
299		binds = append(binds, bind)
300		log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
301	}
302	for containerPath, volumeName := range config.ReadOnlyVolumes {
303		bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath)
304		binds = append(binds, bind)
305		log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount")
306	}
307	return binds
308}
309
310// buildBindMounts validates ephemeral host binds defensively and translates
311// them into Docker bind mounts. The result is sorted by destination so the
312// create request is deterministic. Host source paths are never logged or
313// echoed in errors.
314func buildBindMounts(config *domain.ContainerConfig) ([]mount.Mount, error) {
315	if len(config.Binds) == 0 {
316		return nil, nil
317	}
318	mounts := make([]mount.Mount, 0, len(config.Binds))
319	seen := make(map[string]string, len(config.Binds))
320	for _, bind := range config.Binds {
321		if bind.Source == "" || !filepath.IsAbs(bind.Source) || filepath.Clean(bind.Source) != bind.Source {
322			return nil, fmt.Errorf("invalid bind %q: source must be an absolute clean path", bind.Name)
323		}
324		if err := domain.ValidateBindDestination(bind.Destination); err != nil {
325			return nil, fmt.Errorf("invalid bind %q: %w", bind.Name, err)
326		}
327		if prev, ok := seen[bind.Destination]; ok {
328			return nil, fmt.Errorf("invalid bind %q: destination %q already used by bind %q", bind.Name, bind.Destination, prev)
329		}
330		seen[bind.Destination] = bind.Name
331		mounts = append(mounts, mount.Mount{
332			Type:     mount.TypeBind,
333			Source:   bind.Source,
334			Target:   bind.Destination,
335			ReadOnly: bind.ReadOnly,
336		})
337	}
338	sort.SliceStable(mounts, func(i, j int) bool {
339		if mounts[i].Target != mounts[j].Target {
340			return mounts[i].Target < mounts[j].Target
341		}
342		return mounts[i].Source < mounts[j].Source
343	})
344	return mounts, nil
345}
346
347// requireCDISupport fails closed when the connected engine cannot serve
348// native CDI device requests. Family and version come from the daemon's
349// own /version response; Gordon requires Podman 5.4+ or Docker 28.3+ for
350// device-bearing creates. The version is a capability gate, not hardware
351// proof: CDI specs, toolkit, and node permissions must still be correct
352// on the host.
353func (r *Runtime) requireCDISupport(ctx context.Context) error {
354	version, err := r.client.ServerVersion(ctx, client.ServerVersionOptions{})
355	if err != nil {
356		return engineProbeError(ctx, err)
357	}
358	if err := checkCDISupport(version, r.runtimeName); err != nil {
359		return err
360	}
361	return nil
362}
363
364// engineProbeError maps a failed /version probe onto the CDI capability
365// gate. Cancellation and deadlines survive so callers can still tell an
366// aborted probe from an incapable engine; every other cause collapses to
367// ErrRuntimeUnsupported with the cause text dropped, because transport
368// errors embed the daemon endpoint and the operator's home path.
369func engineProbeError(ctx context.Context, err error) error {
370	var ctxErr error
371	switch {
372	case errors.Is(err, context.Canceled), errors.Is(ctx.Err(), context.Canceled):
373		ctxErr = context.Canceled
374	case errors.Is(err, context.DeadlineExceeded), errors.Is(ctx.Err(), context.DeadlineExceeded):
375		ctxErr = context.DeadlineExceeded
376	}
377	if ctxErr != nil {
378		return fmt.Errorf("%w: cannot verify engine version: %w", domain.ErrRuntimeUnsupported, ctxErr)
379	}
380	return fmt.Errorf("%w: cannot verify engine version", domain.ErrRuntimeUnsupported)
381}
382
383// SupportsCDIDevices implements out.ContainerRuntime. Deployment calls it
384// in preflight for device-bearing revisions so an unsupported engine
385// fails before any workload mutation; CreateContainer keeps the same gate
386// as defense in depth.
387func (r *Runtime) SupportsCDIDevices(ctx context.Context) error {
388	return r.requireCDISupport(ctx)
389}
390
391// checkCDISupport verifies one daemon version response against the CDI
392// support matrix. Family detection prefers the daemon's own components
393// (Podman Engine vs Engine) and falls back to the socket-path hint only
394// when the response names nothing recognizable.
395func checkCDISupport(version client.ServerVersionResult, socketHint string) error {
396	family, daemonVersion := cdiEngineIdentity(version, socketHint)
397	var minimum string
398	switch family {
399	case "podman":
400		minimum = "5.4"
401	case "docker":
402		minimum = "28.3"
403	default:
404		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)
405	}
406	if compareEngineVersion(daemonVersion, minimum) < 0 {
407		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)
408	}
409	return nil
410}
411
412// cdiEngineIdentity names the daemon family and its version from the
413// /version response. Podman answers with a "Podman Engine" component;
414// Docker answers with an "Engine" component and its version at top
415// level. Unknown responses keep the raw version with an "unknown"
416// family so the caller fails closed.
417func cdiEngineIdentity(version client.ServerVersionResult, socketHint string) (family, daemonVersion string) {
418	for _, component := range version.Components {
419		switch component.Name {
420		case "Podman Engine":
421			return "podman", component.Version
422		case "Engine":
423			return "docker", component.Version
424		}
425	}
426	if strings.Contains(strings.ToLower(version.Platform.Name), "podman") {
427		return "podman", version.Version
428	}
429	switch socketHint {
430	case "podman", "docker":
431		return socketHint, version.Version
432	}
433	return "unknown", version.Version
434}
435
436// compareEngineVersion compares dotted major.minor versions. A missing
437// or unparsable version compares below any minimum: the gate fails
438// closed rather than trusting an engine it cannot identify.
439func compareEngineVersion(version, minimum string) int {
440	parse := func(value string) (int, int) {
441		parts := strings.SplitN(value, ".", 3)
442		if len(parts) < 2 {
443			return -1, -1
444		}
445		major, ok := parseEngineVersionComponent(parts[0])
446		if !ok {
447			return -1, -1
448		}
449		minor, ok := parseEngineVersionComponent(parts[1])
450		if !ok {
451			return -1, -1
452		}
453		return major, minor
454	}
455	major, minor := parse(version)
456	minMajor, minMinor := parse(minimum)
457	switch {
458	case major != minMajor:
459		return major - minMajor
460	default:
461		return minor - minMinor
462	}
463}
464
465// parseEngineVersionComponent parses one numeric major/minor component.
466// Anything but digits is refused, so prerelease suffixes such as "-rc1"
467// and malformed versions can never lift an engine above the gate.
468func parseEngineVersionComponent(value string) (int, bool) {
469	value = strings.TrimSpace(value)
470	if value == "" {
471		return 0, false
472	}
473	for _, r := range value {
474		if r < '0' || r > '9' {
475			return 0, false
476		}
477	}
478	n, err := strconv.Atoi(value)
479	if err != nil {
480		return 0, false
481	}
482	return n, true
483}
484
485// buildDeviceRequests encodes ephemeral CDI device IDs as one native CDI
486// DeviceRequest. Capabilities and Options stay empty; Count stays 0 so the
487// explicit DeviceIDs are the only grant. CDI IDs are never logged or echoed
488// in errors: they are host inventory.
489func buildDeviceRequests(config *domain.ContainerConfig) []container.DeviceRequest {
490	if len(config.CDIDevices) == 0 {
491		return nil
492	}
493	ids := append([]string(nil), config.CDIDevices...)
494	sort.Strings(ids)
495	return []container.DeviceRequest{
496		{
497			Driver:    "cdi",
498			DeviceIDs: ids,
499		},
500	}
501}
502
503// StartContainer starts a container.
504func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {
505	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
506		zerowrap.FieldLayer:    "adapter",
507		zerowrap.FieldAdapter:  "docker",
508		zerowrap.FieldAction:   "StartContainer",
509		zerowrap.FieldEntityID: containerID,
510	})
511	log := zerowrap.FromCtx(ctx)
512
513	_, err := r.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{})
514	if err != nil {
515		if cerrdefs.IsNotFound(err) {
516			return log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to start container")
517		}
518		return log.WrapErr(err, "failed to start container")
519	}
520
521	log.Info().Msg("container started")
522	return nil
523}
524
525// WaitForContainer waits for a container to stop running.
526func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error {
527	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
528		zerowrap.FieldLayer:    "adapter",
529		zerowrap.FieldAdapter:  "docker",
530		zerowrap.FieldAction:   "WaitForContainer",
531		zerowrap.FieldEntityID: containerID,
532	})
533	log := zerowrap.FromCtx(ctx)
534
535	waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
536	statusCh, errCh := waitResult.Result, waitResult.Error
537	select {
538	case err := <-errCh:
539		if err != nil {
540			return log.WrapErr(err, "failed to wait for container")
541		}
542	case <-statusCh:
543	}
544
545	log.Debug().Msg("container wait completed")
546	return nil
547}
548
549// StopContainer stops a container, giving it grace to exit before the
550// runtime kills it. A non-positive grace keeps the runtime default.
551func (r *Runtime) StopContainer(ctx context.Context, containerID string, grace time.Duration) error {
552	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
553		zerowrap.FieldLayer:    "adapter",
554		zerowrap.FieldAdapter:  "docker",
555		zerowrap.FieldAction:   "StopContainer",
556		zerowrap.FieldEntityID: containerID,
557	})
558	log := zerowrap.FromCtx(ctx)
559
560	_, err := r.client.ContainerStop(ctx, containerID, client.ContainerStopOptions{Timeout: stopTimeout(grace)})
561	if err != nil {
562		if cerrdefs.IsNotFound(err) {
563			return log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to stop container")
564		}
565		return log.WrapErr(err, "failed to stop container")
566	}
567
568	log.Info().Msg("container stopped")
569	return nil
570}
571
572// RestartContainer restarts a container, giving it grace to exit before
573// the runtime kills it. A non-positive grace keeps the runtime default.
574func (r *Runtime) RestartContainer(ctx context.Context, containerID string, grace time.Duration) error {
575	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
576		zerowrap.FieldLayer:    "adapter",
577		zerowrap.FieldAdapter:  "docker",
578		zerowrap.FieldAction:   "RestartContainer",
579		zerowrap.FieldEntityID: containerID,
580	})
581	log := zerowrap.FromCtx(ctx)
582
583	_, err := r.client.ContainerRestart(ctx, containerID, client.ContainerRestartOptions{Timeout: stopTimeout(grace)})
584	if err != nil {
585		if cerrdefs.IsNotFound(err) {
586			return log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to restart container")
587		}
588		return log.WrapErr(err, "failed to restart container")
589	}
590
591	log.Info().Msg("container restarted")
592	return nil
593}
594
595// stopTimeout converts a stop grace into the runtime API's whole-second
596// timeout. A non-positive grace returns nil, which keeps the runtime's
597// own default instead of an immediate kill. A fractional grace rounds up
598// so the container never receives less time than the spec asked for.
599func stopTimeout(grace time.Duration) *int {
600	if grace <= 0 {
601		return nil
602	}
603	seconds := int(math.Ceil(grace.Seconds()))
604	if seconds < 1 {
605		seconds = 1
606	}
607	return &seconds
608}
609
610// RemoveContainer removes a container.
611func (r *Runtime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
612	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
613		zerowrap.FieldLayer:    "adapter",
614		zerowrap.FieldAdapter:  "docker",
615		zerowrap.FieldAction:   "RemoveContainer",
616		zerowrap.FieldEntityID: containerID,
617		"force":                force,
618	})
619	log := zerowrap.FromCtx(ctx)
620
621	_, err := r.client.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{Force: force})
622	if err != nil {
623		if cerrdefs.IsNotFound(err) {
624			log.Debug().Msg("container not found, already removed")
625			return nil
626		}
627		return log.WrapErr(err, "failed to remove container")
628	}
629
630	log.Info().Msg("container removed")
631	return nil
632}
633
634// RenameContainer renames a container.
635func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName string) error {
636	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
637		zerowrap.FieldLayer:    "adapter",
638		zerowrap.FieldAdapter:  "docker",
639		zerowrap.FieldAction:   "RenameContainer",
640		zerowrap.FieldEntityID: containerID,
641		"new_name":             newName,
642	})
643	log := zerowrap.FromCtx(ctx)
644
645	_, err := r.client.ContainerRename(ctx, containerID, client.ContainerRenameOptions{NewName: newName})
646	if err != nil {
647		return log.WrapErr(err, "failed to rename container")
648	}
649
650	log.Info().Msg("container renamed")
651	return nil
652}
653
654// ListContainers lists containers.
655func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {
656	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
657		zerowrap.FieldLayer:   "adapter",
658		zerowrap.FieldAdapter: "docker",
659		zerowrap.FieldAction:  "ListContainers",
660		"all":                 all,
661	})
662	log := zerowrap.FromCtx(ctx)
663
664	containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: all})
665	if err != nil {
666		return nil, log.WrapErr(err, "failed to list containers")
667	}
668
669	var result []*domain.Container
670	for _, c := range containerList.Items {
671		// Extract ports
672		var ports []int
673		for _, port := range c.Ports {
674			if port.PublicPort > 0 {
675				ports = append(ports, int(port.PublicPort))
676			}
677		}
678
679		// Get the primary name (remove leading slash)
680		name := ""
681		if len(c.Names) > 0 {
682			name = strings.TrimPrefix(c.Names[0], "/")
683		}
684
685		volumeMounts := make([]domain.ContainerVolumeMount, 0, len(c.Mounts))
686		for _, m := range c.Mounts {
687			volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
688				Name:        m.Name,
689				Type:        string(m.Type),
690				Source:      m.Source,
691				Destination: m.Destination,
692				ReadOnly:    !m.RW,
693			})
694		}
695
696		result = append(result, &domain.Container{
697			ID:           c.ID,
698			Image:        c.Image,
699			ImageID:      c.ImageID,
700			Name:         name,
701			Status:       string(c.State), // Use State (e.g., "running") not Status (e.g., "Up 2 days")
702			Ports:        ports,
703			Labels:       c.Labels,
704			VolumeMounts: volumeMounts,
705			Created:      time.Unix(c.Created, 0),
706		})
707	}
708
709	return result, nil
710}
711
712// InspectContainer inspects a container.
713func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*domain.Container, error) {
714	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
715		zerowrap.FieldLayer:    "adapter",
716		zerowrap.FieldAdapter:  "docker",
717		zerowrap.FieldAction:   "InspectContainer",
718		zerowrap.FieldEntityID: containerID,
719	})
720	log := zerowrap.FromCtx(ctx)
721
722	inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
723	if err != nil {
724		if cerrdefs.IsNotFound(err) {
725			return nil, log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to inspect container")
726		}
727		return nil, log.WrapErr(err, "failed to inspect container")
728	}
729	resp := inspectResult.Container
730
731	// Extract published ports
732	var ports []int
733	if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
734		for _, bindings := range resp.NetworkSettings.Ports {
735			for _, binding := range bindings {
736				if binding.HostPort != "" {
737					if port, err := strconv.Atoi(binding.HostPort); err == nil {
738						ports = append(ports, port)
739					}
740				}
741			}
742		}
743	}
744
745	// Get container name (remove leading slash)
746	name := strings.TrimPrefix(resp.Name, "/")
747
748	created, _ := time.Parse(time.RFC3339Nano, resp.Created)
749	// StartedAt scopes log readiness to the current execution: markers
750	// from a previous execution of the same container ID must not
751	// satisfy a probe. An unparsable value stays zero and is rejected by
752	// the readiness probe rather than silently widening the scope.
753	startedAt, _ := time.Parse(time.RFC3339Nano, resp.State.StartedAt)
754	if startedAt.Year() <= 1 {
755		startedAt = time.Time{}
756	}
757	volumeMounts := make([]domain.ContainerVolumeMount, 0, len(resp.Mounts))
758	for _, m := range resp.Mounts {
759		volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
760			Name:        m.Name,
761			Type:        string(m.Type),
762			Source:      m.Source,
763			Destination: m.Destination,
764			ReadOnly:    !m.RW,
765		})
766	}
767
768	return &domain.Container{
769		ID:           resp.ID,
770		Image:        resp.Config.Image,
771		ImageID:      resp.Image,
772		Name:         name,
773		Status:       string(resp.State.Status),
774		ExitCode:     resp.State.ExitCode,
775		Ports:        ports,
776		Labels:       resp.Config.Labels,
777		VolumeMounts: volumeMounts,
778		Created:      created,
779		StartedAt:    startedAt,
780		Env:          resp.Config.Env,
781	}, nil
782}
783
784// GetContainerLogsSince gets container logs emitted at or after since.
785// Readiness uses it with the observed execution start so markers from a
786// previous execution of the same container ID cannot match.
787func (r *Runtime) GetContainerLogsSince(ctx context.Context, containerID string, since time.Time, follow bool) (io.ReadCloser, error) {
788	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
789		zerowrap.FieldLayer:    "adapter",
790		zerowrap.FieldAdapter:  "docker",
791		zerowrap.FieldAction:   "GetContainerLogsSince",
792		zerowrap.FieldEntityID: containerID,
793	})
794	log := zerowrap.FromCtx(ctx)
795
796	options := client.ContainerLogsOptions{
797		ShowStdout: true,
798		ShowStderr: true,
799		Follow:     follow,
800		Timestamps: true,
801		Tail:       "10000",
802	}
803	if !since.IsZero() {
804		// RFC3339Nano keeps the sub-second part of the execution start:
805		// a marker emitted in the same second as a previous execution
806		// must not satisfy the probe.
807		options.Since = since.UTC().Format(time.RFC3339Nano)
808	}
809	logs, err := r.client.ContainerLogs(ctx, containerID, options)
810	if err != nil {
811		if cerrdefs.IsNotFound(err) {
812			return nil, log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to get container logs")
813		}
814		return nil, log.WrapErr(err, "failed to get container logs")
815	}
816
817	return logs, nil
818}
819
820// GetContainerLogs gets container logs.
821func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
822	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
823		zerowrap.FieldLayer:    "adapter",
824		zerowrap.FieldAdapter:  "docker",
825		zerowrap.FieldAction:   "GetContainerLogs",
826		zerowrap.FieldEntityID: containerID,
827	})
828	log := zerowrap.FromCtx(ctx)
829
830	logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{
831		ShowStdout: true,
832		ShowStderr: true,
833		Follow:     follow,
834		Timestamps: true,
835		Tail:       "10000",
836	})
837	if err != nil {
838		return nil, log.WrapErr(err, "failed to get container logs")
839	}
840
841	return logs, nil
842}
843
844func (r *Runtime) PullImageWithOptions(ctx context.Context, request domain.ImagePullRequest) error {
845	if request.Transport == domain.ImagePullTransportHTTP {
846		if r.runtimeName == "podman" || strings.Contains(strings.ToLower(r.client.DaemonHost()), "podman") {
847			return r.pullPodmanHTTP(ctx, request)
848		}
849		// Docker has no per-pull HTTP switch. Its daemon honors HTTP only for
850		// endpoints configured in insecure-registries; retain the compatible
851		// pull API and let a missing daemon policy fail explicitly.
852		if request.Username != "" || request.Password != "" {
853			return r.PullImageWithAuth(ctx, request.Reference, request.Username, request.Password)
854		}
855		return r.PullImage(ctx, request.Reference)
856	}
857	if request.Username != "" || request.Password != "" {
858		return r.PullImageWithAuth(ctx, request.Reference, request.Username, request.Password)
859	}
860	return r.PullImage(ctx, request.Reference)
861}
862
863func (r *Runtime) pullPodmanHTTP(ctx context.Context, request domain.ImagePullRequest) error {
864	host := r.client.DaemonHost()
865	if !strings.HasPrefix(host, "unix://") {
866		return fmt.Errorf("unsupported Podman endpoint %q for HTTP pull", host)
867	}
868	httpClient := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
869		return (&net.Dialer{}).DialContext(ctx, "unix", strings.TrimPrefix(host, "unix://"))
870	}}}
871	query := url.Values{"reference": {request.Reference}, "tlsVerify": {"false"}, "quiet": {"false"}}
872	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://podman/v5.0.0/libpod/images/pull?"+query.Encode(), nil)
873	if err != nil {
874		return err
875	}
876	if request.Username != "" || request.Password != "" {
877		auth, err := json.Marshal(registry.AuthConfig{Username: request.Username, Password: request.Password})
878		if err != nil {
879			return err
880		}
881		req.Header.Set(registry.AuthHeader, base64.StdEncoding.EncodeToString(auth))
882	}
883	resp, err := httpClient.Do(req)
884	if err != nil {
885		return fmt.Errorf("podman HTTP image pull: %w", err)
886	}
887	defer resp.Body.Close()
888	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
889		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
890		return fmt.Errorf("podman HTTP image pull returned %s: %s: %w", resp.Status, strings.TrimSpace(string(body)), domain.ErrImagePullFailed)
891	}
892	decoder := json.NewDecoder(resp.Body)
893	for {
894		var event struct {
895			Error string `json:"error"`
896		}
897		if err := decoder.Decode(&event); errors.Is(err, io.EOF) {
898			break
899		} else if err != nil {
900			return fmt.Errorf("decode Podman pull response: %w", err)
901		} else if event.Error != "" {
902			return fmt.Errorf("podman image pull: %s: %w", event.Error, domain.ErrImagePullFailed)
903		}
904	}
905	return nil
906}
907
908// PullImage pulls an image.
909func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
910	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
911		zerowrap.FieldLayer:   "adapter",
912		zerowrap.FieldAdapter: "docker",
913		zerowrap.FieldAction:  "PullImage",
914		"image":               imageRef,
915	})
916	log := zerowrap.FromCtx(ctx)
917
918	log.Info().Msg("pulling image")
919
920	reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{})
921	if err != nil {
922		return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
923	}
924	defer reader.Close()
925
926	if err := reader.Wait(ctx); err != nil {
927		return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete image pull")
928	}
929
930	log.Info().Msg("image pulled successfully")
931	return nil
932}
933
934// PullImageWithAuth pulls an image with authentication.
935func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
936	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
937		zerowrap.FieldLayer:   "adapter",
938		zerowrap.FieldAdapter: "docker",
939		zerowrap.FieldAction:  "PullImageWithAuth",
940		"image":               imageRef,
941		"username":            username,
942	})
943	log := zerowrap.FromCtx(ctx)
944
945	log.Info().Msg("pulling image with authentication")
946
947	// Extract registry server address from image reference
948	// e.g., "registry.bnema.dev/image:tag" -> "registry.bnema.dev"
949	serverAddress := imageRef
950	if idx := strings.Index(imageRef, "/"); idx > 0 {
951		serverAddress = imageRef[:idx]
952	}
953
954	// Create authentication configuration
955	authConfig := registry.AuthConfig{
956		Username:      username,
957		Password:      password,
958		ServerAddress: serverAddress,
959	}
960
961	// Encode authentication to base64 JSON
962	// Note: Docker API accepts both StdEncoding and URLEncoding, but Podman
963	// may be more strict. Using StdEncoding for maximum compatibility.
964	authConfigBytes, err := json.Marshal(authConfig)
965	if err != nil {
966		return log.WrapErr(err, "failed to marshal auth config")
967	}
968	authStr := base64.StdEncoding.EncodeToString(authConfigBytes)
969
970	log.Debug().
971		Str("server_address", serverAddress).
972		Bool("has_auth", username != "" || password != "").
973		Msg("auth config for pull")
974
975	// Pull with authentication
976	reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{
977		RegistryAuth: authStr,
978	})
979	if err != nil {
980		return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth")
981	}
982	defer reader.Close()
983
984	if err := reader.Wait(ctx); err != nil {
985		return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete authenticated image pull")
986	}
987
988	log.Info().Msg("image pulled successfully with authentication")
989	return nil
990}
991
992// TagImage tags a locally available image with a new reference.
993func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) error {
994	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
995		zerowrap.FieldLayer:   "adapter",
996		zerowrap.FieldAdapter: "docker",
997		zerowrap.FieldAction:  "TagImage",
998		"source":              sourceRef,
999		"target":              targetRef,
1000	})
1001	log := zerowrap.FromCtx(ctx)
1002
1003	if _, err := r.client.ImageTag(ctx, client.ImageTagOptions{Source: sourceRef, Target: targetRef}); err != nil {
1004		return log.WrapErr(err, "failed to tag image")
1005	}
1006
1007	log.Info().Msg("image tagged successfully")
1008	return nil
1009}
1010
1011// UntagImage removes a tag from an image without deleting the underlying image layers.
1012func (r *Runtime) UntagImage(ctx context.Context, imageRef string) error {
1013	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1014		zerowrap.FieldLayer:   "adapter",
1015		zerowrap.FieldAdapter: "docker",
1016		zerowrap.FieldAction:  "UntagImage",
1017		"image":               imageRef,
1018	})
1019	log := zerowrap.FromCtx(ctx)
1020
1021	// ImageRemove with PruneChildren=false only removes the tag, not the image layers
1022	_, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{
1023		Force:         false,
1024		PruneChildren: false,
1025	})
1026	if err != nil {
1027		return log.WrapErr(err, "failed to untag image")
1028	}
1029
1030	log.Debug().Msg("image untagged successfully")
1031	return nil
1032}
1033
1034// RemoveImage removes an image.
1035func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
1036	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1037		zerowrap.FieldLayer:   "adapter",
1038		zerowrap.FieldAdapter: "docker",
1039		zerowrap.FieldAction:  "RemoveImage",
1040		"image":               imageRef,
1041		"force":               force,
1042	})
1043	log := zerowrap.FromCtx(ctx)
1044
1045	_, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{Force: force})
1046	if err != nil {
1047		return log.WrapErr(err, "failed to remove image")
1048	}
1049
1050	log.Info().Msg("image removed")
1051	return nil
1052}
1053
1054// ListImages lists images.
1055func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
1056	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1057		zerowrap.FieldLayer:   "adapter",
1058		zerowrap.FieldAdapter: "docker",
1059		zerowrap.FieldAction:  "ListImages",
1060	})
1061	log := zerowrap.FromCtx(ctx)
1062
1063	imageList, err := r.client.ImageList(ctx, client.ImageListOptions{})
1064	if err != nil {
1065		return nil, log.WrapErr(err, "failed to list images")
1066	}
1067
1068	var result []string
1069	for _, img := range imageList.Items {
1070		for _, tag := range img.RepoTags {
1071			if tag != "<none>:<none>" {
1072				result = append(result, tag)
1073			}
1074		}
1075	}
1076
1077	return result, nil
1078}
1079
1080// ListImagesDetailed lists images with metadata.
1081func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDetail, error) {
1082	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1083		zerowrap.FieldLayer:   "adapter",
1084		zerowrap.FieldAdapter: "docker",
1085		zerowrap.FieldAction:  "ListImagesDetailed",
1086	})
1087	log := zerowrap.FromCtx(ctx)
1088
1089	imageList, err := r.client.ImageList(ctx, client.ImageListOptions{All: true})
1090	if err != nil {
1091		return nil, log.WrapErr(err, "failed to list detailed images")
1092	}
1093
1094	result := make([]runtimepkg.ImageDetail, 0, len(imageList.Items))
1095	for _, img := range imageList.Items {
1096		result = append(result, runtimepkg.ImageDetail{
1097			ID:       img.ID,
1098			RepoTags: img.RepoTags,
1099			Size:     img.Size,
1100			Created:  time.Unix(img.Created, 0),
1101		})
1102	}
1103
1104	return result, nil
1105}
1106
1107// Ping checks if Docker is responsive.
1108func (r *Runtime) Ping(ctx context.Context) error {
1109	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1110		zerowrap.FieldLayer:   "adapter",
1111		zerowrap.FieldAdapter: "docker",
1112		zerowrap.FieldAction:  "Ping",
1113	})
1114	log := zerowrap.FromCtx(ctx)
1115
1116	_, err := r.client.Ping(ctx, client.PingOptions{})
1117	if err != nil {
1118		return log.WrapErr(err, "Docker ping failed")
1119	}
1120	return nil
1121}
1122
1123// Version returns Docker version.
1124func (r *Runtime) Version(ctx context.Context) (string, error) {
1125	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1126		zerowrap.FieldLayer:   "adapter",
1127		zerowrap.FieldAdapter: "docker",
1128		zerowrap.FieldAction:  "Version",
1129	})
1130	log := zerowrap.FromCtx(ctx)
1131
1132	version, err := r.client.ServerVersion(ctx, client.ServerVersionOptions{})
1133	if err != nil {
1134		return "", log.WrapErr(err, "failed to get Docker version")
1135	}
1136	return version.Version, nil
1137}
1138
1139// IsContainerRunning checks if a container is running.
1140func (r *Runtime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
1141	ctr, err := r.InspectContainer(ctx, containerID)
1142	if err != nil {
1143		return false, err
1144	}
1145	return ctr.Status == string(domain.ContainerStatusRunning), nil
1146}
1147
1148// GetContainerHealthStatus returns Docker healthcheck status when available.
1149func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) {
1150	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1151		zerowrap.FieldLayer:    "adapter",
1152		zerowrap.FieldAdapter:  "docker",
1153		zerowrap.FieldAction:   "GetContainerHealthStatus",
1154		zerowrap.FieldEntityID: containerID,
1155	})
1156	log := zerowrap.FromCtx(ctx)
1157
1158	inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1159	if err != nil {
1160		return "", false, log.WrapErr(err, "failed to inspect container")
1161	}
1162	resp := inspectResult.Container
1163
1164	// Detect configured healthchecks from container config rather than relying on
1165	// State.Health presence. Some runtimes can expose an empty State.Health struct
1166	// even when no healthcheck is configured.
1167	hasHealthcheck := hasConfiguredHealthcheck(resp.Config)
1168	if !hasHealthcheck {
1169		return "", false, nil
1170	}
1171	if resp.State == nil || resp.State.Health == nil {
1172		return "", true, nil
1173	}
1174
1175	return string(resp.State.Health.Status), true, nil
1176}
1177
1178func hasConfiguredHealthcheck(cfg *container.Config) bool {
1179	if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 {
1180		return false
1181	}
1182	// Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images.
1183	return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE")
1184}
1185
1186// GetContainerBackendBinds resolves protocol-specific container ports to
1187// their host binds in a single container inspection.
1188func (r *Runtime) GetContainerBackendBinds(ctx context.Context, containerID string, ports []domain.ContainerBackendPort) ([]domain.ContainerBackendBind, error) {
1189	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1190		zerowrap.FieldLayer:    "adapter",
1191		zerowrap.FieldAdapter:  "docker",
1192		zerowrap.FieldAction:   "GetContainerBackendBinds",
1193		zerowrap.FieldEntityID: containerID,
1194		"ports":                len(ports),
1195	})
1196	log := zerowrap.FromCtx(ctx)
1197
1198	inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1199	if err != nil {
1200		return nil, log.WrapErr(err, "failed to inspect container")
1201	}
1202	resp := inspectResult.Container
1203
1204	if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
1205		return nil, fmt.Errorf("no port mappings found for container %s", containerID)
1206	}
1207
1208	binds := make([]domain.ContainerBackendBind, 0, len(ports))
1209	for _, port := range ports {
1210		containerPort, err := network.ParsePort(fmt.Sprintf("%d/%s", port.ContainerPort, port.Protocol))
1211		if err != nil {
1212			return nil, fmt.Errorf("invalid container port %d: %w", port.ContainerPort, err)
1213		}
1214		bindings, exists := resp.NetworkSettings.Ports[containerPort]
1215		if !exists || len(bindings) == 0 {
1216			return nil, fmt.Errorf("port %d/%s not mapped for container %s", port.ContainerPort, port.Protocol, containerID)
1217		}
1218		// Exactly one loopback binding is required: a wildcard, multiple,
1219		// or non-loopback mapping would expose the backend beyond the
1220		// loopback contract the proxy and readiness rely on.
1221		if len(bindings) != 1 {
1222			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)
1223		}
1224		binding := bindings[0]
1225		if binding.HostIP != netip.MustParseAddr("127.0.0.1") {
1226			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)
1227		}
1228		hostPort, err := strconv.Atoi(binding.HostPort)
1229		if err != nil || hostPort < 1 || hostPort > 65535 {
1230			return nil, fmt.Errorf("invalid host port %q for container %s", binding.HostPort, containerID)
1231		}
1232		binds = append(binds, domain.ContainerBackendBind{
1233			ContainerPort: port.ContainerPort,
1234			HostPort:      hostPort,
1235			Protocol:      port.Protocol,
1236		})
1237	}
1238
1239	return binds, nil
1240}
1241
1242// GetImageExposedPorts gets the exposed ports from an image.
1243func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
1244	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1245		zerowrap.FieldLayer:   "adapter",
1246		zerowrap.FieldAdapter: "docker",
1247		zerowrap.FieldAction:  "GetImageExposedPorts",
1248		"image":               imageRef,
1249	})
1250	log := zerowrap.FromCtx(ctx)
1251
1252	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1253	if err != nil {
1254		return nil, log.WrapErr(err, "failed to inspect image")
1255	}
1256
1257	var ports []int
1258	if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
1259		for portSpec := range imageInspect.Config.ExposedPorts {
1260			// Parse port from format like "80/tcp"
1261			portStr := strings.Split(portSpec, "/")[0]
1262			if port, err := strconv.Atoi(portStr); err == nil {
1263				ports = append(ports, port)
1264			}
1265		}
1266	}
1267
1268	if len(ports) == 0 {
1269		return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
1270	}
1271
1272	// Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.)
1273	// Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending
1274	sortPortsHTTPFirst(ports)
1275
1276	log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")
1277	return ports, nil
1278}
1279
1280// sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22).
1281func sortPortsHTTPFirst(ports []int) {
1282	priority := map[int]int{
1283		80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6,
1284	}
1285	sort.Slice(ports, func(i, j int) bool {
1286		pi, oki := priority[ports[i]]
1287		pj, okj := priority[ports[j]]
1288		if oki && okj {
1289			return pi < pj
1290		}
1291		if oki {
1292			return true
1293		}
1294		if okj {
1295			return false
1296		}
1297		// Neither is a priority port - sort ascending but push 22 to the end
1298		if ports[i] == 22 {
1299			return false
1300		}
1301		if ports[j] == 22 {
1302			return true
1303		}
1304		return ports[i] < ports[j]
1305	})
1306}
1307
1308// GetContainerExposedPorts gets all exposed ports from a running container.
1309func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
1310	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1311		zerowrap.FieldLayer:    "adapter",
1312		zerowrap.FieldAdapter:  "docker",
1313		zerowrap.FieldAction:   "GetContainerExposedPorts",
1314		zerowrap.FieldEntityID: containerID,
1315	})
1316	log := zerowrap.FromCtx(ctx)
1317
1318	inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1319	if err != nil {
1320		return nil, log.WrapErr(err, "failed to inspect container")
1321	}
1322	resp := inspectResult.Container
1323
1324	var ports []int
1325	if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
1326		for portSpec := range resp.NetworkSettings.Ports {
1327			// Parse port from format like "80/tcp".
1328			portStr := portSpec.Port()
1329			if port, err := strconv.Atoi(portStr); err == nil {
1330				ports = append(ports, port)
1331			}
1332		}
1333	}
1334
1335	return ports, nil
1336}
1337
1338// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use.
1339func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
1340	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1341		zerowrap.FieldLayer:    "adapter",
1342		zerowrap.FieldAdapter:  "docker",
1343		zerowrap.FieldAction:   "GetContainerNetworkInfo",
1344		zerowrap.FieldEntityID: containerID,
1345	})
1346	log := zerowrap.FromCtx(ctx)
1347
1348	inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1349	if err != nil {
1350		return "", 0, log.WrapErr(err, "failed to inspect container")
1351	}
1352	resp := inspectResult.Container
1353
1354	// Get container's internal IP address
1355	var containerIP string
1356	if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
1357		// Use the first available network (usually bridge or custom network)
1358		for _, net := range resp.NetworkSettings.Networks {
1359			if net.IPAddress.IsValid() {
1360				containerIP = net.IPAddress.String()
1361				break
1362			}
1363		}
1364	}
1365
1366	if containerIP == "" {
1367		return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
1368	}
1369
1370	// Get exposed ports from container
1371	var exposedPorts []int
1372	if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
1373		for portSpec := range resp.NetworkSettings.Ports {
1374			// Parse port from format like "80/tcp".
1375			if port, err := strconv.Atoi(portSpec.Port()); err == nil {
1376				exposedPorts = append(exposedPorts, port)
1377			}
1378		}
1379	}
1380
1381	if len(exposedPorts) == 0 {
1382		return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
1383	}
1384
1385	// Sort ports with HTTP-friendly ports first, pushing SSH (22) to the end
1386	sortPortsHTTPFirst(exposedPorts)
1387	selectedPort := exposedPorts[0]
1388
1389	log.Info().
1390		Str("ip", containerIP).
1391		Int("selected_port", selectedPort).
1392		Ints("exposed_ports", exposedPorts).
1393		Msg("container network info detected")
1394
1395	return containerIP, selectedPort, nil
1396}
1397
1398// GetContainerNetwork returns the network name a container is connected to.
1399func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) {
1400	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1401		zerowrap.FieldLayer:    "adapter",
1402		zerowrap.FieldAdapter:  "docker",
1403		zerowrap.FieldAction:   "GetContainerNetwork",
1404		zerowrap.FieldEntityID: containerID,
1405	})
1406	log := zerowrap.FromCtx(ctx)
1407
1408	inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1409	if err != nil {
1410		return "", log.WrapErr(err, "failed to inspect container")
1411	}
1412
1413	networks := inspectResult.Container.NetworkSettings
1414	if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {
1415		return "bridge", nil
1416	}
1417
1418	// Priority: gordon-* networks first, then bridge, then any other
1419	selected := ""
1420	hasBridge := false
1421	for name := range networks.Networks {
1422		if strings.HasPrefix(name, "gordon-") {
1423			selected = name
1424			break
1425		}
1426		if name == "bridge" {
1427			hasBridge = true
1428		} else if selected == "" {
1429			selected = name
1430		}
1431	}
1432
1433	// If no gordon-* network found, prefer bridge over random network
1434	if selected == "" || (hasBridge && !strings.HasPrefix(selected, "gordon-")) {
1435		if hasBridge {
1436			selected = "bridge"
1437		}
1438	}
1439
1440	if selected == "" {
1441		return "bridge", nil
1442	}
1443
1444	log.Debug().Str("network", selected).Msg("container network detected")
1445	return selected, nil
1446}
1447
1448// InspectImageVolumes gets the volume mount points declared in the image.
1449func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
1450	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1451		zerowrap.FieldLayer:   "adapter",
1452		zerowrap.FieldAdapter: "docker",
1453		zerowrap.FieldAction:  "InspectImageVolumes",
1454		"image":               imageRef,
1455	})
1456	log := zerowrap.FromCtx(ctx)
1457
1458	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1459	if err != nil {
1460		return nil, log.WrapErr(err, "failed to inspect image")
1461	}
1462
1463	var volumes []string
1464	if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
1465		for volumePath := range imageInspect.Config.Volumes {
1466			volumes = append(volumes, volumePath)
1467		}
1468	}
1469
1470	if len(volumes) > 0 {
1471		log.Info().Strs("volumes", volumes).Msg("found VOLUME directives in image")
1472	} else {
1473		log.Debug().Msg("no VOLUME directives found in image")
1474	}
1475
1476	return volumes, nil
1477}
1478
1479// VolumeExists checks if a Docker volume exists.
1480func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
1481	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1482		zerowrap.FieldLayer:   "adapter",
1483		zerowrap.FieldAdapter: "docker",
1484		zerowrap.FieldAction:  "VolumeExists",
1485		"volume":              volumeName,
1486	})
1487	log := zerowrap.FromCtx(ctx)
1488
1489	_, err := r.client.VolumeInspect(ctx, volumeName, client.VolumeInspectOptions{})
1490	if err != nil {
1491		if cerrdefs.IsNotFound(err) {
1492			return false, nil
1493		}
1494		return false, log.WrapErr(err, "failed to inspect volume")
1495	}
1496	return true, nil
1497}
1498
1499// CreateVolume creates a new Docker volume.
1500func (r *Runtime) CreateVolume(ctx context.Context, volumeName string, labels map[string]string) error {
1501	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1502		zerowrap.FieldLayer:   "adapter",
1503		zerowrap.FieldAdapter: "docker",
1504		zerowrap.FieldAction:  "CreateVolume",
1505		"volume":              volumeName,
1506	})
1507	log := zerowrap.FromCtx(ctx)
1508
1509	// The managed marker and creation time are adapter-owned; every
1510	// caller-supplied label is preserved so ownership provenance is
1511	// stamped at creation, not reconstructed later.
1512	merged := make(map[string]string, len(labels)+2)
1513	maps.Copy(merged, labels)
1514	merged[domain.LabelManaged] = "true"
1515	merged[domain.LabelCreated] = time.Now().UTC().Format(time.RFC3339)
1516
1517	_, err := r.client.VolumeCreate(ctx, client.VolumeCreateOptions{
1518		Name:   volumeName,
1519		Labels: merged,
1520	})
1521	if err != nil {
1522		return log.WrapErr(err, "failed to create volume")
1523	}
1524
1525	log.Info().Msg("volume created")
1526	return nil
1527}
1528
1529// RemoveVolume removes a Docker volume.
1530func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
1531	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1532		zerowrap.FieldLayer:   "adapter",
1533		zerowrap.FieldAdapter: "docker",
1534		zerowrap.FieldAction:  "RemoveVolume",
1535		"volume":              volumeName,
1536		"force":               force,
1537	})
1538	log := zerowrap.FromCtx(ctx)
1539
1540	_, err := r.client.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
1541	if err != nil {
1542		if cerrdefs.IsNotFound(err) {
1543			log.Debug().Msg("volume not found, already removed")
1544			return nil
1545		}
1546		return log.WrapErr(err, "failed to remove volume")
1547	}
1548
1549	log.Info().Msg("volume removed")
1550	return nil
1551}
1552
1553// ListVolumes returns all named volumes with their usage status.
1554func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
1555	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1556		zerowrap.FieldLayer:   "adapter",
1557		zerowrap.FieldAdapter: "docker",
1558		zerowrap.FieldAction:  "ListVolumes",
1559	})
1560	log := zerowrap.FromCtx(ctx)
1561
1562	volumeList, err := r.client.VolumeList(ctx, client.VolumeListOptions{})
1563	if err != nil {
1564		return nil, log.WrapErr(err, "failed to list volumes")
1565	}
1566
1567	// Get all containers to determine volume usage.
1568	containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: true})
1569	if err != nil {
1570		return nil, log.WrapErr(err, "failed to list containers for volume usage")
1571	}
1572
1573	// Build a map of volume name -> container names.
1574	volumeUsers := make(map[string][]string)
1575	for _, c := range containerList.Items {
1576		for _, m := range c.Mounts {
1577			if m.Type == "volume" {
1578				name := ""
1579				if len(c.Names) > 0 {
1580					name = strings.TrimPrefix(c.Names[0], "/")
1581				}
1582				volumeUsers[m.Name] = append(volumeUsers[m.Name], name)
1583			}
1584		}
1585	}
1586
1587	var result []*domain.VolumeInfo
1588	for _, v := range volumeList.Items {
1589		var size int64
1590		if v.UsageData != nil {
1591			size = v.UsageData.Size
1592		}
1593
1594		created, _ := time.Parse(time.RFC3339, v.CreatedAt)
1595
1596		users := volumeUsers[v.Name]
1597		info := &domain.VolumeInfo{
1598			Name:       v.Name,
1599			Driver:     v.Driver,
1600			MountPoint: v.Mountpoint,
1601			Size:       size,
1602			CreatedAt:  created,
1603			InUse:      len(users) > 0,
1604			Containers: users,
1605			Labels:     v.Labels,
1606		}
1607		result = append(result, info)
1608	}
1609
1610	return result, nil
1611}
1612
1613// ExportVolumeArchive exports a named volume as a compressed tar archive stream.
1614func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
1615	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1616		zerowrap.FieldLayer:   "adapter",
1617		zerowrap.FieldAdapter: "docker",
1618		zerowrap.FieldAction:  "ExportVolumeArchive",
1619		"volume":              req.VolumeName,
1620	})
1621	log := zerowrap.FromCtx(ctx)
1622
1623	if err := validateVolumeArchiveRequest(req); err != nil {
1624		return nil, err
1625	}
1626	if err := r.ensureVolumeArchiveHelperImage(ctx, req.HelperImage, log); err != nil {
1627		return nil, err
1628	}
1629	created, cleanup, err := r.createVolumeArchiveHelper(ctx, req, log)
1630	if err != nil {
1631		return nil, err
1632	}
1633	if _, err := r.client.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
1634		_ = cleanup()
1635		return nil, log.WrapErr(err, "failed to start volume archive helper container")
1636	}
1637
1638	stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log)
1639	if err != nil {
1640		_ = cleanup()
1641		return nil, err
1642	}
1643	return &domain.VolumeArchiveResult{Stream: stream}, nil
1644}
1645
1646func validateVolumeArchiveRequest(req domain.VolumeArchiveRequest) error {
1647	if strings.TrimSpace(req.VolumeName) == "" {
1648		return fmt.Errorf("volume name is required")
1649	}
1650	if strings.TrimSpace(req.HelperImage) == "" {
1651		return fmt.Errorf("helper image is required")
1652	}
1653	return nil
1654}
1655
1656func (r *Runtime) ensureVolumeArchiveHelperImage(ctx context.Context, imageRef string, log zerowrap.Logger) error {
1657	if _, err := r.client.ImageInspect(ctx, imageRef); err != nil {
1658		if !cerrdefs.IsNotFound(err) {
1659			return log.WrapErr(err, "failed to inspect volume archive helper image")
1660		}
1661		return r.PullImage(ctx, imageRef)
1662	}
1663	return nil
1664}
1665
1666func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (client.ContainerCreateResult, func() error, error) {
1667	cmd, err := volumeArchiveCommand(req.Compression)
1668	if err != nil {
1669		return client.ContainerCreateResult{}, nil, err
1670	}
1671	created, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
1672		Config:     volumeArchiveContainerConfig(req.HelperImage, cmd),
1673		HostConfig: volumeArchiveHostConfig(req.VolumeName),
1674		Name:       fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()),
1675	})
1676	if err != nil {
1677		return client.ContainerCreateResult{}, nil, log.WrapErr(err, "failed to create volume archive helper container")
1678	}
1679	var cleanupOnce sync.Once
1680	cleanup := func() error {
1681		var cleanupErr error
1682		cleanupOnce.Do(func() {
1683			removeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1684			defer cancel()
1685			cleanupErr = r.RemoveContainer(removeCtx, created.ID, true)
1686		})
1687		return cleanupErr
1688	}
1689	return created, cleanup, nil
1690}
1691
1692func volumeArchiveContainerConfig(imageRef, cmd string) *container.Config {
1693	return &container.Config{
1694		Image:           imageRef,
1695		Cmd:             []string{"sh", "-c", cmd},
1696		AttachStdout:    true,
1697		AttachStderr:    true,
1698		NetworkDisabled: true,
1699		Labels: map[string]string{
1700			domain.LabelManaged: "true",
1701			"gordon.purpose":    "volume-backup",
1702		},
1703	}
1704}
1705
1706func volumeArchiveHostConfig(volumeName string) *container.HostConfig {
1707	return &container.HostConfig{
1708		AutoRemove: false,
1709		Mounts: []mount.Mount{{
1710			Type:     mount.TypeVolume,
1711			Source:   volumeName,
1712			Target:   "/volume",
1713			ReadOnly: true,
1714		}},
1715	}
1716}
1717
1718func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {
1719	logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
1720	if err != nil {
1721		return nil, log.WrapErr(err, "failed to attach volume archive stream")
1722	}
1723	waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
1724	statusCh, errCh := waitResult.Result, waitResult.Error
1725	pr, pw := io.Pipe()
1726	go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
1727	return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil
1728}
1729
1730func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) {
1731	defer logs.Close()
1732	defer func() {
1733		if err := cleanup(); err != nil {
1734			log.Warn().Err(err).Msg("failed to clean up volume archive helper container")
1735		}
1736	}()
1737
1738	var stderr bytes.Buffer
1739	_, copyErr := stdcopy.StdCopy(pw, &stderr, logs)
1740	statusCode, waitErr := waitForVolumeArchiveContainer(statusCh, errCh)
1741	closeVolumeArchivePipe(pw, copyErr, waitErr, statusCode, stderr.String())
1742}
1743
1744func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) {
1745	for statusCh != nil || errCh != nil {
1746		select {
1747		case err, ok := <-errCh:
1748			if !ok {
1749				errCh = nil
1750				continue
1751			}
1752			if err != nil {
1753				return 0, err
1754			}
1755		case status, ok := <-statusCh:
1756			if !ok {
1757				return 0, fmt.Errorf("volume archive helper container wait channel closed")
1758			}
1759			return status.StatusCode, nil
1760		}
1761	}
1762	return 0, fmt.Errorf("volume archive helper container wait channels closed")
1763}
1764
1765func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) {
1766	switch {
1767	case copyErr != nil:
1768		_ = pw.CloseWithError(copyErr)
1769	case waitErr != nil:
1770		_ = pw.CloseWithError(waitErr)
1771	case statusCode != 0:
1772		_ = pw.CloseWithError(fmt.Errorf("volume archive helper exited with code %d: %s", statusCode, strings.TrimSpace(stderr)))
1773	default:
1774		_ = pw.Close()
1775	}
1776}
1777
1778func volumeArchiveCommand(compression domain.VolumeBackupCompression) (string, error) {
1779	switch compression {
1780	case domain.VolumeBackupCompressionGzip:
1781		return "cd /volume && tar -czf - .", nil
1782	case domain.VolumeBackupCompressionZstd:
1783		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
1784	default:
1785		return "", fmt.Errorf("unsupported volume backup compression: %s", compression)
1786	}
1787}
1788
1789// InspectImageEnv gets the environment variables declared in the image.
1790func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
1791	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1792		zerowrap.FieldLayer:   "adapter",
1793		zerowrap.FieldAdapter: "docker",
1794		zerowrap.FieldAction:  "InspectImageEnv",
1795		"image":               imageRef,
1796	})
1797	log := zerowrap.FromCtx(ctx)
1798
1799	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1800	if err != nil {
1801		return nil, log.WrapErr(err, "failed to inspect image")
1802	}
1803
1804	var envVars []string
1805	if imageInspect.Config != nil && imageInspect.Config.Env != nil {
1806		envVars = imageInspect.Config.Env
1807	}
1808
1809	if len(envVars) > 0 {
1810		log.Debug().
1811			Int(zerowrap.FieldCount, len(envVars)).
1812			Strs("env_keys", imageEnvKeys(envVars)).
1813			Msg("found ENV directives in image")
1814	} else {
1815		log.Debug().Msg("no ENV directives found in image")
1816	}
1817
1818	return envVars, nil
1819}
1820
1821// GetImageLabels returns the labels defined on an image.
1822func imageEnvKeys(envVars []string) []string {
1823	keys := make([]string, 0, len(envVars))
1824	for _, envVar := range envVars {
1825		key, _, ok := strings.Cut(envVar, "=")
1826		if !ok || key == "" {
1827			keys = append(keys, "[malformed]")
1828			continue
1829		}
1830		keys = append(keys, key)
1831	}
1832	sort.Strings(keys)
1833	return keys
1834}
1835
1836func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
1837	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1838		zerowrap.FieldLayer:   "adapter",
1839		zerowrap.FieldAdapter: "docker",
1840		zerowrap.FieldAction:  "GetImageLabels",
1841		"image":               imageRef,
1842	})
1843	log := zerowrap.FromCtx(ctx)
1844
1845	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1846	if err != nil {
1847		return nil, log.WrapErr(err, "failed to inspect image")
1848	}
1849
1850	labels := make(map[string]string)
1851	if imageInspect.Config != nil && imageInspect.Config.Labels != nil {
1852		labels = imageInspect.Config.Labels
1853	}
1854
1855	log.Debug().Int("label_count", len(labels)).Msg("retrieved image labels")
1856	return labels, nil
1857}
1858
1859// GetImageID returns the unique image ID (sha256 digest) for the given image reference.
1860func (r *Runtime) VerifyImageDigest(ctx context.Context, imageRef, digest string) error {
1861	inspect, err := r.client.ImageInspect(ctx, imageRef)
1862	if err != nil {
1863		return fmt.Errorf("inspect image digest: %w", err)
1864	}
1865	for _, repoDigest := range inspect.RepoDigests {
1866		if strings.HasSuffix(repoDigest, "@"+digest) {
1867			return nil
1868		}
1869	}
1870	return fmt.Errorf("expected digest %s is absent from local image metadata: %w", digest, domain.ErrImagePullFailed)
1871}
1872
1873func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) {
1874	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1875		zerowrap.FieldLayer:   "adapter",
1876		zerowrap.FieldAdapter: "docker",
1877		zerowrap.FieldAction:  "GetImageID",
1878		"image":               imageRef,
1879	})
1880	log := zerowrap.FromCtx(ctx)
1881
1882	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1883	if err != nil {
1884		return "", log.WrapErr(err, "failed to inspect image")
1885	}
1886
1887	return imageInspect.ID, nil
1888}
1889
1890// CreateNetwork creates a new Docker network.
1891func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
1892	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1893		zerowrap.FieldLayer:   "adapter",
1894		zerowrap.FieldAdapter: "docker",
1895		zerowrap.FieldAction:  "CreateNetwork",
1896		"network":             name,
1897	})
1898	log := zerowrap.FromCtx(ctx)
1899
1900	driver := config.Driver
1901	if driver == "" {
1902		driver = "bridge"
1903	}
1904	labels := make(map[string]string, len(config.Labels)+1)
1905	maps.Copy(labels, config.Labels)
1906	labels[domain.LabelManaged] = "true"
1907
1908	createOptions := client.NetworkCreateOptions{
1909		Driver:   driver,
1910		Internal: config.Internal,
1911		Labels:   labels,
1912	}
1913
1914	_, err := r.client.NetworkCreate(ctx, name, createOptions)
1915	if err != nil {
1916		return log.WrapErr(err, "failed to create network")
1917	}
1918
1919	log.Info().Str("driver", driver).Msg("network created")
1920	return nil
1921}
1922
1923// RemoveNetwork removes a Docker network.
1924func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error {
1925	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1926		zerowrap.FieldLayer:   "adapter",
1927		zerowrap.FieldAdapter: "docker",
1928		zerowrap.FieldAction:  "RemoveNetwork",
1929		"network":             name,
1930	})
1931	log := zerowrap.FromCtx(ctx)
1932
1933	_, err := r.client.NetworkRemove(ctx, name, client.NetworkRemoveOptions{})
1934	if err != nil {
1935		if cerrdefs.IsNotFound(err) {
1936			log.Debug().Msg("network not found, already removed")
1937			return nil
1938		}
1939		return log.WrapErr(err, "failed to remove network")
1940	}
1941
1942	log.Info().Msg("network removed")
1943	return nil
1944}
1945
1946// ListNetworks lists all Docker networks.
1947func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
1948	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1949		zerowrap.FieldLayer:   "adapter",
1950		zerowrap.FieldAdapter: "docker",
1951		zerowrap.FieldAction:  "ListNetworks",
1952	})
1953	log := zerowrap.FromCtx(ctx)
1954
1955	networkList, err := r.client.NetworkList(ctx, client.NetworkListOptions{})
1956	if err != nil {
1957		return nil, log.WrapErr(err, "failed to list networks")
1958	}
1959
1960	var result []*domain.NetworkInfo
1961	for _, net := range networkList.Items {
1962		networkInspect, err := r.client.NetworkInspect(ctx, net.ID, client.NetworkInspectOptions{})
1963		if err != nil {
1964			return nil, log.WrapErr(err, "failed to inspect network")
1965		}
1966		var containers []string
1967		for containerID := range networkInspect.Network.Containers {
1968			containers = append(containers, containerID)
1969		}
1970
1971		result = append(result, &domain.NetworkInfo{
1972			ID:         net.ID,
1973			Name:       net.Name,
1974			Driver:     net.Driver,
1975			Containers: containers,
1976			Labels:     net.Labels,
1977		})
1978	}
1979
1980	return result, nil
1981}
1982
1983// NetworkExists checks if a Docker network exists.
1984func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error) {
1985	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1986		zerowrap.FieldLayer:   "adapter",
1987		zerowrap.FieldAdapter: "docker",
1988		zerowrap.FieldAction:  "NetworkExists",
1989		"network":             name,
1990	})
1991	log := zerowrap.FromCtx(ctx)
1992
1993	_, err := r.client.NetworkInspect(ctx, name, client.NetworkInspectOptions{})
1994	if err != nil {
1995		if cerrdefs.IsNotFound(err) {
1996			return false, nil
1997		}
1998		return false, log.WrapErr(err, "failed to inspect network")
1999	}
2000	return true, nil
2001}
2002
2003// ConnectContainerToNetwork connects a container to a network.
2004func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
2005	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2006		zerowrap.FieldLayer:   "adapter",
2007		zerowrap.FieldAdapter: "docker",
2008		zerowrap.FieldAction:  "ConnectContainerToNetwork",
2009		"container":           containerName,
2010		"network":             networkName,
2011	})
2012	log := zerowrap.FromCtx(ctx)
2013
2014	_, err := r.client.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
2015		Container:      containerName,
2016		EndpointConfig: &network.EndpointSettings{},
2017	})
2018	if err != nil {
2019		return log.WrapErr(err, "failed to connect container to network")
2020	}
2021
2022	log.Info().Msg("container connected to network")
2023	return nil
2024}
2025
2026// DisconnectContainerFromNetwork disconnects a container from a network.
2027func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
2028	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2029		zerowrap.FieldLayer:   "adapter",
2030		zerowrap.FieldAdapter: "docker",
2031		zerowrap.FieldAction:  "DisconnectContainerFromNetwork",
2032		"container":           containerName,
2033		"network":             networkName,
2034	})
2035	log := zerowrap.FromCtx(ctx)
2036
2037	_, err := r.client.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{
2038		Container: containerName,
2039	})
2040	if err != nil {
2041		return log.WrapErr(err, "failed to disconnect container from network")
2042	}
2043
2044	log.Info().Msg("container disconnected from network")
2045	return nil
2046}
2047
2048// ExecInContainer executes a command in a running container.
2049func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
2050	if len(cmd) == 0 {
2051		return nil, fmt.Errorf("command cannot be empty")
2052	}
2053
2054	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2055		zerowrap.FieldLayer:    "adapter",
2056		zerowrap.FieldAdapter:  "docker",
2057		zerowrap.FieldAction:   "ExecInContainer",
2058		zerowrap.FieldEntityID: containerID,
2059		"command":              cmd[0],
2060		"arg_count":            len(cmd) - 1,
2061	})
2062	log := zerowrap.FromCtx(ctx)
2063
2064	execResp, err := r.client.ExecCreate(ctx, containerID, client.ExecCreateOptions{
2065		Cmd:          cmd,
2066		AttachStdout: true,
2067		AttachStderr: true,
2068	})
2069	if err != nil {
2070		return nil, log.WrapErr(err, "failed to create exec")
2071	}
2072
2073	attachCtx := ctx
2074	var cancel context.CancelFunc
2075	if _, hasDeadline := ctx.Deadline(); !hasDeadline {
2076		attachCtx, cancel = context.WithTimeout(ctx, 5*time.Minute)
2077		defer cancel()
2078	}
2079
2080	attachResp, err := r.client.ExecAttach(attachCtx, execResp.ID, client.ExecAttachOptions{})
2081	if err != nil {
2082		return nil, log.WrapErr(err, "failed to attach to exec")
2083	}
2084	defer attachResp.Close()
2085	if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil {
2086		_ = attachResp.Conn.SetReadDeadline(deadline)
2087	}
2088
2089	stdout, stderr, err := parseExecOutput(attachResp.Reader)
2090	if err != nil {
2091		return nil, log.WrapErr(err, "failed to read exec output")
2092	}
2093
2094	inspectResp, err := r.client.ExecInspect(ctx, execResp.ID, client.ExecInspectOptions{})
2095	if err != nil {
2096		return nil, log.WrapErr(err, "failed to inspect exec result")
2097	}
2098
2099	return &out.ExecResult{
2100		ExitCode: inspectResp.ExitCode,
2101		Stdout:   stdout,
2102		Stderr:   stderr,
2103	}, nil
2104}
2105
2106const maxExecOutputSize = 8 << 20 // 8 MiB per stream
2107
2108type boundedBuffer struct {
2109	bytes.Buffer
2110	limit int
2111}
2112
2113func (w *boundedBuffer) Write(p []byte) (int, error) {
2114	remaining := w.limit - w.Len()
2115	if remaining <= 0 {
2116		return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
2117	}
2118	if len(p) > remaining {
2119		_, _ = w.Buffer.Write(p[:remaining])
2120		return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
2121	}
2122	return w.Buffer.Write(p)
2123}
2124
2125func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
2126	stdout := boundedBuffer{limit: maxExecOutputSize}
2127	stderr := boundedBuffer{limit: maxExecOutputSize}
2128	buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr}
2129	var header [8]byte
2130
2131	for {
2132		if _, err := io.ReadFull(reader, header[:]); err != nil {
2133			if errors.Is(err, io.EOF) {
2134				break
2135			}
2136			return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err)
2137		}
2138		output, ok := buffers[header[0]]
2139		if !ok {
2140			return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0])
2141		}
2142		payloadSize := int64(binary.BigEndian.Uint32(header[4:]))
2143		remaining := int64(output.limit - output.Len())
2144		if payloadSize > remaining {
2145			return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining)
2146		}
2147		if _, err := io.CopyN(output, reader, payloadSize); err != nil {
2148			return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err)
2149		}
2150	}
2151
2152	return stdout.Bytes(), stderr.Bytes(), nil
2153}
2154
2155// CopyFromContainer copies a file from a container and returns a content stream.
2156func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
2157	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2158		zerowrap.FieldLayer:    "adapter",
2159		zerowrap.FieldAdapter:  "docker",
2160		zerowrap.FieldAction:   "CopyFromContainer",
2161		zerowrap.FieldEntityID: containerID,
2162		"src_path":             srcPath,
2163	})
2164	log := zerowrap.FromCtx(ctx)
2165
2166	copyResult, err := r.client.CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{SourcePath: srcPath})
2167	if err != nil {
2168		return nil, log.WrapErr(err, "failed to copy from container")
2169	}
2170
2171	// The response is a tar archive - we need to extract the requested file stream.
2172	reader, err := extractFileFromTar(copyResult.Content, srcPath)
2173	if err != nil {
2174		_ = copyResult.Content.Close()
2175		return nil, log.WrapErr(err, "failed to extract file from tar")
2176	}
2177
2178	return reader, nil
2179}
2180
2181// extractFileFromTar extracts a single file from a tar archive.
2182func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
2183	tr := tar.NewReader(reader)
2184	targetName := normalizedTarPath(targetPath)
2185	targetBase := filepath.Base(targetName)
2186	var fallback *removeOnCloseFile
2187	fallbackMatches := 0
2188
2189	for {
2190		header, err := tr.Next()
2191		if err == io.EOF {
2192			break
2193		}
2194		if err != nil {
2195			cleanupFallback(fallback)
2196			_ = reader.Close()
2197			return nil, err
2198		}
2199		if header.Typeflag != tar.TypeReg {
2200			continue
2201		}
2202
2203		headerName := normalizedTarPath(header.Name)
2204		if headerName == targetName {
2205			cleanupFallback(fallback)
2206			return streamCurrentTarFile(reader, tr, header.Size), nil
2207		}
2208		if headerName == targetBase {
2209			fallbackMatches++
2210			if fallbackMatches == 1 {
2211				fallback, err = bufferCurrentTarFile(tr, header.Size)
2212				if err != nil {
2213					_ = reader.Close()
2214					return nil, err
2215				}
2216			}
2217		}
2218	}
2219
2220	_ = reader.Close()
2221	if fallbackMatches == 1 {
2222		if _, err := fallback.Seek(0, io.SeekStart); err != nil {
2223			cleanupFallback(fallback)
2224			return nil, err
2225		}
2226		return fallback, nil
2227	}
2228	cleanupFallback(fallback)
2229	if fallbackMatches > 1 {
2230		return nil, fmt.Errorf("ambiguous basename matches in container archive for: %s", targetPath)
2231	}
2232	return nil, fmt.Errorf("file not found in container: %s", targetPath)
2233}
2234
2235func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser {
2236	pr, pw := io.Pipe()
2237	go func() {
2238		defer reader.Close()
2239		_, copyErr := io.CopyN(pw, tr, size)
2240		if copyErr != nil {
2241			_ = pw.CloseWithError(copyErr)
2242			return
2243		}
2244		_ = pw.Close()
2245	}()
2246	return &pipeReadCloser{pr: pr, pw: pw, original: reader}
2247}
2248
2249func bufferCurrentTarFile(tr *tar.Reader, size int64) (*removeOnCloseFile, error) {
2250	file, err := os.CreateTemp("", "gordon-copy-from-container-*")
2251	if err != nil {
2252		return nil, err
2253	}
2254	fallback := &removeOnCloseFile{File: file, path: file.Name()}
2255	if _, err := io.CopyN(fallback, tr, size); err != nil {
2256		cleanupFallback(fallback)
2257		return nil, err
2258	}
2259	return fallback, nil
2260}
2261
2262func cleanupFallback(fallback *removeOnCloseFile) {
2263	if fallback != nil {
2264		_ = fallback.Close()
2265	}
2266}
2267
2268func normalizedTarPath(path string) string {
2269	path = filepath.ToSlash(filepath.Clean(path))
2270	return strings.TrimPrefix(path, "/")
2271}
2272
2273const maxExtractedEnvFileSize = 1 << 20 // 1 MiB
2274
2275func readExtractedEnvFile(reader io.Reader) ([]byte, error) {
2276	limited := io.LimitReader(reader, maxExtractedEnvFileSize+1)
2277	data, err := io.ReadAll(limited)
2278	if err != nil {
2279		return nil, err
2280	}
2281	if len(data) > maxExtractedEnvFileSize {
2282		return nil, fmt.Errorf("extracted env file exceeds %d bytes", maxExtractedEnvFileSize)
2283	}
2284	return data, nil
2285}
2286
2287// ExtractEnvFileFromImage extracts an env file from an image.
2288// This creates a temporary container, copies the file, and removes the container.
2289func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
2290	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2291		zerowrap.FieldLayer:   "adapter",
2292		zerowrap.FieldAdapter: "docker",
2293		zerowrap.FieldAction:  "ExtractEnvFileFromImage",
2294		"image":               imageRef,
2295		"env_file":            envFilePath,
2296	})
2297	log := zerowrap.FromCtx(ctx)
2298
2299	log.Info().Msg("extracting env file from image")
2300
2301	// Create a temporary container without starting it
2302	containerConfig := &container.Config{
2303		Image: imageRef,
2304		Cmd:   []string{"true"}, // Dummy command
2305	}
2306
2307	resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{Config: containerConfig})
2308	if err != nil {
2309		return nil, log.WrapErr(err, "failed to create temporary container")
2310	}
2311
2312	tempContainerID := resp.ID
2313	log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container")
2314
2315	// Ensure cleanup
2316	defer func() {
2317		if _, err := r.client.ContainerRemove(ctx, tempContainerID, client.ContainerRemoveOptions{Force: true}); err != nil {
2318			log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
2319		}
2320	}()
2321
2322	// Copy the env file from the container
2323	reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
2324	if err != nil {
2325		return nil, err
2326	}
2327	defer reader.Close()
2328
2329	data, err := readExtractedEnvFile(reader)
2330	if err != nil {
2331		return nil, log.WrapErr(err, "failed to read extracted env file")
2332	}
2333
2334	log.Info().Int("size", len(data)).Msg("env file extracted from image")
2335	return data, nil
2336}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close