v2.4.2 7da87b2

runtime.go RawHistoryFolder

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

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