v2.15.1 c808592

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

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