v2.14.0 5e11a00

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	"path/filepath"
14	"sort"
15	"strconv"
16	"strings"
17	"sync"
18	"time"
19
20	"github.com/bnema/zerowrap"
21	cerrdefs "github.com/containerd/errdefs"
22	"github.com/docker/docker/api/types/container"
23	"github.com/docker/docker/api/types/image"
24	"github.com/docker/docker/api/types/network"
25	"github.com/docker/docker/api/types/registry"
26	"github.com/docker/docker/api/types/volume"
27	"github.com/docker/docker/client"
28	"github.com/docker/docker/pkg/stdcopy"
29	"github.com/docker/go-connections/nat"
30
31	"github.com/bnema/gordon/internal/boundaries/out"
32	"github.com/bnema/gordon/internal/domain"
33)
34
35// Runtime implements the ContainerRuntime interface using Docker API.
36type Runtime struct {
37	client *client.Client
38}
39
40type pipeReadCloser struct {
41	pr       *io.PipeReader
42	pw       *io.PipeWriter
43	original io.Closer
44	once     sync.Once
45}
46
47func (p *pipeReadCloser) Read(b []byte) (int, error) {
48	return p.pr.Read(b)
49}
50
51func (p *pipeReadCloser) Close() error {
52	var closeErr error
53	p.once.Do(func() {
54		if err := p.pw.CloseWithError(errors.New("reader closed")); err != nil && !errors.Is(err, io.ErrClosedPipe) {
55			closeErr = err
56		}
57		if err := p.pr.Close(); err != nil && closeErr == nil {
58			closeErr = err
59		}
60		if p.original != nil {
61			if err := p.original.Close(); err != nil && closeErr == nil {
62				closeErr = err
63			}
64		}
65	})
66	return closeErr
67}
68
69// NewRuntime creates a new Docker runtime instance.
70func NewRuntime() (*Runtime, error) {
71	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
72	if err != nil {
73		return nil, fmt.Errorf("failed to create Docker client: %w", err)
74	}
75
76	return &Runtime{
77		client: cli,
78	}, nil
79}
80
81// NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).
82func NewRuntimeWithClient(cli *client.Client) *Runtime {
83	return &Runtime{
84		client: cli,
85	}
86}
87
88// CreateContainer creates a new container.
89func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) {
90	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
91		zerowrap.FieldLayer:   "adapter",
92		zerowrap.FieldAdapter: "docker",
93		zerowrap.FieldAction:  "CreateContainer",
94		"container_name":      config.Name,
95		"image":               config.Image,
96	})
97	log := zerowrap.FromCtx(ctx)
98
99	// Convert ports to Docker format
100	exposedPorts := make(nat.PortSet)
101	portBindings := make(nat.PortMap)
102
103	for _, port := range config.Ports {
104		containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
105		exposedPorts[containerPort] = struct{}{}
106
107		// Bind to random available port on localhost only.
108		// SECURITY: Using 127.0.0.1 prevents direct access from the network,
109		// forcing all traffic through Gordon's reverse proxy where auth,
110		// rate limiting, and security headers are applied.
111		portBindings[containerPort] = []nat.PortBinding{
112			{
113				HostIP:   "127.0.0.1",
114				HostPort: "0", // Docker will assign a random available port
115			},
116		}
117	}
118
119	// Convert volumes to Docker format
120	var binds []string
121	if config.Volumes != nil {
122		for containerPath, volumeName := range config.Volumes {
123			bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
124			binds = append(binds, bind)
125			log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
126		}
127	}
128
129	// Create container configuration
130	containerConfig := &container.Config{
131		Image:        config.Image,
132		Hostname:     config.Hostname,
133		Env:          config.Env,
134		ExposedPorts: exposedPorts,
135		WorkingDir:   config.WorkingDir,
136		Cmd:          config.Cmd,
137		Labels:       config.Labels,
138	}
139
140	hostConfig := &container.HostConfig{
141		PortBindings: portBindings,
142		AutoRemove:   config.AutoRemove,
143		Binds:        binds,
144		NetworkMode:  container.NetworkMode(config.NetworkMode),
145	}
146
147	// Create network configuration for container
148	var networkConfig *network.NetworkingConfig
149	if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
150		networkConfig = &network.NetworkingConfig{
151			EndpointsConfig: map[string]*network.EndpointSettings{
152				config.NetworkMode: {
153					Aliases: config.Aliases,
154				},
155			},
156		}
157		if config.Hostname != "" {
158			networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
159		}
160	}
161
162	// Create the container
163	resp, err := r.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)
164	if err != nil {
165		return nil, log.WrapErr(err, "failed to create container")
166	}
167
168	log.Info().Str(zerowrap.FieldEntityID, resp.ID).Msg("container created")
169
170	// Inspect the created container to get full details
171	return r.InspectContainer(ctx, resp.ID)
172}
173
174// StartContainer starts a container.
175func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {
176	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
177		zerowrap.FieldLayer:    "adapter",
178		zerowrap.FieldAdapter:  "docker",
179		zerowrap.FieldAction:   "StartContainer",
180		zerowrap.FieldEntityID: containerID,
181	})
182	log := zerowrap.FromCtx(ctx)
183
184	err := r.client.ContainerStart(ctx, containerID, container.StartOptions{})
185	if err != nil {
186		return log.WrapErr(err, "failed to start container")
187	}
188
189	log.Info().Msg("container started")
190	return nil
191}
192
193// StopContainer stops a container.
194func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
195	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
196		zerowrap.FieldLayer:    "adapter",
197		zerowrap.FieldAdapter:  "docker",
198		zerowrap.FieldAction:   "StopContainer",
199		zerowrap.FieldEntityID: containerID,
200	})
201	log := zerowrap.FromCtx(ctx)
202
203	timeout := 30 // 30 seconds timeout
204	err := r.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
205	if err != nil {
206		return log.WrapErr(err, "failed to stop container")
207	}
208
209	log.Info().Msg("container stopped")
210	return nil
211}
212
213// RestartContainer restarts a container.
214func (r *Runtime) RestartContainer(ctx context.Context, containerID string) error {
215	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
216		zerowrap.FieldLayer:    "adapter",
217		zerowrap.FieldAdapter:  "docker",
218		zerowrap.FieldAction:   "RestartContainer",
219		zerowrap.FieldEntityID: containerID,
220	})
221	log := zerowrap.FromCtx(ctx)
222
223	timeout := 30 // 30 seconds timeout
224	err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
225	if err != nil {
226		return log.WrapErr(err, "failed to restart container")
227	}
228
229	log.Info().Msg("container restarted")
230	return nil
231}
232
233// RemoveContainer removes a container.
234func (r *Runtime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
235	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
236		zerowrap.FieldLayer:    "adapter",
237		zerowrap.FieldAdapter:  "docker",
238		zerowrap.FieldAction:   "RemoveContainer",
239		zerowrap.FieldEntityID: containerID,
240		"force":                force,
241	})
242	log := zerowrap.FromCtx(ctx)
243
244	err := r.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
245	if err != nil {
246		return log.WrapErr(err, "failed to remove container")
247	}
248
249	log.Info().Msg("container removed")
250	return nil
251}
252
253// RenameContainer renames a container.
254func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName string) error {
255	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
256		zerowrap.FieldLayer:    "adapter",
257		zerowrap.FieldAdapter:  "docker",
258		zerowrap.FieldAction:   "RenameContainer",
259		zerowrap.FieldEntityID: containerID,
260		"new_name":             newName,
261	})
262	log := zerowrap.FromCtx(ctx)
263
264	err := r.client.ContainerRename(ctx, containerID, newName)
265	if err != nil {
266		return log.WrapErr(err, "failed to rename container")
267	}
268
269	log.Info().Msg("container renamed")
270	return nil
271}
272
273// ListContainers lists containers.
274func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {
275	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
276		zerowrap.FieldLayer:   "adapter",
277		zerowrap.FieldAdapter: "docker",
278		zerowrap.FieldAction:  "ListContainers",
279		"all":                 all,
280	})
281	log := zerowrap.FromCtx(ctx)
282
283	containers, err := r.client.ContainerList(ctx, container.ListOptions{All: all})
284	if err != nil {
285		return nil, log.WrapErr(err, "failed to list containers")
286	}
287
288	var result []*domain.Container
289	for _, c := range containers {
290		// Extract ports
291		var ports []int
292		for _, port := range c.Ports {
293			if port.PublicPort > 0 {
294				ports = append(ports, int(port.PublicPort))
295			}
296		}
297
298		// Get the primary name (remove leading slash)
299		name := ""
300		if len(c.Names) > 0 {
301			name = strings.TrimPrefix(c.Names[0], "/")
302		}
303
304		result = append(result, &domain.Container{
305			ID:     c.ID,
306			Image:  c.Image,
307			Name:   name,
308			Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
309			Ports:  ports,
310			Labels: c.Labels,
311		})
312	}
313
314	return result, nil
315}
316
317// InspectContainer inspects a container.
318func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*domain.Container, error) {
319	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
320		zerowrap.FieldLayer:    "adapter",
321		zerowrap.FieldAdapter:  "docker",
322		zerowrap.FieldAction:   "InspectContainer",
323		zerowrap.FieldEntityID: containerID,
324	})
325	log := zerowrap.FromCtx(ctx)
326
327	resp, err := r.client.ContainerInspect(ctx, containerID)
328	if err != nil {
329		return nil, log.WrapErr(err, "failed to inspect container")
330	}
331
332	// Extract published ports
333	var ports []int
334	if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
335		for _, bindings := range resp.NetworkSettings.Ports {
336			for _, binding := range bindings {
337				if binding.HostPort != "" {
338					if port, err := strconv.Atoi(binding.HostPort); err == nil {
339						ports = append(ports, port)
340					}
341				}
342			}
343		}
344	}
345
346	// Get container name (remove leading slash)
347	name := strings.TrimPrefix(resp.Name, "/")
348
349	return &domain.Container{
350		ID:     resp.ID,
351		Image:  resp.Config.Image,
352		Name:   name,
353		Status: resp.State.Status,
354		Ports:  ports,
355		Labels: resp.Config.Labels,
356	}, nil
357}
358
359// GetContainerLogs gets container logs.
360func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
361	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
362		zerowrap.FieldLayer:    "adapter",
363		zerowrap.FieldAdapter:  "docker",
364		zerowrap.FieldAction:   "GetContainerLogs",
365		zerowrap.FieldEntityID: containerID,
366	})
367	log := zerowrap.FromCtx(ctx)
368
369	logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{
370		ShowStdout: true,
371		ShowStderr: true,
372		Follow:     follow,
373		Timestamps: true,
374	})
375	if err != nil {
376		return nil, log.WrapErr(err, "failed to get container logs")
377	}
378
379	return logs, nil
380}
381
382// PullImage pulls an image.
383func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
384	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
385		zerowrap.FieldLayer:   "adapter",
386		zerowrap.FieldAdapter: "docker",
387		zerowrap.FieldAction:  "PullImage",
388		"image":               imageRef,
389	})
390	log := zerowrap.FromCtx(ctx)
391
392	log.Info().Msg("pulling image")
393
394	reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{})
395	if err != nil {
396		return log.WrapErr(err, "failed to pull image")
397	}
398	defer reader.Close()
399
400	// Read the response to completion (this is required for the pull to complete)
401	_, err = io.Copy(io.Discard, reader)
402	if err != nil {
403		return log.WrapErr(err, "failed to read pull response")
404	}
405
406	log.Info().Msg("image pulled successfully")
407	return nil
408}
409
410// PullImageWithAuth pulls an image with authentication.
411func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
412	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
413		zerowrap.FieldLayer:   "adapter",
414		zerowrap.FieldAdapter: "docker",
415		zerowrap.FieldAction:  "PullImageWithAuth",
416		"image":               imageRef,
417		"username":            username,
418	})
419	log := zerowrap.FromCtx(ctx)
420
421	log.Info().Msg("pulling image with authentication")
422
423	// Extract registry server address from image reference
424	// e.g., "registry.bnema.dev/image:tag" -> "registry.bnema.dev"
425	serverAddress := imageRef
426	if idx := strings.Index(imageRef, "/"); idx > 0 {
427		serverAddress = imageRef[:idx]
428	}
429
430	// Create authentication configuration
431	authConfig := registry.AuthConfig{
432		Username:      username,
433		Password:      password,
434		ServerAddress: serverAddress,
435	}
436
437	// Encode authentication to base64 JSON
438	// Note: Docker API accepts both StdEncoding and URLEncoding, but Podman
439	// may be more strict. Using StdEncoding for maximum compatibility.
440	authConfigBytes, err := json.Marshal(authConfig)
441	if err != nil {
442		return log.WrapErr(err, "failed to marshal auth config")
443	}
444	authStr := base64.StdEncoding.EncodeToString(authConfigBytes)
445
446	log.Debug().
447		Str("server_address", serverAddress).
448		Bool("has_auth", username != "" || password != "").
449		Msg("auth config for pull")
450
451	// Pull with authentication
452	reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{
453		RegistryAuth: authStr,
454	})
455	if err != nil {
456		return log.WrapErr(err, "failed to pull image with auth")
457	}
458	defer reader.Close()
459
460	// Read the response to completion (this is required for the pull to complete)
461	_, err = io.Copy(io.Discard, reader)
462	if err != nil {
463		return log.WrapErr(err, "failed to read pull response")
464	}
465
466	log.Info().Msg("image pulled successfully with authentication")
467	return nil
468}
469
470// TagImage tags a locally available image with a new reference.
471func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) error {
472	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
473		zerowrap.FieldLayer:   "adapter",
474		zerowrap.FieldAdapter: "docker",
475		zerowrap.FieldAction:  "TagImage",
476		"source":              sourceRef,
477		"target":              targetRef,
478	})
479	log := zerowrap.FromCtx(ctx)
480
481	if err := r.client.ImageTag(ctx, sourceRef, targetRef); err != nil {
482		return log.WrapErr(err, "failed to tag image")
483	}
484
485	log.Info().Msg("image tagged successfully")
486	return nil
487}
488
489// UntagImage removes a tag from an image without deleting the underlying image layers.
490func (r *Runtime) UntagImage(ctx context.Context, imageRef string) error {
491	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
492		zerowrap.FieldLayer:   "adapter",
493		zerowrap.FieldAdapter: "docker",
494		zerowrap.FieldAction:  "UntagImage",
495		"image":               imageRef,
496	})
497	log := zerowrap.FromCtx(ctx)
498
499	// ImageRemove with PruneChildren=false only removes the tag, not the image layers
500	_, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{
501		Force:         false,
502		PruneChildren: false,
503	})
504	if err != nil {
505		return log.WrapErr(err, "failed to untag image")
506	}
507
508	log.Debug().Msg("image untagged successfully")
509	return nil
510}
511
512// RemoveImage removes an image.
513func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
514	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
515		zerowrap.FieldLayer:   "adapter",
516		zerowrap.FieldAdapter: "docker",
517		zerowrap.FieldAction:  "RemoveImage",
518		"image":               imageRef,
519		"force":               force,
520	})
521	log := zerowrap.FromCtx(ctx)
522
523	_, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
524	if err != nil {
525		return log.WrapErr(err, "failed to remove image")
526	}
527
528	log.Info().Msg("image removed")
529	return nil
530}
531
532// ListImages lists images.
533func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
534	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
535		zerowrap.FieldLayer:   "adapter",
536		zerowrap.FieldAdapter: "docker",
537		zerowrap.FieldAction:  "ListImages",
538	})
539	log := zerowrap.FromCtx(ctx)
540
541	images, err := r.client.ImageList(ctx, image.ListOptions{})
542	if err != nil {
543		return nil, log.WrapErr(err, "failed to list images")
544	}
545
546	var result []string
547	for _, img := range images {
548		for _, tag := range img.RepoTags {
549			if tag != "<none>:<none>" {
550				result = append(result, tag)
551			}
552		}
553	}
554
555	return result, nil
556}
557
558// Ping checks if Docker is responsive.
559func (r *Runtime) Ping(ctx context.Context) error {
560	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
561		zerowrap.FieldLayer:   "adapter",
562		zerowrap.FieldAdapter: "docker",
563		zerowrap.FieldAction:  "Ping",
564	})
565	log := zerowrap.FromCtx(ctx)
566
567	_, err := r.client.Ping(ctx)
568	if err != nil {
569		return log.WrapErr(err, "Docker ping failed")
570	}
571	return nil
572}
573
574// Version returns Docker version.
575func (r *Runtime) Version(ctx context.Context) (string, error) {
576	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
577		zerowrap.FieldLayer:   "adapter",
578		zerowrap.FieldAdapter: "docker",
579		zerowrap.FieldAction:  "Version",
580	})
581	log := zerowrap.FromCtx(ctx)
582
583	version, err := r.client.ServerVersion(ctx)
584	if err != nil {
585		return "", log.WrapErr(err, "failed to get Docker version")
586	}
587	return version.Version, nil
588}
589
590// IsContainerRunning checks if a container is running.
591func (r *Runtime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
592	ctr, err := r.InspectContainer(ctx, containerID)
593	if err != nil {
594		return false, err
595	}
596	return ctr.Status == string(domain.ContainerStatusRunning), nil
597}
598
599// GetContainerHealthStatus returns Docker healthcheck status when available.
600func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) {
601	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
602		zerowrap.FieldLayer:    "adapter",
603		zerowrap.FieldAdapter:  "docker",
604		zerowrap.FieldAction:   "GetContainerHealthStatus",
605		zerowrap.FieldEntityID: containerID,
606	})
607	log := zerowrap.FromCtx(ctx)
608
609	resp, err := r.client.ContainerInspect(ctx, containerID)
610	if err != nil {
611		return "", false, log.WrapErr(err, "failed to inspect container")
612	}
613	if resp.State == nil || resp.State.Health == nil {
614		return "", false, nil
615	}
616
617	return resp.State.Health.Status, true, nil
618}
619
620// GetContainerPort gets the host port for a container's internal port.
621func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
622	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
623		zerowrap.FieldLayer:    "adapter",
624		zerowrap.FieldAdapter:  "docker",
625		zerowrap.FieldAction:   "GetContainerPort",
626		zerowrap.FieldEntityID: containerID,
627		"internal_port":        internalPort,
628	})
629	log := zerowrap.FromCtx(ctx)
630
631	resp, err := r.client.ContainerInspect(ctx, containerID)
632	if err != nil {
633		return 0, log.WrapErr(err, "failed to inspect container")
634	}
635
636	if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
637		return 0, fmt.Errorf("no port mappings found for container %s", containerID)
638	}
639
640	containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
641	bindings, exists := resp.NetworkSettings.Ports[containerPort]
642	if !exists || len(bindings) == 0 {
643		return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
644	}
645
646	hostPort, err := strconv.Atoi(bindings[0].HostPort)
647	if err != nil {
648		return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
649	}
650
651	return hostPort, nil
652}
653
654// GetImageExposedPorts gets the exposed ports from an image.
655func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
656	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
657		zerowrap.FieldLayer:   "adapter",
658		zerowrap.FieldAdapter: "docker",
659		zerowrap.FieldAction:  "GetImageExposedPorts",
660		"image":               imageRef,
661	})
662	log := zerowrap.FromCtx(ctx)
663
664	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
665	if err != nil {
666		return nil, log.WrapErr(err, "failed to inspect image")
667	}
668
669	var ports []int
670	if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
671		for portSpec := range imageInspect.Config.ExposedPorts {
672			// Parse port from format like "80/tcp"
673			portStr := strings.Split(portSpec, "/")[0]
674			if port, err := strconv.Atoi(portStr); err == nil {
675				ports = append(ports, port)
676			}
677		}
678	}
679
680	if len(ports) == 0 {
681		return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
682	}
683
684	// Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.)
685	// Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending
686	sortPortsHTTPFirst(ports)
687
688	log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")
689	return ports, nil
690}
691
692// sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22).
693func sortPortsHTTPFirst(ports []int) {
694	priority := map[int]int{
695		80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6,
696	}
697	sort.Slice(ports, func(i, j int) bool {
698		pi, oki := priority[ports[i]]
699		pj, okj := priority[ports[j]]
700		if oki && okj {
701			return pi < pj
702		}
703		if oki {
704			return true
705		}
706		if okj {
707			return false
708		}
709		// Neither is a priority port - sort ascending but push 22 to the end
710		if ports[i] == 22 {
711			return false
712		}
713		if ports[j] == 22 {
714			return true
715		}
716		return ports[i] < ports[j]
717	})
718}
719
720// GetContainerExposedPorts gets all exposed ports from a running container.
721func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
722	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
723		zerowrap.FieldLayer:    "adapter",
724		zerowrap.FieldAdapter:  "docker",
725		zerowrap.FieldAction:   "GetContainerExposedPorts",
726		zerowrap.FieldEntityID: containerID,
727	})
728	log := zerowrap.FromCtx(ctx)
729
730	resp, err := r.client.ContainerInspect(ctx, containerID)
731	if err != nil {
732		return nil, log.WrapErr(err, "failed to inspect container")
733	}
734
735	var ports []int
736	if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
737		for portSpec := range resp.NetworkSettings.Ports {
738			// Parse port from format like "80/tcp"
739			portStr := strings.Split(string(portSpec), "/")[0]
740			if port, err := strconv.Atoi(portStr); err == nil {
741				ports = append(ports, port)
742			}
743		}
744	}
745
746	return ports, nil
747}
748
749// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use.
750func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
751	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
752		zerowrap.FieldLayer:    "adapter",
753		zerowrap.FieldAdapter:  "docker",
754		zerowrap.FieldAction:   "GetContainerNetworkInfo",
755		zerowrap.FieldEntityID: containerID,
756	})
757	log := zerowrap.FromCtx(ctx)
758
759	resp, err := r.client.ContainerInspect(ctx, containerID)
760	if err != nil {
761		return "", 0, log.WrapErr(err, "failed to inspect container")
762	}
763
764	// Get container's internal IP address
765	var containerIP string
766	if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
767		// Use the first available network (usually bridge or custom network)
768		for _, net := range resp.NetworkSettings.Networks {
769			if net.IPAddress != "" {
770				containerIP = net.IPAddress
771				break
772			}
773		}
774	}
775
776	if containerIP == "" {
777		return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
778	}
779
780	// Get exposed ports from container
781	var exposedPorts []int
782	if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
783		for portSpec := range resp.NetworkSettings.Ports {
784			// Parse port from format like "80/tcp"
785			portStr := strings.Split(string(portSpec), "/")[0]
786			if port, err := strconv.Atoi(portStr); err == nil {
787				exposedPorts = append(exposedPorts, port)
788			}
789		}
790	}
791
792	if len(exposedPorts) == 0 {
793		return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
794	}
795
796	// Use the first exposed port
797	selectedPort := exposedPorts[0]
798
799	log.Info().
800		Str("ip", containerIP).
801		Int("selected_port", selectedPort).
802		Ints("exposed_ports", exposedPorts).
803		Msg("container network info detected")
804
805	return containerIP, selectedPort, nil
806}
807
808// GetContainerNetwork returns the network name a container is connected to.
809func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) {
810	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
811		zerowrap.FieldLayer:    "adapter",
812		zerowrap.FieldAdapter:  "docker",
813		zerowrap.FieldAction:   "GetContainerNetwork",
814		zerowrap.FieldEntityID: containerID,
815	})
816	log := zerowrap.FromCtx(ctx)
817
818	resp, err := r.client.ContainerInspect(ctx, containerID)
819	if err != nil {
820		return "", log.WrapErr(err, "failed to inspect container")
821	}
822
823	networks := resp.NetworkSettings
824	if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {
825		return "bridge", nil
826	}
827
828	// Priority: gordon-* networks first, then bridge, then any other
829	selected := ""
830	hasBridge := false
831	for name := range networks.Networks {
832		if strings.HasPrefix(name, "gordon-") {
833			selected = name
834			break
835		}
836		if name == "bridge" {
837			hasBridge = true
838		} else if selected == "" {
839			selected = name
840		}
841	}
842
843	// If no gordon-* network found, prefer bridge over random network
844	if selected == "" || (hasBridge && !strings.HasPrefix(selected, "gordon-")) {
845		if hasBridge {
846			selected = "bridge"
847		}
848	}
849
850	if selected == "" {
851		return "bridge", nil
852	}
853
854	log.Debug().Str("network", selected).Msg("container network detected")
855	return selected, nil
856}
857
858// InspectImageVolumes gets the volume mount points declared in the image.
859func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
860	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
861		zerowrap.FieldLayer:   "adapter",
862		zerowrap.FieldAdapter: "docker",
863		zerowrap.FieldAction:  "InspectImageVolumes",
864		"image":               imageRef,
865	})
866	log := zerowrap.FromCtx(ctx)
867
868	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
869	if err != nil {
870		return nil, log.WrapErr(err, "failed to inspect image")
871	}
872
873	var volumes []string
874	if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
875		for volumePath := range imageInspect.Config.Volumes {
876			volumes = append(volumes, volumePath)
877		}
878	}
879
880	if len(volumes) > 0 {
881		log.Info().Strs("volumes", volumes).Msg("found VOLUME directives in image")
882	} else {
883		log.Debug().Msg("no VOLUME directives found in image")
884	}
885
886	return volumes, nil
887}
888
889// VolumeExists checks if a Docker volume exists.
890func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
891	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
892		zerowrap.FieldLayer:   "adapter",
893		zerowrap.FieldAdapter: "docker",
894		zerowrap.FieldAction:  "VolumeExists",
895		"volume":              volumeName,
896	})
897	log := zerowrap.FromCtx(ctx)
898
899	_, err := r.client.VolumeInspect(ctx, volumeName)
900	if err != nil {
901		if cerrdefs.IsNotFound(err) {
902			return false, nil
903		}
904		return false, log.WrapErr(err, "failed to inspect volume")
905	}
906	return true, nil
907}
908
909// CreateVolume creates a new Docker volume.
910func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error {
911	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
912		zerowrap.FieldLayer:   "adapter",
913		zerowrap.FieldAdapter: "docker",
914		zerowrap.FieldAction:  "CreateVolume",
915		"volume":              volumeName,
916	})
917	log := zerowrap.FromCtx(ctx)
918
919	_, err := r.client.VolumeCreate(ctx, volume.CreateOptions{
920		Name: volumeName,
921		Labels: map[string]string{
922			"gordon.managed": "true",
923			"gordon.created": "auto",
924		},
925	})
926	if err != nil {
927		return log.WrapErr(err, "failed to create volume")
928	}
929
930	log.Info().Msg("volume created")
931	return nil
932}
933
934// RemoveVolume removes a Docker volume.
935func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
936	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
937		zerowrap.FieldLayer:   "adapter",
938		zerowrap.FieldAdapter: "docker",
939		zerowrap.FieldAction:  "RemoveVolume",
940		"volume":              volumeName,
941		"force":               force,
942	})
943	log := zerowrap.FromCtx(ctx)
944
945	err := r.client.VolumeRemove(ctx, volumeName, force)
946	if err != nil {
947		if cerrdefs.IsNotFound(err) {
948			log.Debug().Msg("volume not found, already removed")
949			return nil
950		}
951		return log.WrapErr(err, "failed to remove volume")
952	}
953
954	log.Info().Msg("volume removed")
955	return nil
956}
957
958// InspectImageEnv gets the environment variables declared in the image.
959func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
960	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
961		zerowrap.FieldLayer:   "adapter",
962		zerowrap.FieldAdapter: "docker",
963		zerowrap.FieldAction:  "InspectImageEnv",
964		"image":               imageRef,
965	})
966	log := zerowrap.FromCtx(ctx)
967
968	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
969	if err != nil {
970		return nil, log.WrapErr(err, "failed to inspect image")
971	}
972
973	var envVars []string
974	if imageInspect.Config != nil && imageInspect.Config.Env != nil {
975		envVars = imageInspect.Config.Env
976	}
977
978	if len(envVars) > 0 {
979		log.Debug().Strs("env_vars", envVars).Msg("found ENV directives in image")
980	} else {
981		log.Debug().Msg("no ENV directives found in image")
982	}
983
984	return envVars, nil
985}
986
987// GetImageLabels returns the labels defined on an image.
988func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
989	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
990		zerowrap.FieldLayer:   "adapter",
991		zerowrap.FieldAdapter: "docker",
992		zerowrap.FieldAction:  "GetImageLabels",
993		"image":               imageRef,
994	})
995	log := zerowrap.FromCtx(ctx)
996
997	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
998	if err != nil {
999		return nil, log.WrapErr(err, "failed to inspect image")
1000	}
1001
1002	labels := make(map[string]string)
1003	if imageInspect.Config != nil && imageInspect.Config.Labels != nil {
1004		labels = imageInspect.Config.Labels
1005	}
1006
1007	log.Debug().Int("label_count", len(labels)).Msg("retrieved image labels")
1008	return labels, nil
1009}
1010
1011// CreateNetwork creates a new Docker network.
1012func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
1013	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1014		zerowrap.FieldLayer:   "adapter",
1015		zerowrap.FieldAdapter: "docker",
1016		zerowrap.FieldAction:  "CreateNetwork",
1017		"network":             name,
1018	})
1019	log := zerowrap.FromCtx(ctx)
1020
1021	// Set default driver to bridge if not specified
1022	driver := "bridge"
1023	if driverOption, exists := options["driver"]; exists {
1024		driver = driverOption
1025	}
1026
1027	createOptions := network.CreateOptions{
1028		Driver: driver,
1029		Labels: map[string]string{
1030			"gordon.managed": "true",
1031		},
1032	}
1033
1034	// Add any additional options to labels
1035	for key, value := range options {
1036		if key != "driver" {
1037			createOptions.Labels["gordon."+key] = value
1038		}
1039	}
1040
1041	_, err := r.client.NetworkCreate(ctx, name, createOptions)
1042	if err != nil {
1043		return log.WrapErr(err, "failed to create network")
1044	}
1045
1046	log.Info().Str("driver", driver).Msg("network created")
1047	return nil
1048}
1049
1050// RemoveNetwork removes a Docker network.
1051func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error {
1052	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1053		zerowrap.FieldLayer:   "adapter",
1054		zerowrap.FieldAdapter: "docker",
1055		zerowrap.FieldAction:  "RemoveNetwork",
1056		"network":             name,
1057	})
1058	log := zerowrap.FromCtx(ctx)
1059
1060	err := r.client.NetworkRemove(ctx, name)
1061	if err != nil {
1062		if cerrdefs.IsNotFound(err) {
1063			log.Debug().Msg("network not found, already removed")
1064			return nil
1065		}
1066		return log.WrapErr(err, "failed to remove network")
1067	}
1068
1069	log.Info().Msg("network removed")
1070	return nil
1071}
1072
1073// ListNetworks lists all Docker networks.
1074func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
1075	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1076		zerowrap.FieldLayer:   "adapter",
1077		zerowrap.FieldAdapter: "docker",
1078		zerowrap.FieldAction:  "ListNetworks",
1079	})
1080	log := zerowrap.FromCtx(ctx)
1081
1082	networks, err := r.client.NetworkList(ctx, network.ListOptions{})
1083	if err != nil {
1084		return nil, log.WrapErr(err, "failed to list networks")
1085	}
1086
1087	var result []*domain.NetworkInfo
1088	for _, net := range networks {
1089		var containers []string
1090		for containerID := range net.Containers {
1091			containers = append(containers, containerID)
1092		}
1093
1094		result = append(result, &domain.NetworkInfo{
1095			ID:         net.ID,
1096			Name:       net.Name,
1097			Driver:     net.Driver,
1098			Containers: containers,
1099			Labels:     net.Labels,
1100		})
1101	}
1102
1103	return result, nil
1104}
1105
1106// NetworkExists checks if a Docker network exists.
1107func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error) {
1108	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1109		zerowrap.FieldLayer:   "adapter",
1110		zerowrap.FieldAdapter: "docker",
1111		zerowrap.FieldAction:  "NetworkExists",
1112		"network":             name,
1113	})
1114	log := zerowrap.FromCtx(ctx)
1115
1116	_, err := r.client.NetworkInspect(ctx, name, network.InspectOptions{})
1117	if err != nil {
1118		if cerrdefs.IsNotFound(err) {
1119			return false, nil
1120		}
1121		return false, log.WrapErr(err, "failed to inspect network")
1122	}
1123	return true, nil
1124}
1125
1126// ConnectContainerToNetwork connects a container to a network.
1127func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
1128	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1129		zerowrap.FieldLayer:   "adapter",
1130		zerowrap.FieldAdapter: "docker",
1131		zerowrap.FieldAction:  "ConnectContainerToNetwork",
1132		"container":           containerName,
1133		"network":             networkName,
1134	})
1135	log := zerowrap.FromCtx(ctx)
1136
1137	err := r.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
1138	if err != nil {
1139		return log.WrapErr(err, "failed to connect container to network")
1140	}
1141
1142	log.Info().Msg("container connected to network")
1143	return nil
1144}
1145
1146// DisconnectContainerFromNetwork disconnects a container from a network.
1147func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
1148	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1149		zerowrap.FieldLayer:   "adapter",
1150		zerowrap.FieldAdapter: "docker",
1151		zerowrap.FieldAction:  "DisconnectContainerFromNetwork",
1152		"container":           containerName,
1153		"network":             networkName,
1154	})
1155	log := zerowrap.FromCtx(ctx)
1156
1157	err := r.client.NetworkDisconnect(ctx, networkName, containerName, false)
1158	if err != nil {
1159		return log.WrapErr(err, "failed to disconnect container from network")
1160	}
1161
1162	log.Info().Msg("container disconnected from network")
1163	return nil
1164}
1165
1166// ExecInContainer executes a command in a running container.
1167func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
1168	if len(cmd) == 0 {
1169		return nil, fmt.Errorf("command cannot be empty")
1170	}
1171
1172	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1173		zerowrap.FieldLayer:    "adapter",
1174		zerowrap.FieldAdapter:  "docker",
1175		zerowrap.FieldAction:   "ExecInContainer",
1176		zerowrap.FieldEntityID: containerID,
1177		"command":              cmd[0],
1178		"arg_count":            len(cmd) - 1,
1179	})
1180	log := zerowrap.FromCtx(ctx)
1181
1182	execResp, err := r.client.ContainerExecCreate(ctx, containerID, container.ExecOptions{
1183		Cmd:          cmd,
1184		AttachStdout: true,
1185		AttachStderr: true,
1186	})
1187	if err != nil {
1188		return nil, log.WrapErr(err, "failed to create exec")
1189	}
1190
1191	attachCtx := ctx
1192	var cancel context.CancelFunc
1193	if _, hasDeadline := ctx.Deadline(); !hasDeadline {
1194		attachCtx, cancel = context.WithTimeout(ctx, 5*time.Minute)
1195		defer cancel()
1196	}
1197
1198	attachResp, err := r.client.ContainerExecAttach(attachCtx, execResp.ID, container.ExecAttachOptions{})
1199	if err != nil {
1200		return nil, log.WrapErr(err, "failed to attach to exec")
1201	}
1202	defer attachResp.Close()
1203	if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil {
1204		_ = attachResp.Conn.SetReadDeadline(deadline)
1205	}
1206
1207	stdout, stderr, err := parseExecOutput(attachResp.Reader)
1208	if err != nil {
1209		return nil, log.WrapErr(err, "failed to read exec output")
1210	}
1211
1212	inspectResp, err := r.client.ContainerExecInspect(ctx, execResp.ID)
1213	if err != nil {
1214		return nil, log.WrapErr(err, "failed to inspect exec result")
1215	}
1216
1217	return &out.ExecResult{
1218		ExitCode: inspectResp.ExitCode,
1219		Stdout:   stdout,
1220		Stderr:   stderr,
1221	}, nil
1222}
1223
1224func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
1225	var stdout bytes.Buffer
1226	var stderr bytes.Buffer
1227
1228	if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil {
1229		return nil, nil, err
1230	}
1231
1232	return stdout.Bytes(), stderr.Bytes(), nil
1233}
1234
1235// CopyFromContainer copies a file from a container and returns a content stream.
1236func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
1237	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1238		zerowrap.FieldLayer:    "adapter",
1239		zerowrap.FieldAdapter:  "docker",
1240		zerowrap.FieldAction:   "CopyFromContainer",
1241		zerowrap.FieldEntityID: containerID,
1242		"src_path":             srcPath,
1243	})
1244	log := zerowrap.FromCtx(ctx)
1245
1246	tarReader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1247	if err != nil {
1248		return nil, log.WrapErr(err, "failed to copy from container")
1249	}
1250
1251	// The response is a tar archive - we need to extract the requested file stream.
1252	reader, err := extractFileFromTar(tarReader, srcPath)
1253	if err != nil {
1254		_ = tarReader.Close()
1255		return nil, log.WrapErr(err, "failed to extract file from tar")
1256	}
1257
1258	return reader, nil
1259}
1260
1261// extractFileFromTar extracts a single file from a tar archive.
1262func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
1263	tr := tar.NewReader(reader)
1264
1265	// The path in the tar may be relative or absolute
1266	// We need to match based on the filename
1267	targetName := filepath.Base(targetPath)
1268
1269	for {
1270		header, err := tr.Next()
1271		if err == io.EOF {
1272			break
1273		}
1274		if err != nil {
1275			return nil, err
1276		}
1277
1278		// Check if this is our target file
1279		if header.Typeflag == tar.TypeReg {
1280			headerName := filepath.Base(header.Name)
1281			if headerName == targetName {
1282				size := header.Size
1283				pr, pw := io.Pipe()
1284				go func() {
1285					defer reader.Close()
1286					_, copyErr := io.CopyN(pw, tr, size)
1287					if copyErr != nil {
1288						_ = pw.CloseWithError(copyErr)
1289						return
1290					}
1291					_ = pw.Close()
1292				}()
1293				return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1294			}
1295		}
1296	}
1297
1298	_ = reader.Close()
1299	return nil, fmt.Errorf("file not found in container: %s", targetPath)
1300}
1301
1302// ExtractEnvFileFromImage extracts an env file from an image.
1303// This creates a temporary container, copies the file, and removes the container.
1304func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
1305	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1306		zerowrap.FieldLayer:   "adapter",
1307		zerowrap.FieldAdapter: "docker",
1308		zerowrap.FieldAction:  "ExtractEnvFileFromImage",
1309		"image":               imageRef,
1310		"env_file":            envFilePath,
1311	})
1312	log := zerowrap.FromCtx(ctx)
1313
1314	log.Info().Msg("extracting env file from image")
1315
1316	// Create a temporary container without starting it
1317	containerConfig := &container.Config{
1318		Image: imageRef,
1319		Cmd:   []string{"true"}, // Dummy command
1320	}
1321
1322	resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "")
1323	if err != nil {
1324		return nil, log.WrapErr(err, "failed to create temporary container")
1325	}
1326
1327	tempContainerID := resp.ID
1328	log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container")
1329
1330	// Ensure cleanup
1331	defer func() {
1332		if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil {
1333			log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
1334		}
1335	}()
1336
1337	// Copy the env file from the container
1338	reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1339	if err != nil {
1340		return nil, err
1341	}
1342	defer reader.Close()
1343
1344	data, err := io.ReadAll(reader)
1345	if err != nil {
1346		return nil, log.WrapErr(err, "failed to read extracted env file")
1347	}
1348
1349	log.Info().Int("size", len(data)).Msg("env file extracted from image")
1350	return data, nil
1351}

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