v2.30.0-pre 7341e0e

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

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