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}