v2.31.1 f37fe67

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

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