v2.30.0-pre5 7568a1c

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

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