v2.31.0-dev23 58a92f3

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

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