v2.15.0 cf76057

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

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