v2.5.0 b9b6391

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// GetContainerNetwork returns the network name a container is connected to.
750func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) {
751	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
752		zerowrap.FieldLayer:    "adapter",
753		zerowrap.FieldAdapter:  "docker",
754		zerowrap.FieldAction:   "GetContainerNetwork",
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 "", log.WrapErr(err, "failed to inspect container")
762	}
763
764	networks := resp.NetworkSettings
765	if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {
766		return "bridge", nil
767	}
768
769	selected := ""
770	for name := range networks.Networks {
771		if strings.HasPrefix(name, "gordon-") {
772			selected = name
773			break
774		}
775		if selected == "" {
776			selected = name
777		}
778	}
779
780	if selected == "" {
781		return "bridge", nil
782	}
783
784	log.Debug().Str("network", selected).Msg("container network detected")
785	return selected, nil
786}
787
788// InspectImageVolumes gets the volume mount points declared in the image.
789func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
790	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
791		zerowrap.FieldLayer:   "adapter",
792		zerowrap.FieldAdapter: "docker",
793		zerowrap.FieldAction:  "InspectImageVolumes",
794		"image":               imageRef,
795	})
796	log := zerowrap.FromCtx(ctx)
797
798	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
799	if err != nil {
800		return nil, log.WrapErr(err, "failed to inspect image")
801	}
802
803	var volumes []string
804	if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
805		for volumePath := range imageInspect.Config.Volumes {
806			volumes = append(volumes, volumePath)
807		}
808	}
809
810	if len(volumes) > 0 {
811		log.Info().Strs("volumes", volumes).Msg("found VOLUME directives in image")
812	} else {
813		log.Debug().Msg("no VOLUME directives found in image")
814	}
815
816	return volumes, nil
817}
818
819// VolumeExists checks if a Docker volume exists.
820func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
821	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
822		zerowrap.FieldLayer:   "adapter",
823		zerowrap.FieldAdapter: "docker",
824		zerowrap.FieldAction:  "VolumeExists",
825		"volume":              volumeName,
826	})
827	log := zerowrap.FromCtx(ctx)
828
829	_, err := r.client.VolumeInspect(ctx, volumeName)
830	if err != nil {
831		if cerrdefs.IsNotFound(err) {
832			return false, nil
833		}
834		return false, log.WrapErr(err, "failed to inspect volume")
835	}
836	return true, nil
837}
838
839// CreateVolume creates a new Docker volume.
840func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error {
841	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
842		zerowrap.FieldLayer:   "adapter",
843		zerowrap.FieldAdapter: "docker",
844		zerowrap.FieldAction:  "CreateVolume",
845		"volume":              volumeName,
846	})
847	log := zerowrap.FromCtx(ctx)
848
849	_, err := r.client.VolumeCreate(ctx, volume.CreateOptions{
850		Name: volumeName,
851		Labels: map[string]string{
852			"gordon.managed": "true",
853			"gordon.created": "auto",
854		},
855	})
856	if err != nil {
857		return log.WrapErr(err, "failed to create volume")
858	}
859
860	log.Info().Msg("volume created")
861	return nil
862}
863
864// RemoveVolume removes a Docker volume.
865func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
866	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
867		zerowrap.FieldLayer:   "adapter",
868		zerowrap.FieldAdapter: "docker",
869		zerowrap.FieldAction:  "RemoveVolume",
870		"volume":              volumeName,
871		"force":               force,
872	})
873	log := zerowrap.FromCtx(ctx)
874
875	err := r.client.VolumeRemove(ctx, volumeName, force)
876	if err != nil {
877		if cerrdefs.IsNotFound(err) {
878			log.Debug().Msg("volume not found, already removed")
879			return nil
880		}
881		return log.WrapErr(err, "failed to remove volume")
882	}
883
884	log.Info().Msg("volume removed")
885	return nil
886}
887
888// InspectImageEnv gets the environment variables declared in the image.
889func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
890	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
891		zerowrap.FieldLayer:   "adapter",
892		zerowrap.FieldAdapter: "docker",
893		zerowrap.FieldAction:  "InspectImageEnv",
894		"image":               imageRef,
895	})
896	log := zerowrap.FromCtx(ctx)
897
898	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
899	if err != nil {
900		return nil, log.WrapErr(err, "failed to inspect image")
901	}
902
903	var envVars []string
904	if imageInspect.Config != nil && imageInspect.Config.Env != nil {
905		envVars = imageInspect.Config.Env
906	}
907
908	if len(envVars) > 0 {
909		log.Debug().Strs("env_vars", envVars).Msg("found ENV directives in image")
910	} else {
911		log.Debug().Msg("no ENV directives found in image")
912	}
913
914	return envVars, nil
915}
916
917// GetImageLabels returns the labels defined on an image.
918func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
919	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
920		zerowrap.FieldLayer:   "adapter",
921		zerowrap.FieldAdapter: "docker",
922		zerowrap.FieldAction:  "GetImageLabels",
923		"image":               imageRef,
924	})
925	log := zerowrap.FromCtx(ctx)
926
927	imageInspect, err := r.client.ImageInspect(ctx, imageRef)
928	if err != nil {
929		return nil, log.WrapErr(err, "failed to inspect image")
930	}
931
932	labels := make(map[string]string)
933	if imageInspect.Config != nil && imageInspect.Config.Labels != nil {
934		labels = imageInspect.Config.Labels
935	}
936
937	log.Debug().Int("label_count", len(labels)).Msg("retrieved image labels")
938	return labels, nil
939}
940
941// CreateNetwork creates a new Docker network.
942func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
943	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
944		zerowrap.FieldLayer:   "adapter",
945		zerowrap.FieldAdapter: "docker",
946		zerowrap.FieldAction:  "CreateNetwork",
947		"network":             name,
948	})
949	log := zerowrap.FromCtx(ctx)
950
951	// Set default driver to bridge if not specified
952	driver := "bridge"
953	if driverOption, exists := options["driver"]; exists {
954		driver = driverOption
955	}
956
957	createOptions := network.CreateOptions{
958		Driver: driver,
959		Labels: map[string]string{
960			"gordon.managed": "true",
961		},
962	}
963
964	// Add any additional options to labels
965	for key, value := range options {
966		if key != "driver" {
967			createOptions.Labels["gordon."+key] = value
968		}
969	}
970
971	_, err := r.client.NetworkCreate(ctx, name, createOptions)
972	if err != nil {
973		return log.WrapErr(err, "failed to create network")
974	}
975
976	log.Info().Str("driver", driver).Msg("network created")
977	return nil
978}
979
980// RemoveNetwork removes a Docker network.
981func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error {
982	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
983		zerowrap.FieldLayer:   "adapter",
984		zerowrap.FieldAdapter: "docker",
985		zerowrap.FieldAction:  "RemoveNetwork",
986		"network":             name,
987	})
988	log := zerowrap.FromCtx(ctx)
989
990	err := r.client.NetworkRemove(ctx, name)
991	if err != nil {
992		if cerrdefs.IsNotFound(err) {
993			log.Debug().Msg("network not found, already removed")
994			return nil
995		}
996		return log.WrapErr(err, "failed to remove network")
997	}
998
999	log.Info().Msg("network removed")
1000	return nil
1001}
1002
1003// ListNetworks lists all Docker networks.
1004func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
1005	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1006		zerowrap.FieldLayer:   "adapter",
1007		zerowrap.FieldAdapter: "docker",
1008		zerowrap.FieldAction:  "ListNetworks",
1009	})
1010	log := zerowrap.FromCtx(ctx)
1011
1012	networks, err := r.client.NetworkList(ctx, network.ListOptions{})
1013	if err != nil {
1014		return nil, log.WrapErr(err, "failed to list networks")
1015	}
1016
1017	var result []*domain.NetworkInfo
1018	for _, net := range networks {
1019		var containers []string
1020		for containerID := range net.Containers {
1021			containers = append(containers, containerID)
1022		}
1023
1024		result = append(result, &domain.NetworkInfo{
1025			ID:         net.ID,
1026			Name:       net.Name,
1027			Driver:     net.Driver,
1028			Containers: containers,
1029			Labels:     net.Labels,
1030		})
1031	}
1032
1033	return result, nil
1034}
1035
1036// NetworkExists checks if a Docker network exists.
1037func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error) {
1038	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1039		zerowrap.FieldLayer:   "adapter",
1040		zerowrap.FieldAdapter: "docker",
1041		zerowrap.FieldAction:  "NetworkExists",
1042		"network":             name,
1043	})
1044	log := zerowrap.FromCtx(ctx)
1045
1046	_, err := r.client.NetworkInspect(ctx, name, network.InspectOptions{})
1047	if err != nil {
1048		if cerrdefs.IsNotFound(err) {
1049			return false, nil
1050		}
1051		return false, log.WrapErr(err, "failed to inspect network")
1052	}
1053	return true, nil
1054}
1055
1056// ConnectContainerToNetwork connects a container to a network.
1057func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
1058	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1059		zerowrap.FieldLayer:   "adapter",
1060		zerowrap.FieldAdapter: "docker",
1061		zerowrap.FieldAction:  "ConnectContainerToNetwork",
1062		"container":           containerName,
1063		"network":             networkName,
1064	})
1065	log := zerowrap.FromCtx(ctx)
1066
1067	err := r.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
1068	if err != nil {
1069		return log.WrapErr(err, "failed to connect container to network")
1070	}
1071
1072	log.Info().Msg("container connected to network")
1073	return nil
1074}
1075
1076// DisconnectContainerFromNetwork disconnects a container from a network.
1077func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
1078	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1079		zerowrap.FieldLayer:   "adapter",
1080		zerowrap.FieldAdapter: "docker",
1081		zerowrap.FieldAction:  "DisconnectContainerFromNetwork",
1082		"container":           containerName,
1083		"network":             networkName,
1084	})
1085	log := zerowrap.FromCtx(ctx)
1086
1087	err := r.client.NetworkDisconnect(ctx, networkName, containerName, false)
1088	if err != nil {
1089		return log.WrapErr(err, "failed to disconnect container from network")
1090	}
1091
1092	log.Info().Msg("container disconnected from network")
1093	return nil
1094}
1095
1096// CopyFromContainer copies a file from a container to the host.
1097// Returns the file contents as a byte slice.
1098func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) {
1099	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1100		zerowrap.FieldLayer:    "adapter",
1101		zerowrap.FieldAdapter:  "docker",
1102		zerowrap.FieldAction:   "CopyFromContainer",
1103		zerowrap.FieldEntityID: containerID,
1104		"src_path":             srcPath,
1105	})
1106	log := zerowrap.FromCtx(ctx)
1107
1108	reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1109	if err != nil {
1110		return nil, log.WrapErr(err, "failed to copy from container")
1111	}
1112	defer reader.Close()
1113
1114	// The response is a tar archive - we need to extract the file
1115	data, err := extractFileFromTar(reader, srcPath)
1116	if err != nil {
1117		return nil, log.WrapErr(err, "failed to extract file from tar")
1118	}
1119
1120	log.Debug().Int("size", len(data)).Msg("file copied from container")
1121	return data, nil
1122}
1123
1124// extractFileFromTar extracts a single file from a tar archive.
1125func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
1126	tr := tar.NewReader(reader)
1127
1128	// The path in the tar may be relative or absolute
1129	// We need to match based on the filename
1130	targetName := filepath.Base(targetPath)
1131
1132	for {
1133		header, err := tr.Next()
1134		if err == io.EOF {
1135			break
1136		}
1137		if err != nil {
1138			return nil, err
1139		}
1140
1141		// Check if this is our target file
1142		if header.Typeflag == tar.TypeReg {
1143			headerName := filepath.Base(header.Name)
1144			if headerName == targetName {
1145				data, err := io.ReadAll(tr)
1146				if err != nil {
1147					return nil, err
1148				}
1149				return data, nil
1150			}
1151		}
1152	}
1153
1154	return nil, fmt.Errorf("file not found in container: %s", targetPath)
1155}
1156
1157// ExtractEnvFileFromImage extracts an env file from an image.
1158// This creates a temporary container, copies the file, and removes the container.
1159func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
1160	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1161		zerowrap.FieldLayer:   "adapter",
1162		zerowrap.FieldAdapter: "docker",
1163		zerowrap.FieldAction:  "ExtractEnvFileFromImage",
1164		"image":               imageRef,
1165		"env_file":            envFilePath,
1166	})
1167	log := zerowrap.FromCtx(ctx)
1168
1169	log.Info().Msg("extracting env file from image")
1170
1171	// Create a temporary container without starting it
1172	containerConfig := &container.Config{
1173		Image: imageRef,
1174		Cmd:   []string{"true"}, // Dummy command
1175	}
1176
1177	resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "")
1178	if err != nil {
1179		return nil, log.WrapErr(err, "failed to create temporary container")
1180	}
1181
1182	tempContainerID := resp.ID
1183	log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container")
1184
1185	// Ensure cleanup
1186	defer func() {
1187		if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil {
1188			log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
1189		}
1190	}()
1191
1192	// Copy the env file from the container
1193	data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1194	if err != nil {
1195		return nil, err
1196	}
1197
1198	log.Info().Int("size", len(data)).Msg("env file extracted from image")
1199	return data, nil
1200}

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