v2.31.0-dev19 35467d7

runtime.go RawHistoryFolder

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

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