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

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