v2.31.0-dev13 0d280f4

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

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