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/binary"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "maps"
15 "math"
16 "net/netip"
17 "os"
18 "path/filepath"
19 "sort"
20 "strconv"
21 "strings"
22 "sync"
23 "time"
24
25 "github.com/bnema/zerowrap"
26 cerrdefs "github.com/containerd/errdefs"
27 "github.com/docker/go-units"
28 "github.com/moby/moby/api/pkg/stdcopy"
29 "github.com/moby/moby/api/types/container"
30 "github.com/moby/moby/api/types/mount"
31 "github.com/moby/moby/api/types/network"
32 "github.com/moby/moby/api/types/registry"
33 "github.com/moby/moby/client"
34
35 "github.com/bnema/gordon/internal/boundaries/out"
36 "github.com/bnema/gordon/internal/domain"
37 runtimepkg "github.com/bnema/gordon/pkg/runtime"
38)
39
40// Runtime implements the ContainerRuntime interface using Docker API.
41type Runtime struct {
42 client *client.Client
43}
44
45var _ out.ContainerRuntime = (*Runtime)(nil)
46var _ out.VolumeArchiveExporter = (*Runtime)(nil)
47
48type pipeReadCloser struct {
49 pr *io.PipeReader
50 pw *io.PipeWriter
51 original io.Closer
52 cleanup func() error
53 once sync.Once
54}
55
56type removeOnCloseFile struct {
57 *os.File
58 path string
59}
60
61func (p *pipeReadCloser) Read(b []byte) (int, error) {
62 return p.pr.Read(b)
63}
64
65func (p *pipeReadCloser) Close() error {
66 var closeErr error
67 p.once.Do(func() {
68 if err := p.pw.CloseWithError(errors.New("reader closed")); err != nil && !errors.Is(err, io.ErrClosedPipe) {
69 closeErr = err
70 }
71 if err := p.pr.Close(); err != nil && closeErr == nil {
72 closeErr = err
73 }
74 if p.original != nil {
75 if err := p.original.Close(); err != nil && closeErr == nil {
76 closeErr = err
77 }
78 }
79 if p.cleanup != nil {
80 if err := p.cleanup(); err != nil && closeErr == nil {
81 closeErr = err
82 }
83 }
84 })
85 return closeErr
86}
87
88func (f *removeOnCloseFile) Close() error {
89 closeErr := f.File.Close()
90 removeErr := os.Remove(f.path)
91 if closeErr != nil {
92 return closeErr
93 }
94 return removeErr
95}
96
97// NewRuntime creates a new Docker runtime instance.
98func NewRuntime() (*Runtime, error) {
99 cli, err := client.New(client.FromEnv)
100 if err != nil {
101 return nil, fmt.Errorf("failed to create Docker client: %w", err)
102 }
103
104 return &Runtime{
105 client: cli,
106 }, nil
107}
108
109// NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path.
110func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {
111 host := "unix://" + socketPath
112 cli, err := client.New(client.WithHost(host))
113 if err != nil {
114 return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)
115 }
116
117 return &Runtime{client: cli}, nil
118}
119
120// NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).
121func NewRuntimeWithClient(cli *client.Client) *Runtime {
122 return &Runtime{
123 client: cli,
124 }
125}
126
127// CreateContainer creates a new container.
128func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) {
129 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
130 zerowrap.FieldLayer: "adapter",
131 zerowrap.FieldAdapter: "docker",
132 zerowrap.FieldAction: "CreateContainer",
133 "container_name": config.Name,
134 "image": config.Image,
135 })
136 log := zerowrap.FromCtx(ctx)
137
138 exposedPorts, portBindings, err := buildPortBindings(config)
139 if err != nil {
140 return nil, err
141 }
142 binds := buildVolumeBinds(config, log)
143
144 // Create container configuration
145 containerConfig := &container.Config{
146 Image: config.Image,
147 Hostname: config.Hostname,
148 Env: config.Env,
149 ExposedPorts: exposedPorts,
150 WorkingDir: config.WorkingDir,
151 Cmd: config.Cmd,
152 Labels: config.Labels,
153 User: config.User,
154 }
155
156 resources := container.Resources{
157 Memory: config.MemoryLimit,
158 NanoCPUs: config.NanoCPUs,
159 Ulimits: []*units.Ulimit{
160 {Name: "nofile", Soft: 65536, Hard: 65536},
161 },
162 }
163 if config.PidsLimit > 0 {
164 resources.PidsLimit = &config.PidsLimit
165 }
166 capDrop := []string{"ALL"}
167 capAdd := []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
168 if config.CapDrop != nil {
169 capDrop = config.CapDrop
170 }
171 if config.CapAdd != nil {
172 capAdd = config.CapAdd
173 }
174 hostConfig := &container.HostConfig{
175 PortBindings: portBindings,
176 AutoRemove: config.AutoRemove,
177 Binds: binds,
178 NetworkMode: container.NetworkMode(config.NetworkMode),
179 Resources: resources,
180 SecurityOpt: []string{"no-new-privileges:true"},
181 CapDrop: capDrop,
182 CapAdd: capAdd,
183 ReadonlyRootfs: config.ReadOnlyRootFS,
184 }
185 if config.RestartPolicy != "" {
186 hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)}
187 }
188
189 // Create network configuration for container
190 var networkConfig *network.NetworkingConfig
191 if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
192 networkConfig = &network.NetworkingConfig{
193 EndpointsConfig: map[string]*network.EndpointSettings{
194 config.NetworkMode: {
195 Aliases: config.Aliases,
196 },
197 },
198 }
199 if config.Hostname != "" {
200 networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
201 }
202 }
203
204 // Create the container.
205 resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
206 Config: containerConfig,
207 HostConfig: hostConfig,
208 NetworkingConfig: networkConfig,
209 Name: config.Name,
210 })
211 if err != nil {
212 return nil, log.WrapErr(err, "failed to create container")
213 }
214
215 log.Info().Str(zerowrap.FieldEntityID, resp.ID).Msg("container created")
216
217 // Inspect the created container to get full details
218 return r.InspectContainer(ctx, resp.ID)
219}
220
221func buildPortBindings(config *domain.ContainerConfig) (network.PortSet, network.PortMap, error) {
222 exposedPorts := make(network.PortSet)
223 portBindings := make(network.PortMap)
224 if err := addLegacyPortBindings(config.Ports, exposedPorts, portBindings); err != nil {
225 return nil, nil, err
226 }
227 if err := addExplicitPortBindings(config.PortPublishes, exposedPorts, portBindings); err != nil {
228 return nil, nil, err
229 }
230 return exposedPorts, portBindings, nil
231}
232
233func addLegacyPortBindings(ports []int, exposedPorts network.PortSet, portBindings network.PortMap) error {
234 for _, port := range ports {
235 containerPort, err := network.ParsePort(fmt.Sprintf("%d/tcp", port))
236 if err != nil {
237 return fmt.Errorf("invalid container port %d: %w", port, err)
238 }
239 exposedPorts[containerPort] = struct{}{}
240
241 // Bind to random available port on localhost only.
242 // SECURITY: Using 127.0.0.1 prevents direct access from the network,
243 // forcing all traffic through Gordon's reverse proxy where auth,
244 // rate limiting, and security headers are applied.
245 portBindings[containerPort] = []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "0"}}
246 }
247 return nil
248}
249
250func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts network.PortSet, portBindings network.PortMap) error {
251 for _, publish := range publishes {
252 if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP {
253 return fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol)
254 }
255 containerPort, err := network.ParsePort(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))
256 if err != nil {
257 return fmt.Errorf("invalid container port %d: %w", publish.ContainerPort, err)
258 }
259 var hostIP netip.Addr
260 if publish.HostIP != "" {
261 hostIP, err = netip.ParseAddr(publish.HostIP)
262 if err != nil {
263 return fmt.Errorf("invalid host IP %q: %w", publish.HostIP, err)
264 }
265 }
266 exposedPorts[containerPort] = struct{}{}
267 portBindings[containerPort] = append(portBindings[containerPort], network.PortBinding{
268 HostIP: hostIP,
269 HostPort: strconv.Itoa(publish.HostPort),
270 })
271 }
272 return nil
273}
274
275func buildVolumeBinds(config *domain.ContainerConfig, log zerowrap.Logger) []string {
276 binds := make([]string, 0, len(config.Volumes)+len(config.ReadOnlyVolumes))
277 for containerPath, volumeName := range config.Volumes {
278 bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
279 binds = append(binds, bind)
280 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
281 }
282 for containerPath, volumeName := range config.ReadOnlyVolumes {
283 bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath)
284 binds = append(binds, bind)
285 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount")
286 }
287 return binds
288}
289
290// StartContainer starts a container.
291func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {
292 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
293 zerowrap.FieldLayer: "adapter",
294 zerowrap.FieldAdapter: "docker",
295 zerowrap.FieldAction: "StartContainer",
296 zerowrap.FieldEntityID: containerID,
297 })
298 log := zerowrap.FromCtx(ctx)
299
300 _, err := r.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{})
301 if err != nil {
302 return log.WrapErr(err, "failed to start container")
303 }
304
305 log.Info().Msg("container started")
306 return nil
307}
308
309// WaitForContainer waits for a container to stop running.
310func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error {
311 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
312 zerowrap.FieldLayer: "adapter",
313 zerowrap.FieldAdapter: "docker",
314 zerowrap.FieldAction: "WaitForContainer",
315 zerowrap.FieldEntityID: containerID,
316 })
317 log := zerowrap.FromCtx(ctx)
318
319 waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
320 statusCh, errCh := waitResult.Result, waitResult.Error
321 select {
322 case err := <-errCh:
323 if err != nil {
324 return log.WrapErr(err, "failed to wait for container")
325 }
326 case <-statusCh:
327 }
328
329 log.Debug().Msg("container wait completed")
330 return nil
331}
332
333// StopContainer stops a container.
334func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
335 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
336 zerowrap.FieldLayer: "adapter",
337 zerowrap.FieldAdapter: "docker",
338 zerowrap.FieldAction: "StopContainer",
339 zerowrap.FieldEntityID: containerID,
340 })
341 log := zerowrap.FromCtx(ctx)
342
343 timeout := 20 // 20 seconds before SIGKILL
344 _, err := r.client.ContainerStop(ctx, containerID, client.ContainerStopOptions{Timeout: &timeout})
345 if err != nil {
346 return log.WrapErr(err, "failed to stop container")
347 }
348
349 log.Info().Msg("container stopped")
350 return nil
351}
352
353// RestartContainer restarts a container.
354func (r *Runtime) RestartContainer(ctx context.Context, containerID string) error {
355 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
356 zerowrap.FieldLayer: "adapter",
357 zerowrap.FieldAdapter: "docker",
358 zerowrap.FieldAction: "RestartContainer",
359 zerowrap.FieldEntityID: containerID,
360 })
361 log := zerowrap.FromCtx(ctx)
362
363 timeout := 20 // 20 seconds before SIGKILL
364 _, err := r.client.ContainerRestart(ctx, containerID, client.ContainerRestartOptions{Timeout: &timeout})
365 if err != nil {
366 return log.WrapErr(err, "failed to restart container")
367 }
368
369 log.Info().Msg("container restarted")
370 return nil
371}
372
373// RemoveContainer removes a container.
374func (r *Runtime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
375 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
376 zerowrap.FieldLayer: "adapter",
377 zerowrap.FieldAdapter: "docker",
378 zerowrap.FieldAction: "RemoveContainer",
379 zerowrap.FieldEntityID: containerID,
380 "force": force,
381 })
382 log := zerowrap.FromCtx(ctx)
383
384 _, err := r.client.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{Force: force})
385 if err != nil {
386 return log.WrapErr(err, "failed to remove container")
387 }
388
389 log.Info().Msg("container removed")
390 return nil
391}
392
393// RenameContainer renames a container.
394func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName string) error {
395 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
396 zerowrap.FieldLayer: "adapter",
397 zerowrap.FieldAdapter: "docker",
398 zerowrap.FieldAction: "RenameContainer",
399 zerowrap.FieldEntityID: containerID,
400 "new_name": newName,
401 })
402 log := zerowrap.FromCtx(ctx)
403
404 _, err := r.client.ContainerRename(ctx, containerID, client.ContainerRenameOptions{NewName: newName})
405 if err != nil {
406 return log.WrapErr(err, "failed to rename container")
407 }
408
409 log.Info().Msg("container renamed")
410 return nil
411}
412
413// ListContainers lists containers.
414func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {
415 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
416 zerowrap.FieldLayer: "adapter",
417 zerowrap.FieldAdapter: "docker",
418 zerowrap.FieldAction: "ListContainers",
419 "all": all,
420 })
421 log := zerowrap.FromCtx(ctx)
422
423 containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: all})
424 if err != nil {
425 return nil, log.WrapErr(err, "failed to list containers")
426 }
427
428 var result []*domain.Container
429 for _, c := range containerList.Items {
430 // Extract ports
431 var ports []int
432 for _, port := range c.Ports {
433 if port.PublicPort > 0 {
434 ports = append(ports, int(port.PublicPort))
435 }
436 }
437
438 // Get the primary name (remove leading slash)
439 name := ""
440 if len(c.Names) > 0 {
441 name = strings.TrimPrefix(c.Names[0], "/")
442 }
443
444 volumeMounts := make([]domain.ContainerVolumeMount, 0, len(c.Mounts))
445 for _, m := range c.Mounts {
446 volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
447 Name: m.Name,
448 Type: string(m.Type),
449 Source: m.Source,
450 Destination: m.Destination,
451 ReadOnly: !m.RW,
452 })
453 }
454
455 result = append(result, &domain.Container{
456 ID: c.ID,
457 Image: c.Image,
458 ImageID: c.ImageID,
459 Name: name,
460 Status: string(c.State), // Use State (e.g., "running") not Status (e.g., "Up 2 days")
461 Ports: ports,
462 Labels: c.Labels,
463 VolumeMounts: volumeMounts,
464 Created: time.Unix(c.Created, 0),
465 })
466 }
467
468 return result, nil
469}
470
471// InspectContainer inspects a container.
472func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*domain.Container, error) {
473 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
474 zerowrap.FieldLayer: "adapter",
475 zerowrap.FieldAdapter: "docker",
476 zerowrap.FieldAction: "InspectContainer",
477 zerowrap.FieldEntityID: containerID,
478 })
479 log := zerowrap.FromCtx(ctx)
480
481 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
482 if err != nil {
483 return nil, log.WrapErr(err, "failed to inspect container")
484 }
485 resp := inspectResult.Container
486
487 // Extract published ports
488 var ports []int
489 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
490 for _, bindings := range resp.NetworkSettings.Ports {
491 for _, binding := range bindings {
492 if binding.HostPort != "" {
493 if port, err := strconv.Atoi(binding.HostPort); err == nil {
494 ports = append(ports, port)
495 }
496 }
497 }
498 }
499 }
500
501 // Get container name (remove leading slash)
502 name := strings.TrimPrefix(resp.Name, "/")
503
504 created, _ := time.Parse(time.RFC3339Nano, resp.Created)
505 volumeMounts := make([]domain.ContainerVolumeMount, 0, len(resp.Mounts))
506 for _, m := range resp.Mounts {
507 volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
508 Name: m.Name,
509 Type: string(m.Type),
510 Source: m.Source,
511 Destination: m.Destination,
512 ReadOnly: !m.RW,
513 })
514 }
515
516 return &domain.Container{
517 ID: resp.ID,
518 Image: resp.Config.Image,
519 ImageID: resp.Image,
520 Name: name,
521 Status: string(resp.State.Status),
522 ExitCode: resp.State.ExitCode,
523 Ports: ports,
524 Labels: resp.Config.Labels,
525 VolumeMounts: volumeMounts,
526 Created: created,
527 }, nil
528}
529
530// GetContainerLogs gets container logs.
531func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
532 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
533 zerowrap.FieldLayer: "adapter",
534 zerowrap.FieldAdapter: "docker",
535 zerowrap.FieldAction: "GetContainerLogs",
536 zerowrap.FieldEntityID: containerID,
537 })
538 log := zerowrap.FromCtx(ctx)
539
540 logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{
541 ShowStdout: true,
542 ShowStderr: true,
543 Follow: follow,
544 Timestamps: true,
545 Tail: "10000",
546 })
547 if err != nil {
548 return nil, log.WrapErr(err, "failed to get container logs")
549 }
550
551 return logs, nil
552}
553
554// PullImage pulls an image.
555func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
556 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
557 zerowrap.FieldLayer: "adapter",
558 zerowrap.FieldAdapter: "docker",
559 zerowrap.FieldAction: "PullImage",
560 "image": imageRef,
561 })
562 log := zerowrap.FromCtx(ctx)
563
564 log.Info().Msg("pulling image")
565
566 reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{})
567 if err != nil {
568 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
569 }
570 defer reader.Close()
571
572 // Read the response to completion (this is required for the pull to complete)
573 _, err = io.Copy(io.Discard, reader)
574 if err != nil {
575 return log.WrapErr(err, "failed to read pull response")
576 }
577
578 log.Info().Msg("image pulled successfully")
579 return nil
580}
581
582// PullImageWithAuth pulls an image with authentication.
583func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
584 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
585 zerowrap.FieldLayer: "adapter",
586 zerowrap.FieldAdapter: "docker",
587 zerowrap.FieldAction: "PullImageWithAuth",
588 "image": imageRef,
589 "username": username,
590 })
591 log := zerowrap.FromCtx(ctx)
592
593 log.Info().Msg("pulling image with authentication")
594
595 // Extract registry server address from image reference
596 // e.g., "registry.bnema.dev/image:tag" -> "registry.bnema.dev"
597 serverAddress := imageRef
598 if idx := strings.Index(imageRef, "/"); idx > 0 {
599 serverAddress = imageRef[:idx]
600 }
601
602 // Create authentication configuration
603 authConfig := registry.AuthConfig{
604 Username: username,
605 Password: password,
606 ServerAddress: serverAddress,
607 }
608
609 // Encode authentication to base64 JSON
610 // Note: Docker API accepts both StdEncoding and URLEncoding, but Podman
611 // may be more strict. Using StdEncoding for maximum compatibility.
612 authConfigBytes, err := json.Marshal(authConfig)
613 if err != nil {
614 return log.WrapErr(err, "failed to marshal auth config")
615 }
616 authStr := base64.StdEncoding.EncodeToString(authConfigBytes)
617
618 log.Debug().
619 Str("server_address", serverAddress).
620 Bool("has_auth", username != "" || password != "").
621 Msg("auth config for pull")
622
623 // Pull with authentication
624 reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{
625 RegistryAuth: authStr,
626 })
627 if err != nil {
628 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth")
629 }
630 defer reader.Close()
631
632 // Read the response to completion (this is required for the pull to complete)
633 _, err = io.Copy(io.Discard, reader)
634 if err != nil {
635 return log.WrapErr(err, "failed to read pull response")
636 }
637
638 log.Info().Msg("image pulled successfully with authentication")
639 return nil
640}
641
642// TagImage tags a locally available image with a new reference.
643func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) error {
644 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
645 zerowrap.FieldLayer: "adapter",
646 zerowrap.FieldAdapter: "docker",
647 zerowrap.FieldAction: "TagImage",
648 "source": sourceRef,
649 "target": targetRef,
650 })
651 log := zerowrap.FromCtx(ctx)
652
653 if _, err := r.client.ImageTag(ctx, client.ImageTagOptions{Source: sourceRef, Target: targetRef}); err != nil {
654 return log.WrapErr(err, "failed to tag image")
655 }
656
657 log.Info().Msg("image tagged successfully")
658 return nil
659}
660
661// UntagImage removes a tag from an image without deleting the underlying image layers.
662func (r *Runtime) UntagImage(ctx context.Context, imageRef string) error {
663 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
664 zerowrap.FieldLayer: "adapter",
665 zerowrap.FieldAdapter: "docker",
666 zerowrap.FieldAction: "UntagImage",
667 "image": imageRef,
668 })
669 log := zerowrap.FromCtx(ctx)
670
671 // ImageRemove with PruneChildren=false only removes the tag, not the image layers
672 _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{
673 Force: false,
674 PruneChildren: false,
675 })
676 if err != nil {
677 return log.WrapErr(err, "failed to untag image")
678 }
679
680 log.Debug().Msg("image untagged successfully")
681 return nil
682}
683
684// RemoveImage removes an image.
685func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
686 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
687 zerowrap.FieldLayer: "adapter",
688 zerowrap.FieldAdapter: "docker",
689 zerowrap.FieldAction: "RemoveImage",
690 "image": imageRef,
691 "force": force,
692 })
693 log := zerowrap.FromCtx(ctx)
694
695 _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{Force: force})
696 if err != nil {
697 return log.WrapErr(err, "failed to remove image")
698 }
699
700 log.Info().Msg("image removed")
701 return nil
702}
703
704// ListImages lists images.
705func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
706 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
707 zerowrap.FieldLayer: "adapter",
708 zerowrap.FieldAdapter: "docker",
709 zerowrap.FieldAction: "ListImages",
710 })
711 log := zerowrap.FromCtx(ctx)
712
713 imageList, err := r.client.ImageList(ctx, client.ImageListOptions{})
714 if err != nil {
715 return nil, log.WrapErr(err, "failed to list images")
716 }
717
718 var result []string
719 for _, img := range imageList.Items {
720 for _, tag := range img.RepoTags {
721 if tag != "<none>:<none>" {
722 result = append(result, tag)
723 }
724 }
725 }
726
727 return result, nil
728}
729
730// ListImagesDetailed lists images with metadata.
731func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDetail, error) {
732 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
733 zerowrap.FieldLayer: "adapter",
734 zerowrap.FieldAdapter: "docker",
735 zerowrap.FieldAction: "ListImagesDetailed",
736 })
737 log := zerowrap.FromCtx(ctx)
738
739 imageList, err := r.client.ImageList(ctx, client.ImageListOptions{All: true})
740 if err != nil {
741 return nil, log.WrapErr(err, "failed to list detailed images")
742 }
743
744 result := make([]runtimepkg.ImageDetail, 0, len(imageList.Items))
745 for _, img := range imageList.Items {
746 result = append(result, runtimepkg.ImageDetail{
747 ID: img.ID,
748 RepoTags: img.RepoTags,
749 Size: img.Size,
750 Created: time.Unix(img.Created, 0),
751 })
752 }
753
754 return result, nil
755}
756
757// PruneImages prunes unused images and reports reclaimed space.
758func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepkg.PruneReport, error) {
759 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
760 zerowrap.FieldLayer: "adapter",
761 zerowrap.FieldAdapter: "docker",
762 zerowrap.FieldAction: "PruneImages",
763 "dangling_only": danglingOnly,
764 })
765 log := zerowrap.FromCtx(ctx)
766
767 pruneFilters := make(client.Filters).Add("label", domain.LabelManaged+"=true")
768 if danglingOnly {
769 pruneFilters.Add("dangling", "true")
770 }
771
772 pruneResult, err := r.client.ImagePrune(ctx, client.ImagePruneOptions{Filters: pruneFilters})
773 if err != nil {
774 return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to prune images")
775 }
776
777 deletedIDs := make([]string, 0, len(pruneResult.Report.ImagesDeleted))
778 for _, deleted := range pruneResult.Report.ImagesDeleted {
779 if deleted.Deleted != "" {
780 deletedIDs = append(deletedIDs, deleted.Deleted)
781 }
782 if deleted.Untagged != "" {
783 deletedIDs = append(deletedIDs, deleted.Untagged)
784 }
785 }
786
787 spaceReclaimed := pruneResult.Report.SpaceReclaimed
788 if spaceReclaimed > math.MaxInt64 {
789 log.Warn().
790 Uint64("space_reclaimed_bytes", spaceReclaimed).
791 Int64("space_reclaimed_capped_bytes", math.MaxInt64).
792 Msg("space reclaimed exceeds int64 max; capping value")
793 spaceReclaimed = uint64(math.MaxInt64)
794 }
795
796 //nolint:gosec // spaceReclaimed is capped to MaxInt64 above
797 spaceReclaimedInt := int64(spaceReclaimed)
798
799 return runtimepkg.PruneReport{
800 DeletedIDs: deletedIDs,
801 SpaceReclaimed: spaceReclaimedInt,
802 }, nil
803}
804
805// Ping checks if Docker is responsive.
806func (r *Runtime) Ping(ctx context.Context) error {
807 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
808 zerowrap.FieldLayer: "adapter",
809 zerowrap.FieldAdapter: "docker",
810 zerowrap.FieldAction: "Ping",
811 })
812 log := zerowrap.FromCtx(ctx)
813
814 _, err := r.client.Ping(ctx, client.PingOptions{})
815 if err != nil {
816 return log.WrapErr(err, "Docker ping failed")
817 }
818 return nil
819}
820
821// Version returns Docker version.
822func (r *Runtime) Version(ctx context.Context) (string, error) {
823 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
824 zerowrap.FieldLayer: "adapter",
825 zerowrap.FieldAdapter: "docker",
826 zerowrap.FieldAction: "Version",
827 })
828 log := zerowrap.FromCtx(ctx)
829
830 version, err := r.client.ServerVersion(ctx, client.ServerVersionOptions{})
831 if err != nil {
832 return "", log.WrapErr(err, "failed to get Docker version")
833 }
834 return version.Version, nil
835}
836
837// IsContainerRunning checks if a container is running.
838func (r *Runtime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
839 ctr, err := r.InspectContainer(ctx, containerID)
840 if err != nil {
841 return false, err
842 }
843 return ctr.Status == string(domain.ContainerStatusRunning), nil
844}
845
846// GetContainerHealthStatus returns Docker healthcheck status when available.
847func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) {
848 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
849 zerowrap.FieldLayer: "adapter",
850 zerowrap.FieldAdapter: "docker",
851 zerowrap.FieldAction: "GetContainerHealthStatus",
852 zerowrap.FieldEntityID: containerID,
853 })
854 log := zerowrap.FromCtx(ctx)
855
856 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
857 if err != nil {
858 return "", false, log.WrapErr(err, "failed to inspect container")
859 }
860 resp := inspectResult.Container
861
862 // Detect configured healthchecks from container config rather than relying on
863 // State.Health presence. Some runtimes can expose an empty State.Health struct
864 // even when no healthcheck is configured.
865 hasHealthcheck := hasConfiguredHealthcheck(resp.Config)
866 if !hasHealthcheck {
867 return "", false, nil
868 }
869 if resp.State == nil || resp.State.Health == nil {
870 return "", true, nil
871 }
872
873 return string(resp.State.Health.Status), true, nil
874}
875
876func hasConfiguredHealthcheck(cfg *container.Config) bool {
877 if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 {
878 return false
879 }
880 // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images.
881 return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE")
882}
883
884// GetContainerPort gets the host port for a container's internal port.
885func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
886 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
887 zerowrap.FieldLayer: "adapter",
888 zerowrap.FieldAdapter: "docker",
889 zerowrap.FieldAction: "GetContainerPort",
890 zerowrap.FieldEntityID: containerID,
891 "internal_port": internalPort,
892 })
893 log := zerowrap.FromCtx(ctx)
894
895 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
896 if err != nil {
897 return 0, log.WrapErr(err, "failed to inspect container")
898 }
899 resp := inspectResult.Container
900
901 if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
902 return 0, fmt.Errorf("no port mappings found for container %s", containerID)
903 }
904
905 containerPort, err := network.ParsePort(fmt.Sprintf("%d/tcp", internalPort))
906 if err != nil {
907 return 0, fmt.Errorf("invalid container port %d: %w", internalPort, err)
908 }
909 bindings, exists := resp.NetworkSettings.Ports[containerPort]
910 if !exists || len(bindings) == 0 {
911 return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
912 }
913
914 hostPort, err := strconv.Atoi(bindings[0].HostPort)
915 if err != nil {
916 return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
917 }
918
919 return hostPort, nil
920}
921
922// GetImageExposedPorts gets the exposed ports from an image.
923func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
924 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
925 zerowrap.FieldLayer: "adapter",
926 zerowrap.FieldAdapter: "docker",
927 zerowrap.FieldAction: "GetImageExposedPorts",
928 "image": imageRef,
929 })
930 log := zerowrap.FromCtx(ctx)
931
932 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
933 if err != nil {
934 return nil, log.WrapErr(err, "failed to inspect image")
935 }
936
937 var ports []int
938 if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
939 for portSpec := range imageInspect.Config.ExposedPorts {
940 // Parse port from format like "80/tcp"
941 portStr := strings.Split(portSpec, "/")[0]
942 if port, err := strconv.Atoi(portStr); err == nil {
943 ports = append(ports, port)
944 }
945 }
946 }
947
948 if len(ports) == 0 {
949 return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
950 }
951
952 // Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.)
953 // Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending
954 sortPortsHTTPFirst(ports)
955
956 log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")
957 return ports, nil
958}
959
960// sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22).
961func sortPortsHTTPFirst(ports []int) {
962 priority := map[int]int{
963 80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6,
964 }
965 sort.Slice(ports, func(i, j int) bool {
966 pi, oki := priority[ports[i]]
967 pj, okj := priority[ports[j]]
968 if oki && okj {
969 return pi < pj
970 }
971 if oki {
972 return true
973 }
974 if okj {
975 return false
976 }
977 // Neither is a priority port - sort ascending but push 22 to the end
978 if ports[i] == 22 {
979 return false
980 }
981 if ports[j] == 22 {
982 return true
983 }
984 return ports[i] < ports[j]
985 })
986}
987
988// GetContainerExposedPorts gets all exposed ports from a running container.
989func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
990 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
991 zerowrap.FieldLayer: "adapter",
992 zerowrap.FieldAdapter: "docker",
993 zerowrap.FieldAction: "GetContainerExposedPorts",
994 zerowrap.FieldEntityID: containerID,
995 })
996 log := zerowrap.FromCtx(ctx)
997
998 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
999 if err != nil {
1000 return nil, log.WrapErr(err, "failed to inspect container")
1001 }
1002 resp := inspectResult.Container
1003
1004 var ports []int
1005 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
1006 for portSpec := range resp.NetworkSettings.Ports {
1007 // Parse port from format like "80/tcp".
1008 portStr := portSpec.Port()
1009 if port, err := strconv.Atoi(portStr); err == nil {
1010 ports = append(ports, port)
1011 }
1012 }
1013 }
1014
1015 return ports, nil
1016}
1017
1018// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use.
1019func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
1020 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1021 zerowrap.FieldLayer: "adapter",
1022 zerowrap.FieldAdapter: "docker",
1023 zerowrap.FieldAction: "GetContainerNetworkInfo",
1024 zerowrap.FieldEntityID: containerID,
1025 })
1026 log := zerowrap.FromCtx(ctx)
1027
1028 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1029 if err != nil {
1030 return "", 0, log.WrapErr(err, "failed to inspect container")
1031 }
1032 resp := inspectResult.Container
1033
1034 // Get container's internal IP address
1035 var containerIP string
1036 if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
1037 // Use the first available network (usually bridge or custom network)
1038 for _, net := range resp.NetworkSettings.Networks {
1039 if net.IPAddress.IsValid() {
1040 containerIP = net.IPAddress.String()
1041 break
1042 }
1043 }
1044 }
1045
1046 if containerIP == "" {
1047 return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
1048 }
1049
1050 // Get exposed ports from container
1051 var exposedPorts []int
1052 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
1053 for portSpec := range resp.NetworkSettings.Ports {
1054 // Parse port from format like "80/tcp".
1055 if port, err := strconv.Atoi(portSpec.Port()); err == nil {
1056 exposedPorts = append(exposedPorts, port)
1057 }
1058 }
1059 }
1060
1061 if len(exposedPorts) == 0 {
1062 return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
1063 }
1064
1065 // Sort ports with HTTP-friendly ports first, pushing SSH (22) to the end
1066 sortPortsHTTPFirst(exposedPorts)
1067 selectedPort := exposedPorts[0]
1068
1069 log.Info().
1070 Str("ip", containerIP).
1071 Int("selected_port", selectedPort).
1072 Ints("exposed_ports", exposedPorts).
1073 Msg("container network info detected")
1074
1075 return containerIP, selectedPort, nil
1076}
1077
1078// GetContainerNetwork returns the network name a container is connected to.
1079func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) {
1080 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1081 zerowrap.FieldLayer: "adapter",
1082 zerowrap.FieldAdapter: "docker",
1083 zerowrap.FieldAction: "GetContainerNetwork",
1084 zerowrap.FieldEntityID: containerID,
1085 })
1086 log := zerowrap.FromCtx(ctx)
1087
1088 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1089 if err != nil {
1090 return "", log.WrapErr(err, "failed to inspect container")
1091 }
1092
1093 networks := inspectResult.Container.NetworkSettings
1094 if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {
1095 return "bridge", nil
1096 }
1097
1098 // Priority: gordon-* networks first, then bridge, then any other
1099 selected := ""
1100 hasBridge := false
1101 for name := range networks.Networks {
1102 if strings.HasPrefix(name, "gordon-") {
1103 selected = name
1104 break
1105 }
1106 if name == "bridge" {
1107 hasBridge = true
1108 } else if selected == "" {
1109 selected = name
1110 }
1111 }
1112
1113 // If no gordon-* network found, prefer bridge over random network
1114 if selected == "" || (hasBridge && !strings.HasPrefix(selected, "gordon-")) {
1115 if hasBridge {
1116 selected = "bridge"
1117 }
1118 }
1119
1120 if selected == "" {
1121 return "bridge", nil
1122 }
1123
1124 log.Debug().Str("network", selected).Msg("container network detected")
1125 return selected, nil
1126}
1127
1128// InspectImageVolumes gets the volume mount points declared in the image.
1129func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
1130 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1131 zerowrap.FieldLayer: "adapter",
1132 zerowrap.FieldAdapter: "docker",
1133 zerowrap.FieldAction: "InspectImageVolumes",
1134 "image": imageRef,
1135 })
1136 log := zerowrap.FromCtx(ctx)
1137
1138 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1139 if err != nil {
1140 return nil, log.WrapErr(err, "failed to inspect image")
1141 }
1142
1143 var volumes []string
1144 if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
1145 for volumePath := range imageInspect.Config.Volumes {
1146 volumes = append(volumes, volumePath)
1147 }
1148 }
1149
1150 if len(volumes) > 0 {
1151 log.Info().Strs("volumes", volumes).Msg("found VOLUME directives in image")
1152 } else {
1153 log.Debug().Msg("no VOLUME directives found in image")
1154 }
1155
1156 return volumes, nil
1157}
1158
1159// VolumeExists checks if a Docker volume exists.
1160func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
1161 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1162 zerowrap.FieldLayer: "adapter",
1163 zerowrap.FieldAdapter: "docker",
1164 zerowrap.FieldAction: "VolumeExists",
1165 "volume": volumeName,
1166 })
1167 log := zerowrap.FromCtx(ctx)
1168
1169 _, err := r.client.VolumeInspect(ctx, volumeName, client.VolumeInspectOptions{})
1170 if err != nil {
1171 if cerrdefs.IsNotFound(err) {
1172 return false, nil
1173 }
1174 return false, log.WrapErr(err, "failed to inspect volume")
1175 }
1176 return true, nil
1177}
1178
1179// CreateVolume creates a new Docker volume.
1180func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error {
1181 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1182 zerowrap.FieldLayer: "adapter",
1183 zerowrap.FieldAdapter: "docker",
1184 zerowrap.FieldAction: "CreateVolume",
1185 "volume": volumeName,
1186 })
1187 log := zerowrap.FromCtx(ctx)
1188
1189 _, err := r.client.VolumeCreate(ctx, client.VolumeCreateOptions{
1190 Name: volumeName,
1191 Labels: map[string]string{
1192 domain.LabelManaged: "true",
1193 domain.LabelCreated: time.Now().UTC().Format(time.RFC3339),
1194 },
1195 })
1196 if err != nil {
1197 return log.WrapErr(err, "failed to create volume")
1198 }
1199
1200 log.Info().Msg("volume created")
1201 return nil
1202}
1203
1204// RemoveVolume removes a Docker volume.
1205func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
1206 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1207 zerowrap.FieldLayer: "adapter",
1208 zerowrap.FieldAdapter: "docker",
1209 zerowrap.FieldAction: "RemoveVolume",
1210 "volume": volumeName,
1211 "force": force,
1212 })
1213 log := zerowrap.FromCtx(ctx)
1214
1215 _, err := r.client.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
1216 if err != nil {
1217 if cerrdefs.IsNotFound(err) {
1218 log.Debug().Msg("volume not found, already removed")
1219 return nil
1220 }
1221 return log.WrapErr(err, "failed to remove volume")
1222 }
1223
1224 log.Info().Msg("volume removed")
1225 return nil
1226}
1227
1228// ListVolumes returns all named volumes with their usage status.
1229func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
1230 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1231 zerowrap.FieldLayer: "adapter",
1232 zerowrap.FieldAdapter: "docker",
1233 zerowrap.FieldAction: "ListVolumes",
1234 })
1235 log := zerowrap.FromCtx(ctx)
1236
1237 volumeList, err := r.client.VolumeList(ctx, client.VolumeListOptions{})
1238 if err != nil {
1239 return nil, log.WrapErr(err, "failed to list volumes")
1240 }
1241
1242 // Get all containers to determine volume usage.
1243 containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: true})
1244 if err != nil {
1245 return nil, log.WrapErr(err, "failed to list containers for volume usage")
1246 }
1247
1248 // Build a map of volume name -> container names.
1249 volumeUsers := make(map[string][]string)
1250 for _, c := range containerList.Items {
1251 for _, m := range c.Mounts {
1252 if m.Type == "volume" {
1253 name := ""
1254 if len(c.Names) > 0 {
1255 name = strings.TrimPrefix(c.Names[0], "/")
1256 }
1257 volumeUsers[m.Name] = append(volumeUsers[m.Name], name)
1258 }
1259 }
1260 }
1261
1262 var result []*domain.VolumeInfo
1263 for _, v := range volumeList.Items {
1264 var size int64
1265 if v.UsageData != nil {
1266 size = v.UsageData.Size
1267 }
1268
1269 created, _ := time.Parse(time.RFC3339, v.CreatedAt)
1270
1271 users := volumeUsers[v.Name]
1272 info := &domain.VolumeInfo{
1273 Name: v.Name,
1274 Driver: v.Driver,
1275 MountPoint: v.Mountpoint,
1276 Size: size,
1277 CreatedAt: created,
1278 InUse: len(users) > 0,
1279 Containers: users,
1280 Labels: v.Labels,
1281 }
1282 result = append(result, info)
1283 }
1284
1285 return result, nil
1286}
1287
1288// ExportVolumeArchive exports a named volume as a compressed tar archive stream.
1289func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
1290 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1291 zerowrap.FieldLayer: "adapter",
1292 zerowrap.FieldAdapter: "docker",
1293 zerowrap.FieldAction: "ExportVolumeArchive",
1294 "volume": req.VolumeName,
1295 })
1296 log := zerowrap.FromCtx(ctx)
1297
1298 if err := validateVolumeArchiveRequest(req); err != nil {
1299 return nil, err
1300 }
1301 if err := r.ensureVolumeArchiveHelperImage(ctx, req.HelperImage, log); err != nil {
1302 return nil, err
1303 }
1304 created, cleanup, err := r.createVolumeArchiveHelper(ctx, req, log)
1305 if err != nil {
1306 return nil, err
1307 }
1308 if _, err := r.client.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
1309 _ = cleanup()
1310 return nil, log.WrapErr(err, "failed to start volume archive helper container")
1311 }
1312
1313 stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log)
1314 if err != nil {
1315 _ = cleanup()
1316 return nil, err
1317 }
1318 return &domain.VolumeArchiveResult{Stream: stream}, nil
1319}
1320
1321func validateVolumeArchiveRequest(req domain.VolumeArchiveRequest) error {
1322 if strings.TrimSpace(req.VolumeName) == "" {
1323 return fmt.Errorf("volume name is required")
1324 }
1325 if strings.TrimSpace(req.HelperImage) == "" {
1326 return fmt.Errorf("helper image is required")
1327 }
1328 return nil
1329}
1330
1331func (r *Runtime) ensureVolumeArchiveHelperImage(ctx context.Context, imageRef string, log zerowrap.Logger) error {
1332 if _, err := r.client.ImageInspect(ctx, imageRef); err != nil {
1333 if !cerrdefs.IsNotFound(err) {
1334 return log.WrapErr(err, "failed to inspect volume archive helper image")
1335 }
1336 return r.PullImage(ctx, imageRef)
1337 }
1338 return nil
1339}
1340
1341func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (client.ContainerCreateResult, func() error, error) {
1342 cmd, err := volumeArchiveCommand(req.Compression)
1343 if err != nil {
1344 return client.ContainerCreateResult{}, nil, err
1345 }
1346 created, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
1347 Config: volumeArchiveContainerConfig(req.HelperImage, cmd),
1348 HostConfig: volumeArchiveHostConfig(req.VolumeName),
1349 Name: fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()),
1350 })
1351 if err != nil {
1352 return client.ContainerCreateResult{}, nil, log.WrapErr(err, "failed to create volume archive helper container")
1353 }
1354 var cleanupOnce sync.Once
1355 cleanup := func() error {
1356 var cleanupErr error
1357 cleanupOnce.Do(func() {
1358 removeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1359 defer cancel()
1360 cleanupErr = r.RemoveContainer(removeCtx, created.ID, true)
1361 })
1362 return cleanupErr
1363 }
1364 return created, cleanup, nil
1365}
1366
1367func volumeArchiveContainerConfig(imageRef, cmd string) *container.Config {
1368 return &container.Config{
1369 Image: imageRef,
1370 Cmd: []string{"sh", "-c", cmd},
1371 AttachStdout: true,
1372 AttachStderr: true,
1373 NetworkDisabled: true,
1374 Labels: map[string]string{
1375 domain.LabelManaged: "true",
1376 "gordon.purpose": "volume-backup",
1377 },
1378 }
1379}
1380
1381func volumeArchiveHostConfig(volumeName string) *container.HostConfig {
1382 return &container.HostConfig{
1383 AutoRemove: false,
1384 Mounts: []mount.Mount{{
1385 Type: mount.TypeVolume,
1386 Source: volumeName,
1387 Target: "/volume",
1388 ReadOnly: true,
1389 }},
1390 }
1391}
1392
1393func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {
1394 logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
1395 if err != nil {
1396 return nil, log.WrapErr(err, "failed to attach volume archive stream")
1397 }
1398 waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
1399 statusCh, errCh := waitResult.Result, waitResult.Error
1400 pr, pw := io.Pipe()
1401 go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
1402 return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil
1403}
1404
1405func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) {
1406 defer logs.Close()
1407 defer func() {
1408 if err := cleanup(); err != nil {
1409 log.Warn().Err(err).Msg("failed to clean up volume archive helper container")
1410 }
1411 }()
1412
1413 var stderr bytes.Buffer
1414 _, copyErr := stdcopy.StdCopy(pw, &stderr, logs)
1415 statusCode, waitErr := waitForVolumeArchiveContainer(statusCh, errCh)
1416 closeVolumeArchivePipe(pw, copyErr, waitErr, statusCode, stderr.String())
1417}
1418
1419func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) {
1420 for statusCh != nil || errCh != nil {
1421 select {
1422 case err, ok := <-errCh:
1423 if !ok {
1424 errCh = nil
1425 continue
1426 }
1427 if err != nil {
1428 return 0, err
1429 }
1430 case status, ok := <-statusCh:
1431 if !ok {
1432 return 0, fmt.Errorf("volume archive helper container wait channel closed")
1433 }
1434 return status.StatusCode, nil
1435 }
1436 }
1437 return 0, fmt.Errorf("volume archive helper container wait channels closed")
1438}
1439
1440func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) {
1441 switch {
1442 case copyErr != nil:
1443 _ = pw.CloseWithError(copyErr)
1444 case waitErr != nil:
1445 _ = pw.CloseWithError(waitErr)
1446 case statusCode != 0:
1447 _ = pw.CloseWithError(fmt.Errorf("volume archive helper exited with code %d: %s", statusCode, strings.TrimSpace(stderr)))
1448 default:
1449 _ = pw.Close()
1450 }
1451}
1452
1453func volumeArchiveCommand(compression domain.VolumeBackupCompression) (string, error) {
1454 switch compression {
1455 case domain.VolumeBackupCompressionGzip:
1456 return "cd /volume && tar -czf - .", nil
1457 case domain.VolumeBackupCompressionZstd:
1458 return "cd /volume && fifo=/tmp/gordon-volume-backup.tar && mkfifo $fifo && zstd -T0 -c $fifo & zstd_pid=$! && tar -cf $fifo .; tar_status=$?; wait $zstd_pid; zstd_status=$?; if [ $tar_status -ne 0 ]; then exit $tar_status; fi; exit $zstd_status", nil
1459 default:
1460 return "", fmt.Errorf("unsupported volume backup compression: %s", compression)
1461 }
1462}
1463
1464// InspectImageEnv gets the environment variables declared in the image.
1465func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
1466 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1467 zerowrap.FieldLayer: "adapter",
1468 zerowrap.FieldAdapter: "docker",
1469 zerowrap.FieldAction: "InspectImageEnv",
1470 "image": imageRef,
1471 })
1472 log := zerowrap.FromCtx(ctx)
1473
1474 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1475 if err != nil {
1476 return nil, log.WrapErr(err, "failed to inspect image")
1477 }
1478
1479 var envVars []string
1480 if imageInspect.Config != nil && imageInspect.Config.Env != nil {
1481 envVars = imageInspect.Config.Env
1482 }
1483
1484 if len(envVars) > 0 {
1485 log.Debug().
1486 Int(zerowrap.FieldCount, len(envVars)).
1487 Strs("env_keys", imageEnvKeys(envVars)).
1488 Msg("found ENV directives in image")
1489 } else {
1490 log.Debug().Msg("no ENV directives found in image")
1491 }
1492
1493 return envVars, nil
1494}
1495
1496// GetImageLabels returns the labels defined on an image.
1497func imageEnvKeys(envVars []string) []string {
1498 keys := make([]string, 0, len(envVars))
1499 for _, envVar := range envVars {
1500 key, _, ok := strings.Cut(envVar, "=")
1501 if !ok || key == "" {
1502 keys = append(keys, "[malformed]")
1503 continue
1504 }
1505 keys = append(keys, key)
1506 }
1507 sort.Strings(keys)
1508 return keys
1509}
1510
1511func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
1512 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1513 zerowrap.FieldLayer: "adapter",
1514 zerowrap.FieldAdapter: "docker",
1515 zerowrap.FieldAction: "GetImageLabels",
1516 "image": imageRef,
1517 })
1518 log := zerowrap.FromCtx(ctx)
1519
1520 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1521 if err != nil {
1522 return nil, log.WrapErr(err, "failed to inspect image")
1523 }
1524
1525 labels := make(map[string]string)
1526 if imageInspect.Config != nil && imageInspect.Config.Labels != nil {
1527 labels = imageInspect.Config.Labels
1528 }
1529
1530 log.Debug().Int("label_count", len(labels)).Msg("retrieved image labels")
1531 return labels, nil
1532}
1533
1534// GetImageID returns the unique image ID (sha256 digest) for the given image reference.
1535func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) {
1536 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1537 zerowrap.FieldLayer: "adapter",
1538 zerowrap.FieldAdapter: "docker",
1539 zerowrap.FieldAction: "GetImageID",
1540 "image": imageRef,
1541 })
1542 log := zerowrap.FromCtx(ctx)
1543
1544 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1545 if err != nil {
1546 return "", log.WrapErr(err, "failed to inspect image")
1547 }
1548
1549 return imageInspect.ID, nil
1550}
1551
1552// CreateNetwork creates a new Docker network.
1553func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
1554 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1555 zerowrap.FieldLayer: "adapter",
1556 zerowrap.FieldAdapter: "docker",
1557 zerowrap.FieldAction: "CreateNetwork",
1558 "network": name,
1559 })
1560 log := zerowrap.FromCtx(ctx)
1561
1562 driver := config.Driver
1563 if driver == "" {
1564 driver = "bridge"
1565 }
1566 labels := make(map[string]string, len(config.Labels)+1)
1567 maps.Copy(labels, config.Labels)
1568 labels[domain.LabelManaged] = "true"
1569
1570 createOptions := client.NetworkCreateOptions{
1571 Driver: driver,
1572 Internal: config.Internal,
1573 Labels: labels,
1574 }
1575
1576 _, err := r.client.NetworkCreate(ctx, name, createOptions)
1577 if err != nil {
1578 return log.WrapErr(err, "failed to create network")
1579 }
1580
1581 log.Info().Str("driver", driver).Msg("network created")
1582 return nil
1583}
1584
1585// RemoveNetwork removes a Docker network.
1586func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error {
1587 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1588 zerowrap.FieldLayer: "adapter",
1589 zerowrap.FieldAdapter: "docker",
1590 zerowrap.FieldAction: "RemoveNetwork",
1591 "network": name,
1592 })
1593 log := zerowrap.FromCtx(ctx)
1594
1595 _, err := r.client.NetworkRemove(ctx, name, client.NetworkRemoveOptions{})
1596 if err != nil {
1597 if cerrdefs.IsNotFound(err) {
1598 log.Debug().Msg("network not found, already removed")
1599 return nil
1600 }
1601 return log.WrapErr(err, "failed to remove network")
1602 }
1603
1604 log.Info().Msg("network removed")
1605 return nil
1606}
1607
1608// ListNetworks lists all Docker networks.
1609func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
1610 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1611 zerowrap.FieldLayer: "adapter",
1612 zerowrap.FieldAdapter: "docker",
1613 zerowrap.FieldAction: "ListNetworks",
1614 })
1615 log := zerowrap.FromCtx(ctx)
1616
1617 networkList, err := r.client.NetworkList(ctx, client.NetworkListOptions{})
1618 if err != nil {
1619 return nil, log.WrapErr(err, "failed to list networks")
1620 }
1621
1622 var result []*domain.NetworkInfo
1623 for _, net := range networkList.Items {
1624 networkInspect, err := r.client.NetworkInspect(ctx, net.ID, client.NetworkInspectOptions{})
1625 if err != nil {
1626 return nil, log.WrapErr(err, "failed to inspect network")
1627 }
1628 var containers []string
1629 for containerID := range networkInspect.Network.Containers {
1630 containers = append(containers, containerID)
1631 }
1632
1633 result = append(result, &domain.NetworkInfo{
1634 ID: net.ID,
1635 Name: net.Name,
1636 Driver: net.Driver,
1637 Containers: containers,
1638 Labels: net.Labels,
1639 })
1640 }
1641
1642 return result, nil
1643}
1644
1645// NetworkExists checks if a Docker network exists.
1646func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error) {
1647 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1648 zerowrap.FieldLayer: "adapter",
1649 zerowrap.FieldAdapter: "docker",
1650 zerowrap.FieldAction: "NetworkExists",
1651 "network": name,
1652 })
1653 log := zerowrap.FromCtx(ctx)
1654
1655 _, err := r.client.NetworkInspect(ctx, name, client.NetworkInspectOptions{})
1656 if err != nil {
1657 if cerrdefs.IsNotFound(err) {
1658 return false, nil
1659 }
1660 return false, log.WrapErr(err, "failed to inspect network")
1661 }
1662 return true, nil
1663}
1664
1665// ConnectContainerToNetwork connects a container to a network.
1666func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
1667 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1668 zerowrap.FieldLayer: "adapter",
1669 zerowrap.FieldAdapter: "docker",
1670 zerowrap.FieldAction: "ConnectContainerToNetwork",
1671 "container": containerName,
1672 "network": networkName,
1673 })
1674 log := zerowrap.FromCtx(ctx)
1675
1676 _, err := r.client.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
1677 Container: containerName,
1678 EndpointConfig: &network.EndpointSettings{},
1679 })
1680 if err != nil {
1681 return log.WrapErr(err, "failed to connect container to network")
1682 }
1683
1684 log.Info().Msg("container connected to network")
1685 return nil
1686}
1687
1688// DisconnectContainerFromNetwork disconnects a container from a network.
1689func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
1690 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1691 zerowrap.FieldLayer: "adapter",
1692 zerowrap.FieldAdapter: "docker",
1693 zerowrap.FieldAction: "DisconnectContainerFromNetwork",
1694 "container": containerName,
1695 "network": networkName,
1696 })
1697 log := zerowrap.FromCtx(ctx)
1698
1699 _, err := r.client.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{
1700 Container: containerName,
1701 })
1702 if err != nil {
1703 return log.WrapErr(err, "failed to disconnect container from network")
1704 }
1705
1706 log.Info().Msg("container disconnected from network")
1707 return nil
1708}
1709
1710// ExecInContainer executes a command in a running container.
1711func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
1712 if len(cmd) == 0 {
1713 return nil, fmt.Errorf("command cannot be empty")
1714 }
1715
1716 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1717 zerowrap.FieldLayer: "adapter",
1718 zerowrap.FieldAdapter: "docker",
1719 zerowrap.FieldAction: "ExecInContainer",
1720 zerowrap.FieldEntityID: containerID,
1721 "command": cmd[0],
1722 "arg_count": len(cmd) - 1,
1723 })
1724 log := zerowrap.FromCtx(ctx)
1725
1726 execResp, err := r.client.ExecCreate(ctx, containerID, client.ExecCreateOptions{
1727 Cmd: cmd,
1728 AttachStdout: true,
1729 AttachStderr: true,
1730 })
1731 if err != nil {
1732 return nil, log.WrapErr(err, "failed to create exec")
1733 }
1734
1735 attachCtx := ctx
1736 var cancel context.CancelFunc
1737 if _, hasDeadline := ctx.Deadline(); !hasDeadline {
1738 attachCtx, cancel = context.WithTimeout(ctx, 5*time.Minute)
1739 defer cancel()
1740 }
1741
1742 attachResp, err := r.client.ExecAttach(attachCtx, execResp.ID, client.ExecAttachOptions{})
1743 if err != nil {
1744 return nil, log.WrapErr(err, "failed to attach to exec")
1745 }
1746 defer attachResp.Close()
1747 if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil {
1748 _ = attachResp.Conn.SetReadDeadline(deadline)
1749 }
1750
1751 stdout, stderr, err := parseExecOutput(attachResp.Reader)
1752 if err != nil {
1753 return nil, log.WrapErr(err, "failed to read exec output")
1754 }
1755
1756 inspectResp, err := r.client.ExecInspect(ctx, execResp.ID, client.ExecInspectOptions{})
1757 if err != nil {
1758 return nil, log.WrapErr(err, "failed to inspect exec result")
1759 }
1760
1761 return &out.ExecResult{
1762 ExitCode: inspectResp.ExitCode,
1763 Stdout: stdout,
1764 Stderr: stderr,
1765 }, nil
1766}
1767
1768const maxExecOutputSize = 8 << 20 // 8 MiB per stream
1769
1770type boundedBuffer struct {
1771 bytes.Buffer
1772 limit int
1773}
1774
1775func (w *boundedBuffer) Write(p []byte) (int, error) {
1776 remaining := w.limit - w.Len()
1777 if remaining <= 0 {
1778 return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
1779 }
1780 if len(p) > remaining {
1781 _, _ = w.Buffer.Write(p[:remaining])
1782 return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
1783 }
1784 return w.Buffer.Write(p)
1785}
1786
1787func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
1788 stdout := boundedBuffer{limit: maxExecOutputSize}
1789 stderr := boundedBuffer{limit: maxExecOutputSize}
1790 buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr}
1791 var header [8]byte
1792
1793 for {
1794 if _, err := io.ReadFull(reader, header[:]); err != nil {
1795 if errors.Is(err, io.EOF) {
1796 break
1797 }
1798 return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err)
1799 }
1800 output, ok := buffers[header[0]]
1801 if !ok {
1802 return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0])
1803 }
1804 payloadSize := int64(binary.BigEndian.Uint32(header[4:]))
1805 remaining := int64(output.limit - output.Len())
1806 if payloadSize > remaining {
1807 return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining)
1808 }
1809 if _, err := io.CopyN(output, reader, payloadSize); err != nil {
1810 return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err)
1811 }
1812 }
1813
1814 return stdout.Bytes(), stderr.Bytes(), nil
1815}
1816
1817// CopyFromContainer copies a file from a container and returns a content stream.
1818func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
1819 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1820 zerowrap.FieldLayer: "adapter",
1821 zerowrap.FieldAdapter: "docker",
1822 zerowrap.FieldAction: "CopyFromContainer",
1823 zerowrap.FieldEntityID: containerID,
1824 "src_path": srcPath,
1825 })
1826 log := zerowrap.FromCtx(ctx)
1827
1828 copyResult, err := r.client.CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{SourcePath: srcPath})
1829 if err != nil {
1830 return nil, log.WrapErr(err, "failed to copy from container")
1831 }
1832
1833 // The response is a tar archive - we need to extract the requested file stream.
1834 reader, err := extractFileFromTar(copyResult.Content, srcPath)
1835 if err != nil {
1836 _ = copyResult.Content.Close()
1837 return nil, log.WrapErr(err, "failed to extract file from tar")
1838 }
1839
1840 return reader, nil
1841}
1842
1843// extractFileFromTar extracts a single file from a tar archive.
1844func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
1845 tr := tar.NewReader(reader)
1846 targetName := normalizedTarPath(targetPath)
1847 targetBase := filepath.Base(targetName)
1848 var fallback *removeOnCloseFile
1849 fallbackMatches := 0
1850
1851 for {
1852 header, err := tr.Next()
1853 if err == io.EOF {
1854 break
1855 }
1856 if err != nil {
1857 cleanupFallback(fallback)
1858 _ = reader.Close()
1859 return nil, err
1860 }
1861 if header.Typeflag != tar.TypeReg {
1862 continue
1863 }
1864
1865 headerName := normalizedTarPath(header.Name)
1866 if headerName == targetName {
1867 cleanupFallback(fallback)
1868 return streamCurrentTarFile(reader, tr, header.Size), nil
1869 }
1870 if headerName == targetBase {
1871 fallbackMatches++
1872 if fallbackMatches == 1 {
1873 fallback, err = bufferCurrentTarFile(tr, header.Size)
1874 if err != nil {
1875 _ = reader.Close()
1876 return nil, err
1877 }
1878 }
1879 }
1880 }
1881
1882 _ = reader.Close()
1883 if fallbackMatches == 1 {
1884 if _, err := fallback.Seek(0, io.SeekStart); err != nil {
1885 cleanupFallback(fallback)
1886 return nil, err
1887 }
1888 return fallback, nil
1889 }
1890 cleanupFallback(fallback)
1891 if fallbackMatches > 1 {
1892 return nil, fmt.Errorf("ambiguous basename matches in container archive for: %s", targetPath)
1893 }
1894 return nil, fmt.Errorf("file not found in container: %s", targetPath)
1895}
1896
1897func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser {
1898 pr, pw := io.Pipe()
1899 go func() {
1900 defer reader.Close()
1901 _, copyErr := io.CopyN(pw, tr, size)
1902 if copyErr != nil {
1903 _ = pw.CloseWithError(copyErr)
1904 return
1905 }
1906 _ = pw.Close()
1907 }()
1908 return &pipeReadCloser{pr: pr, pw: pw, original: reader}
1909}
1910
1911func bufferCurrentTarFile(tr *tar.Reader, size int64) (*removeOnCloseFile, error) {
1912 file, err := os.CreateTemp("", "gordon-copy-from-container-*")
1913 if err != nil {
1914 return nil, err
1915 }
1916 fallback := &removeOnCloseFile{File: file, path: file.Name()}
1917 if _, err := io.CopyN(fallback, tr, size); err != nil {
1918 cleanupFallback(fallback)
1919 return nil, err
1920 }
1921 return fallback, nil
1922}
1923
1924func cleanupFallback(fallback *removeOnCloseFile) {
1925 if fallback != nil {
1926 _ = fallback.Close()
1927 }
1928}
1929
1930func normalizedTarPath(path string) string {
1931 path = filepath.ToSlash(filepath.Clean(path))
1932 return strings.TrimPrefix(path, "/")
1933}
1934
1935const maxExtractedEnvFileSize = 1 << 20 // 1 MiB
1936
1937func readExtractedEnvFile(reader io.Reader) ([]byte, error) {
1938 limited := io.LimitReader(reader, maxExtractedEnvFileSize+1)
1939 data, err := io.ReadAll(limited)
1940 if err != nil {
1941 return nil, err
1942 }
1943 if len(data) > maxExtractedEnvFileSize {
1944 return nil, fmt.Errorf("extracted env file exceeds %d bytes", maxExtractedEnvFileSize)
1945 }
1946 return data, nil
1947}
1948
1949// ExtractEnvFileFromImage extracts an env file from an image.
1950// This creates a temporary container, copies the file, and removes the container.
1951func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
1952 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1953 zerowrap.FieldLayer: "adapter",
1954 zerowrap.FieldAdapter: "docker",
1955 zerowrap.FieldAction: "ExtractEnvFileFromImage",
1956 "image": imageRef,
1957 "env_file": envFilePath,
1958 })
1959 log := zerowrap.FromCtx(ctx)
1960
1961 log.Info().Msg("extracting env file from image")
1962
1963 // Create a temporary container without starting it
1964 containerConfig := &container.Config{
1965 Image: imageRef,
1966 Cmd: []string{"true"}, // Dummy command
1967 }
1968
1969 resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{Config: containerConfig})
1970 if err != nil {
1971 return nil, log.WrapErr(err, "failed to create temporary container")
1972 }
1973
1974 tempContainerID := resp.ID
1975 log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container")
1976
1977 // Ensure cleanup
1978 defer func() {
1979 if _, err := r.client.ContainerRemove(ctx, tempContainerID, client.ContainerRemoveOptions{Force: true}); err != nil {
1980 log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
1981 }
1982 }()
1983
1984 // Copy the env file from the container
1985 reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1986 if err != nil {
1987 return nil, err
1988 }
1989 defer reader.Close()
1990
1991 data, err := readExtractedEnvFile(reader)
1992 if err != nil {
1993 return nil, log.WrapErr(err, "failed to read extracted env file")
1994 }
1995
1996 log.Info().Int("size", len(data)).Msg("env file extracted from image")
1997 return data, nil
1998}