v2.30.0-pre4 15d9fe3

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

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