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