v2.10.1 43db1bb

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

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