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