v2.31.0-dev4 f8fb41e

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

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