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"
17 "net/http"
18 "net/netip"
19 "net/url"
20 "os"
21 "path/filepath"
22 "sort"
23 "strconv"
24 "strings"
25 "sync"
26 "time"
27
28 "github.com/bnema/zerowrap"
29 cerrdefs "github.com/containerd/errdefs"
30 "github.com/docker/go-units"
31 "github.com/moby/moby/api/pkg/stdcopy"
32 "github.com/moby/moby/api/types/container"
33 "github.com/moby/moby/api/types/mount"
34 "github.com/moby/moby/api/types/network"
35 "github.com/moby/moby/api/types/registry"
36 "github.com/moby/moby/client"
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 runtimeName string
47}
48
49var _ out.ContainerRuntime = (*Runtime)(nil)
50var _ out.VolumeArchiveExporter = (*Runtime)(nil)
51
52type pipeReadCloser struct {
53 pr *io.PipeReader
54 pw *io.PipeWriter
55 original io.Closer
56 cleanup func() error
57 once sync.Once
58}
59
60type removeOnCloseFile struct {
61 *os.File
62 path string
63}
64
65func (p *pipeReadCloser) Read(b []byte) (int, error) {
66 return p.pr.Read(b)
67}
68
69func (p *pipeReadCloser) Close() error {
70 var closeErr error
71 p.once.Do(func() {
72 if err := p.pw.CloseWithError(errors.New("reader closed")); err != nil && !errors.Is(err, io.ErrClosedPipe) {
73 closeErr = err
74 }
75 if err := p.pr.Close(); err != nil && closeErr == nil {
76 closeErr = err
77 }
78 if p.original != nil {
79 if err := p.original.Close(); err != nil && closeErr == nil {
80 closeErr = err
81 }
82 }
83 if p.cleanup != nil {
84 if err := p.cleanup(); err != nil && closeErr == nil {
85 closeErr = err
86 }
87 }
88 })
89 return closeErr
90}
91
92func (f *removeOnCloseFile) Close() error {
93 closeErr := f.File.Close()
94 removeErr := os.Remove(f.path)
95 if closeErr != nil {
96 return closeErr
97 }
98 return removeErr
99}
100
101// NewRuntime creates a new Docker runtime instance.
102func NewRuntime() (*Runtime, error) {
103 cli, err := client.New(client.FromEnv)
104 if err != nil {
105 return nil, fmt.Errorf("failed to create Docker client: %w", err)
106 }
107
108 return &Runtime{
109 client: cli,
110 }, nil
111}
112
113// NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path.
114func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {
115 host := "unix://" + socketPath
116 cli, err := client.New(client.WithHost(host))
117 if err != nil {
118 return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)
119 }
120
121 return &Runtime{client: cli, runtimeName: guessRuntimeName(socketPath)}, nil
122}
123
124// NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).
125func NewRuntimeWithClient(cli *client.Client) *Runtime {
126 return &Runtime{
127 client: cli,
128 }
129}
130
131// CreateContainer creates a new container.
132func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) {
133 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
134 zerowrap.FieldLayer: "adapter",
135 zerowrap.FieldAdapter: "docker",
136 zerowrap.FieldAction: "CreateContainer",
137 "container_name": config.Name,
138 "image": config.Image,
139 })
140 log := zerowrap.FromCtx(ctx)
141
142 exposedPorts, portBindings, err := buildPortBindings(config)
143 if err != nil {
144 return nil, err
145 }
146 binds := buildVolumeBinds(config, log)
147 mounts, err := buildBindMounts(config)
148 if err != nil {
149 return nil, err
150 }
151 // Device-bearing creates require a CDI-capable engine. The gate runs
152 // only when devices are requested: ordinary apps never pay for it
153 // and never fail it.
154 if len(config.CDIDevices) > 0 {
155 if err := r.requireCDISupport(ctx); err != nil {
156 return nil, err
157 }
158 }
159 deviceRequests := buildDeviceRequests(config)
160
161 // Create container configuration
162 containerConfig := &container.Config{
163 Image: config.Image,
164 Hostname: config.Hostname,
165 Env: config.Env,
166 ExposedPorts: exposedPorts,
167 WorkingDir: config.WorkingDir,
168 Cmd: config.Cmd,
169 Entrypoint: config.Entrypoint,
170 Labels: config.Labels,
171 User: config.User,
172 }
173
174 resources := container.Resources{
175 Memory: config.MemoryLimit,
176 NanoCPUs: config.NanoCPUs,
177 Ulimits: []*units.Ulimit{
178 {Name: "nofile", Soft: 65536, Hard: 65536},
179 },
180 DeviceRequests: deviceRequests,
181 }
182 if config.PidsLimit > 0 {
183 resources.PidsLimit = &config.PidsLimit
184 }
185 capDrop := []string{"ALL"}
186 capAdd := []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
187 if config.CapDrop != nil {
188 capDrop = config.CapDrop
189 }
190 if config.CapAdd != nil {
191 capAdd = config.CapAdd
192 }
193 hostConfig := &container.HostConfig{
194 PortBindings: portBindings,
195 AutoRemove: config.AutoRemove,
196 Binds: binds,
197 Mounts: mounts,
198 NetworkMode: container.NetworkMode(config.NetworkMode),
199 Resources: resources,
200 SecurityOpt: []string{"no-new-privileges:true"},
201 CapDrop: capDrop,
202 CapAdd: capAdd,
203 ReadonlyRootfs: config.ReadOnlyRootFS,
204 }
205 if config.RestartPolicy != "" {
206 hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)}
207 }
208
209 // Create network configuration for container
210 var networkConfig *network.NetworkingConfig
211 if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
212 networkConfig = &network.NetworkingConfig{
213 EndpointsConfig: map[string]*network.EndpointSettings{
214 config.NetworkMode: {
215 Aliases: config.Aliases,
216 },
217 },
218 }
219 if config.Hostname != "" {
220 networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
221 }
222 }
223
224 // Create the container.
225 resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
226 Config: containerConfig,
227 HostConfig: hostConfig,
228 NetworkingConfig: networkConfig,
229 Name: config.Name,
230 })
231 if err != nil {
232 return nil, log.WrapErr(err, "failed to create container")
233 }
234
235 log.Info().Str(zerowrap.FieldEntityID, resp.ID).Msg("container created")
236
237 // Inspect the created container to get full details
238 return r.InspectContainer(ctx, resp.ID)
239}
240
241func buildPortBindings(config *domain.ContainerConfig) (network.PortSet, network.PortMap, error) {
242 exposedPorts := make(network.PortSet)
243 portBindings := make(network.PortMap)
244 if err := addLegacyPortBindings(config.Ports, exposedPorts, portBindings); err != nil {
245 return nil, nil, err
246 }
247 if err := addExplicitPortBindings(config.PortPublishes, exposedPorts, portBindings); err != nil {
248 return nil, nil, err
249 }
250 return exposedPorts, portBindings, nil
251}
252
253func addLegacyPortBindings(ports []int, exposedPorts network.PortSet, portBindings network.PortMap) error {
254 for _, port := range ports {
255 containerPort, err := network.ParsePort(fmt.Sprintf("%d/tcp", port))
256 if err != nil {
257 return fmt.Errorf("invalid container port %d: %w", port, err)
258 }
259 exposedPorts[containerPort] = struct{}{}
260
261 // Bind to random available port on localhost only.
262 // SECURITY: Using 127.0.0.1 prevents direct access from the network,
263 // forcing all traffic through Gordon's reverse proxy where auth,
264 // rate limiting, and security headers are applied.
265 portBindings[containerPort] = []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "0"}}
266 }
267 return nil
268}
269
270func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts network.PortSet, portBindings network.PortMap) error {
271 for _, publish := range publishes {
272 if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP {
273 return fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol)
274 }
275 containerPort, err := network.ParsePort(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))
276 if err != nil {
277 return fmt.Errorf("invalid container port %d: %w", publish.ContainerPort, err)
278 }
279 var hostIP netip.Addr
280 if publish.HostIP != "" {
281 hostIP, err = netip.ParseAddr(publish.HostIP)
282 if err != nil {
283 return fmt.Errorf("invalid host IP %q: %w", publish.HostIP, err)
284 }
285 }
286 exposedPorts[containerPort] = struct{}{}
287 portBindings[containerPort] = append(portBindings[containerPort], network.PortBinding{
288 HostIP: hostIP,
289 HostPort: strconv.Itoa(publish.HostPort),
290 })
291 }
292 return nil
293}
294
295func buildVolumeBinds(config *domain.ContainerConfig, log zerowrap.Logger) []string {
296 binds := make([]string, 0, len(config.Volumes)+len(config.ReadOnlyVolumes))
297 for containerPath, volumeName := range config.Volumes {
298 bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
299 binds = append(binds, bind)
300 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
301 }
302 for containerPath, volumeName := range config.ReadOnlyVolumes {
303 bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath)
304 binds = append(binds, bind)
305 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount")
306 }
307 return binds
308}
309
310// buildBindMounts validates ephemeral host binds defensively and translates
311// them into Docker bind mounts. The result is sorted by destination so the
312// create request is deterministic. Host source paths are never logged or
313// echoed in errors.
314func buildBindMounts(config *domain.ContainerConfig) ([]mount.Mount, error) {
315 if len(config.Binds) == 0 {
316 return nil, nil
317 }
318 mounts := make([]mount.Mount, 0, len(config.Binds))
319 seen := make(map[string]string, len(config.Binds))
320 for _, bind := range config.Binds {
321 if bind.Source == "" || !filepath.IsAbs(bind.Source) || filepath.Clean(bind.Source) != bind.Source {
322 return nil, fmt.Errorf("invalid bind %q: source must be an absolute clean path", bind.Name)
323 }
324 if err := domain.ValidateBindDestination(bind.Destination); err != nil {
325 return nil, fmt.Errorf("invalid bind %q: %w", bind.Name, err)
326 }
327 if prev, ok := seen[bind.Destination]; ok {
328 return nil, fmt.Errorf("invalid bind %q: destination %q already used by bind %q", bind.Name, bind.Destination, prev)
329 }
330 seen[bind.Destination] = bind.Name
331 mounts = append(mounts, mount.Mount{
332 Type: mount.TypeBind,
333 Source: bind.Source,
334 Target: bind.Destination,
335 ReadOnly: bind.ReadOnly,
336 })
337 }
338 sort.SliceStable(mounts, func(i, j int) bool {
339 if mounts[i].Target != mounts[j].Target {
340 return mounts[i].Target < mounts[j].Target
341 }
342 return mounts[i].Source < mounts[j].Source
343 })
344 return mounts, nil
345}
346
347// requireCDISupport fails closed when the connected engine cannot serve
348// native CDI device requests. Family and version come from the daemon's
349// own /version response; Gordon requires Podman 5.4+ or Docker 28.3+ for
350// device-bearing creates. The version is a capability gate, not hardware
351// proof: CDI specs, toolkit, and node permissions must still be correct
352// on the host.
353func (r *Runtime) requireCDISupport(ctx context.Context) error {
354 version, err := r.client.ServerVersion(ctx, client.ServerVersionOptions{})
355 if err != nil {
356 return engineProbeError(ctx, err)
357 }
358 if err := checkCDISupport(version, r.runtimeName); err != nil {
359 return err
360 }
361 return nil
362}
363
364// engineProbeError maps a failed /version probe onto the CDI capability
365// gate. Cancellation and deadlines survive so callers can still tell an
366// aborted probe from an incapable engine; every other cause collapses to
367// ErrRuntimeUnsupported with the cause text dropped, because transport
368// errors embed the daemon endpoint and the operator's home path.
369func engineProbeError(ctx context.Context, err error) error {
370 var ctxErr error
371 switch {
372 case errors.Is(err, context.Canceled), errors.Is(ctx.Err(), context.Canceled):
373 ctxErr = context.Canceled
374 case errors.Is(err, context.DeadlineExceeded), errors.Is(ctx.Err(), context.DeadlineExceeded):
375 ctxErr = context.DeadlineExceeded
376 }
377 if ctxErr != nil {
378 return fmt.Errorf("%w: cannot verify engine version: %w", domain.ErrRuntimeUnsupported, ctxErr)
379 }
380 return fmt.Errorf("%w: cannot verify engine version", domain.ErrRuntimeUnsupported)
381}
382
383// SupportsCDIDevices implements out.ContainerRuntime. Deployment calls it
384// in preflight for device-bearing revisions so an unsupported engine
385// fails before any workload mutation; CreateContainer keeps the same gate
386// as defense in depth.
387func (r *Runtime) SupportsCDIDevices(ctx context.Context) error {
388 return r.requireCDISupport(ctx)
389}
390
391// checkCDISupport verifies one daemon version response against the CDI
392// support matrix. Family detection prefers the daemon's own components
393// (Podman Engine vs Engine) and falls back to the socket-path hint only
394// when the response names nothing recognizable.
395func checkCDISupport(version client.ServerVersionResult, socketHint string) error {
396 family, daemonVersion := cdiEngineIdentity(version, socketHint)
397 var minimum string
398 switch family {
399 case "podman":
400 minimum = "5.4"
401 case "docker":
402 minimum = "28.3"
403 default:
404 return fmt.Errorf("%w: unrecognized engine %q version %q: device requests require Podman 5.4+ or Docker 28.3+ with native CDI configured", domain.ErrRuntimeUnsupported, family, daemonVersion)
405 }
406 if compareEngineVersion(daemonVersion, minimum) < 0 {
407 return fmt.Errorf("%w: %s %s below minimum %s: device requests require Podman 5.4+ or Docker 28.3+ with native CDI configured (check engine upgrade, CDI specs, toolkit, and device permissions)", domain.ErrRuntimeUnsupported, family, daemonVersion, minimum)
408 }
409 return nil
410}
411
412// cdiEngineIdentity names the daemon family and its version from the
413// /version response. Podman answers with a "Podman Engine" component;
414// Docker answers with an "Engine" component and its version at top
415// level. Unknown responses keep the raw version with an "unknown"
416// family so the caller fails closed.
417func cdiEngineIdentity(version client.ServerVersionResult, socketHint string) (family, daemonVersion string) {
418 for _, component := range version.Components {
419 switch component.Name {
420 case "Podman Engine":
421 return "podman", component.Version
422 case "Engine":
423 return "docker", component.Version
424 }
425 }
426 if strings.Contains(strings.ToLower(version.Platform.Name), "podman") {
427 return "podman", version.Version
428 }
429 switch socketHint {
430 case "podman", "docker":
431 return socketHint, version.Version
432 }
433 return "unknown", version.Version
434}
435
436// compareEngineVersion compares dotted major.minor versions. A missing
437// or unparsable version compares below any minimum: the gate fails
438// closed rather than trusting an engine it cannot identify.
439func compareEngineVersion(version, minimum string) int {
440 parse := func(value string) (int, int) {
441 parts := strings.SplitN(value, ".", 3)
442 if len(parts) < 2 {
443 return -1, -1
444 }
445 major, ok := parseEngineVersionComponent(parts[0])
446 if !ok {
447 return -1, -1
448 }
449 minor, ok := parseEngineVersionComponent(parts[1])
450 if !ok {
451 return -1, -1
452 }
453 return major, minor
454 }
455 major, minor := parse(version)
456 minMajor, minMinor := parse(minimum)
457 switch {
458 case major != minMajor:
459 return major - minMajor
460 default:
461 return minor - minMinor
462 }
463}
464
465// parseEngineVersionComponent parses one numeric major/minor component.
466// Anything but digits is refused, so prerelease suffixes such as "-rc1"
467// and malformed versions can never lift an engine above the gate.
468func parseEngineVersionComponent(value string) (int, bool) {
469 value = strings.TrimSpace(value)
470 if value == "" {
471 return 0, false
472 }
473 for _, r := range value {
474 if r < '0' || r > '9' {
475 return 0, false
476 }
477 }
478 n, err := strconv.Atoi(value)
479 if err != nil {
480 return 0, false
481 }
482 return n, true
483}
484
485// buildDeviceRequests encodes ephemeral CDI device IDs as one native CDI
486// DeviceRequest. Capabilities and Options stay empty; Count stays 0 so the
487// explicit DeviceIDs are the only grant. CDI IDs are never logged or echoed
488// in errors: they are host inventory.
489func buildDeviceRequests(config *domain.ContainerConfig) []container.DeviceRequest {
490 if len(config.CDIDevices) == 0 {
491 return nil
492 }
493 ids := append([]string(nil), config.CDIDevices...)
494 sort.Strings(ids)
495 return []container.DeviceRequest{
496 {
497 Driver: "cdi",
498 DeviceIDs: ids,
499 },
500 }
501}
502
503// StartContainer starts a container.
504func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {
505 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
506 zerowrap.FieldLayer: "adapter",
507 zerowrap.FieldAdapter: "docker",
508 zerowrap.FieldAction: "StartContainer",
509 zerowrap.FieldEntityID: containerID,
510 })
511 log := zerowrap.FromCtx(ctx)
512
513 _, err := r.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{})
514 if err != nil {
515 if cerrdefs.IsNotFound(err) {
516 return log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to start container")
517 }
518 return log.WrapErr(err, "failed to start container")
519 }
520
521 log.Info().Msg("container started")
522 return nil
523}
524
525// WaitForContainer waits for a container to stop running.
526func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error {
527 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
528 zerowrap.FieldLayer: "adapter",
529 zerowrap.FieldAdapter: "docker",
530 zerowrap.FieldAction: "WaitForContainer",
531 zerowrap.FieldEntityID: containerID,
532 })
533 log := zerowrap.FromCtx(ctx)
534
535 waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
536 statusCh, errCh := waitResult.Result, waitResult.Error
537 select {
538 case err := <-errCh:
539 if err != nil {
540 return log.WrapErr(err, "failed to wait for container")
541 }
542 case <-statusCh:
543 }
544
545 log.Debug().Msg("container wait completed")
546 return nil
547}
548
549// StopContainer stops a container, giving it grace to exit before the
550// runtime kills it. A non-positive grace keeps the runtime default.
551func (r *Runtime) StopContainer(ctx context.Context, containerID string, grace time.Duration) error {
552 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
553 zerowrap.FieldLayer: "adapter",
554 zerowrap.FieldAdapter: "docker",
555 zerowrap.FieldAction: "StopContainer",
556 zerowrap.FieldEntityID: containerID,
557 })
558 log := zerowrap.FromCtx(ctx)
559
560 _, err := r.client.ContainerStop(ctx, containerID, client.ContainerStopOptions{Timeout: stopTimeout(grace)})
561 if err != nil {
562 if cerrdefs.IsNotFound(err) {
563 return log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to stop container")
564 }
565 return log.WrapErr(err, "failed to stop container")
566 }
567
568 log.Info().Msg("container stopped")
569 return nil
570}
571
572// RestartContainer restarts a container, giving it grace to exit before
573// the runtime kills it. A non-positive grace keeps the runtime default.
574func (r *Runtime) RestartContainer(ctx context.Context, containerID string, grace time.Duration) error {
575 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
576 zerowrap.FieldLayer: "adapter",
577 zerowrap.FieldAdapter: "docker",
578 zerowrap.FieldAction: "RestartContainer",
579 zerowrap.FieldEntityID: containerID,
580 })
581 log := zerowrap.FromCtx(ctx)
582
583 _, err := r.client.ContainerRestart(ctx, containerID, client.ContainerRestartOptions{Timeout: stopTimeout(grace)})
584 if err != nil {
585 if cerrdefs.IsNotFound(err) {
586 return log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to restart container")
587 }
588 return log.WrapErr(err, "failed to restart container")
589 }
590
591 log.Info().Msg("container restarted")
592 return nil
593}
594
595// stopTimeout converts a stop grace into the runtime API's whole-second
596// timeout. A non-positive grace returns nil, which keeps the runtime's
597// own default instead of an immediate kill. A fractional grace rounds up
598// so the container never receives less time than the spec asked for.
599func stopTimeout(grace time.Duration) *int {
600 if grace <= 0 {
601 return nil
602 }
603 seconds := int(math.Ceil(grace.Seconds()))
604 if seconds < 1 {
605 seconds = 1
606 }
607 return &seconds
608}
609
610// RemoveContainer removes a container.
611func (r *Runtime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
612 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
613 zerowrap.FieldLayer: "adapter",
614 zerowrap.FieldAdapter: "docker",
615 zerowrap.FieldAction: "RemoveContainer",
616 zerowrap.FieldEntityID: containerID,
617 "force": force,
618 })
619 log := zerowrap.FromCtx(ctx)
620
621 _, err := r.client.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{Force: force})
622 if err != nil {
623 if cerrdefs.IsNotFound(err) {
624 log.Debug().Msg("container not found, already removed")
625 return nil
626 }
627 return log.WrapErr(err, "failed to remove container")
628 }
629
630 log.Info().Msg("container removed")
631 return nil
632}
633
634// RenameContainer renames a container.
635func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName string) error {
636 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
637 zerowrap.FieldLayer: "adapter",
638 zerowrap.FieldAdapter: "docker",
639 zerowrap.FieldAction: "RenameContainer",
640 zerowrap.FieldEntityID: containerID,
641 "new_name": newName,
642 })
643 log := zerowrap.FromCtx(ctx)
644
645 _, err := r.client.ContainerRename(ctx, containerID, client.ContainerRenameOptions{NewName: newName})
646 if err != nil {
647 return log.WrapErr(err, "failed to rename container")
648 }
649
650 log.Info().Msg("container renamed")
651 return nil
652}
653
654// ListContainers lists containers.
655func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {
656 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
657 zerowrap.FieldLayer: "adapter",
658 zerowrap.FieldAdapter: "docker",
659 zerowrap.FieldAction: "ListContainers",
660 "all": all,
661 })
662 log := zerowrap.FromCtx(ctx)
663
664 containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: all})
665 if err != nil {
666 return nil, log.WrapErr(err, "failed to list containers")
667 }
668
669 var result []*domain.Container
670 for _, c := range containerList.Items {
671 // Extract ports
672 var ports []int
673 for _, port := range c.Ports {
674 if port.PublicPort > 0 {
675 ports = append(ports, int(port.PublicPort))
676 }
677 }
678
679 // Get the primary name (remove leading slash)
680 name := ""
681 if len(c.Names) > 0 {
682 name = strings.TrimPrefix(c.Names[0], "/")
683 }
684
685 volumeMounts := make([]domain.ContainerVolumeMount, 0, len(c.Mounts))
686 for _, m := range c.Mounts {
687 volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
688 Name: m.Name,
689 Type: string(m.Type),
690 Source: m.Source,
691 Destination: m.Destination,
692 ReadOnly: !m.RW,
693 })
694 }
695
696 result = append(result, &domain.Container{
697 ID: c.ID,
698 Image: c.Image,
699 ImageID: c.ImageID,
700 Name: name,
701 Status: string(c.State), // Use State (e.g., "running") not Status (e.g., "Up 2 days")
702 Ports: ports,
703 Labels: c.Labels,
704 VolumeMounts: volumeMounts,
705 Created: time.Unix(c.Created, 0),
706 })
707 }
708
709 return result, nil
710}
711
712// InspectContainer inspects a container.
713func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*domain.Container, error) {
714 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
715 zerowrap.FieldLayer: "adapter",
716 zerowrap.FieldAdapter: "docker",
717 zerowrap.FieldAction: "InspectContainer",
718 zerowrap.FieldEntityID: containerID,
719 })
720 log := zerowrap.FromCtx(ctx)
721
722 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
723 if err != nil {
724 if cerrdefs.IsNotFound(err) {
725 return nil, log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to inspect container")
726 }
727 return nil, log.WrapErr(err, "failed to inspect container")
728 }
729 resp := inspectResult.Container
730
731 // Extract published ports
732 var ports []int
733 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
734 for _, bindings := range resp.NetworkSettings.Ports {
735 for _, binding := range bindings {
736 if binding.HostPort != "" {
737 if port, err := strconv.Atoi(binding.HostPort); err == nil {
738 ports = append(ports, port)
739 }
740 }
741 }
742 }
743 }
744
745 // Get container name (remove leading slash)
746 name := strings.TrimPrefix(resp.Name, "/")
747
748 created, _ := time.Parse(time.RFC3339Nano, resp.Created)
749 // StartedAt scopes log readiness to the current execution: markers
750 // from a previous execution of the same container ID must not
751 // satisfy a probe. An unparsable value stays zero and is rejected by
752 // the readiness probe rather than silently widening the scope.
753 startedAt, _ := time.Parse(time.RFC3339Nano, resp.State.StartedAt)
754 if startedAt.Year() <= 1 {
755 startedAt = time.Time{}
756 }
757 volumeMounts := make([]domain.ContainerVolumeMount, 0, len(resp.Mounts))
758 for _, m := range resp.Mounts {
759 volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
760 Name: m.Name,
761 Type: string(m.Type),
762 Source: m.Source,
763 Destination: m.Destination,
764 ReadOnly: !m.RW,
765 })
766 }
767
768 return &domain.Container{
769 ID: resp.ID,
770 Image: resp.Config.Image,
771 ImageID: resp.Image,
772 Name: name,
773 Status: string(resp.State.Status),
774 ExitCode: resp.State.ExitCode,
775 Ports: ports,
776 Labels: resp.Config.Labels,
777 VolumeMounts: volumeMounts,
778 Created: created,
779 StartedAt: startedAt,
780 Env: resp.Config.Env,
781 }, nil
782}
783
784// GetContainerLogsSince gets container logs emitted at or after since.
785// Readiness uses it with the observed execution start so markers from a
786// previous execution of the same container ID cannot match.
787func (r *Runtime) GetContainerLogsSince(ctx context.Context, containerID string, since time.Time, follow bool) (io.ReadCloser, error) {
788 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
789 zerowrap.FieldLayer: "adapter",
790 zerowrap.FieldAdapter: "docker",
791 zerowrap.FieldAction: "GetContainerLogsSince",
792 zerowrap.FieldEntityID: containerID,
793 })
794 log := zerowrap.FromCtx(ctx)
795
796 options := client.ContainerLogsOptions{
797 ShowStdout: true,
798 ShowStderr: true,
799 Follow: follow,
800 Timestamps: true,
801 Tail: "10000",
802 }
803 if !since.IsZero() {
804 // RFC3339Nano keeps the sub-second part of the execution start:
805 // a marker emitted in the same second as a previous execution
806 // must not satisfy the probe.
807 options.Since = since.UTC().Format(time.RFC3339Nano)
808 }
809 logs, err := r.client.ContainerLogs(ctx, containerID, options)
810 if err != nil {
811 if cerrdefs.IsNotFound(err) {
812 return nil, log.WrapErr(fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID), "failed to get container logs")
813 }
814 return nil, log.WrapErr(err, "failed to get container logs")
815 }
816
817 return logs, nil
818}
819
820// GetContainerLogs gets container logs.
821func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
822 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
823 zerowrap.FieldLayer: "adapter",
824 zerowrap.FieldAdapter: "docker",
825 zerowrap.FieldAction: "GetContainerLogs",
826 zerowrap.FieldEntityID: containerID,
827 })
828 log := zerowrap.FromCtx(ctx)
829
830 logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{
831 ShowStdout: true,
832 ShowStderr: true,
833 Follow: follow,
834 Timestamps: true,
835 Tail: "10000",
836 })
837 if err != nil {
838 return nil, log.WrapErr(err, "failed to get container logs")
839 }
840
841 return logs, nil
842}
843
844func (r *Runtime) PullImageWithOptions(ctx context.Context, request domain.ImagePullRequest) error {
845 if request.Transport == domain.ImagePullTransportHTTP {
846 if r.runtimeName == "podman" || strings.Contains(strings.ToLower(r.client.DaemonHost()), "podman") {
847 return r.pullPodmanHTTP(ctx, request)
848 }
849 // Docker has no per-pull HTTP switch. Its daemon honors HTTP only for
850 // endpoints configured in insecure-registries; retain the compatible
851 // pull API and let a missing daemon policy fail explicitly.
852 if request.Username != "" || request.Password != "" {
853 return r.PullImageWithAuth(ctx, request.Reference, request.Username, request.Password)
854 }
855 return r.PullImage(ctx, request.Reference)
856 }
857 if request.Username != "" || request.Password != "" {
858 return r.PullImageWithAuth(ctx, request.Reference, request.Username, request.Password)
859 }
860 return r.PullImage(ctx, request.Reference)
861}
862
863func (r *Runtime) pullPodmanHTTP(ctx context.Context, request domain.ImagePullRequest) error {
864 host := r.client.DaemonHost()
865 if !strings.HasPrefix(host, "unix://") {
866 return fmt.Errorf("unsupported Podman endpoint %q for HTTP pull", host)
867 }
868 httpClient := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
869 return (&net.Dialer{}).DialContext(ctx, "unix", strings.TrimPrefix(host, "unix://"))
870 }}}
871 query := url.Values{"reference": {request.Reference}, "tlsVerify": {"false"}, "quiet": {"false"}}
872 req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://podman/v5.0.0/libpod/images/pull?"+query.Encode(), nil)
873 if err != nil {
874 return err
875 }
876 if request.Username != "" || request.Password != "" {
877 auth, err := json.Marshal(registry.AuthConfig{Username: request.Username, Password: request.Password})
878 if err != nil {
879 return err
880 }
881 req.Header.Set(registry.AuthHeader, base64.StdEncoding.EncodeToString(auth))
882 }
883 resp, err := httpClient.Do(req)
884 if err != nil {
885 return fmt.Errorf("podman HTTP image pull: %w", err)
886 }
887 defer resp.Body.Close()
888 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
889 body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
890 return fmt.Errorf("podman HTTP image pull returned %s: %s: %w", resp.Status, strings.TrimSpace(string(body)), domain.ErrImagePullFailed)
891 }
892 decoder := json.NewDecoder(resp.Body)
893 for {
894 var event struct {
895 Error string `json:"error"`
896 }
897 if err := decoder.Decode(&event); errors.Is(err, io.EOF) {
898 break
899 } else if err != nil {
900 return fmt.Errorf("decode Podman pull response: %w", err)
901 } else if event.Error != "" {
902 return fmt.Errorf("podman image pull: %s: %w", event.Error, domain.ErrImagePullFailed)
903 }
904 }
905 return nil
906}
907
908// PullImage pulls an image.
909func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
910 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
911 zerowrap.FieldLayer: "adapter",
912 zerowrap.FieldAdapter: "docker",
913 zerowrap.FieldAction: "PullImage",
914 "image": imageRef,
915 })
916 log := zerowrap.FromCtx(ctx)
917
918 log.Info().Msg("pulling image")
919
920 reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{})
921 if err != nil {
922 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
923 }
924 defer reader.Close()
925
926 if err := reader.Wait(ctx); err != nil {
927 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete image pull")
928 }
929
930 log.Info().Msg("image pulled successfully")
931 return nil
932}
933
934// PullImageWithAuth pulls an image with authentication.
935func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
936 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
937 zerowrap.FieldLayer: "adapter",
938 zerowrap.FieldAdapter: "docker",
939 zerowrap.FieldAction: "PullImageWithAuth",
940 "image": imageRef,
941 "username": username,
942 })
943 log := zerowrap.FromCtx(ctx)
944
945 log.Info().Msg("pulling image with authentication")
946
947 // Extract registry server address from image reference
948 // e.g., "registry.bnema.dev/image:tag" -> "registry.bnema.dev"
949 serverAddress := imageRef
950 if idx := strings.Index(imageRef, "/"); idx > 0 {
951 serverAddress = imageRef[:idx]
952 }
953
954 // Create authentication configuration
955 authConfig := registry.AuthConfig{
956 Username: username,
957 Password: password,
958 ServerAddress: serverAddress,
959 }
960
961 // Encode authentication to base64 JSON
962 // Note: Docker API accepts both StdEncoding and URLEncoding, but Podman
963 // may be more strict. Using StdEncoding for maximum compatibility.
964 authConfigBytes, err := json.Marshal(authConfig)
965 if err != nil {
966 return log.WrapErr(err, "failed to marshal auth config")
967 }
968 authStr := base64.StdEncoding.EncodeToString(authConfigBytes)
969
970 log.Debug().
971 Str("server_address", serverAddress).
972 Bool("has_auth", username != "" || password != "").
973 Msg("auth config for pull")
974
975 // Pull with authentication
976 reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{
977 RegistryAuth: authStr,
978 })
979 if err != nil {
980 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth")
981 }
982 defer reader.Close()
983
984 if err := reader.Wait(ctx); err != nil {
985 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete authenticated image pull")
986 }
987
988 log.Info().Msg("image pulled successfully with authentication")
989 return nil
990}
991
992// TagImage tags a locally available image with a new reference.
993func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) error {
994 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
995 zerowrap.FieldLayer: "adapter",
996 zerowrap.FieldAdapter: "docker",
997 zerowrap.FieldAction: "TagImage",
998 "source": sourceRef,
999 "target": targetRef,
1000 })
1001 log := zerowrap.FromCtx(ctx)
1002
1003 if _, err := r.client.ImageTag(ctx, client.ImageTagOptions{Source: sourceRef, Target: targetRef}); err != nil {
1004 return log.WrapErr(err, "failed to tag image")
1005 }
1006
1007 log.Info().Msg("image tagged successfully")
1008 return nil
1009}
1010
1011// UntagImage removes a tag from an image without deleting the underlying image layers.
1012func (r *Runtime) UntagImage(ctx context.Context, imageRef string) error {
1013 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1014 zerowrap.FieldLayer: "adapter",
1015 zerowrap.FieldAdapter: "docker",
1016 zerowrap.FieldAction: "UntagImage",
1017 "image": imageRef,
1018 })
1019 log := zerowrap.FromCtx(ctx)
1020
1021 // ImageRemove with PruneChildren=false only removes the tag, not the image layers
1022 _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{
1023 Force: false,
1024 PruneChildren: false,
1025 })
1026 if err != nil {
1027 return log.WrapErr(err, "failed to untag image")
1028 }
1029
1030 log.Debug().Msg("image untagged successfully")
1031 return nil
1032}
1033
1034// RemoveImage removes an image.
1035func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
1036 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1037 zerowrap.FieldLayer: "adapter",
1038 zerowrap.FieldAdapter: "docker",
1039 zerowrap.FieldAction: "RemoveImage",
1040 "image": imageRef,
1041 "force": force,
1042 })
1043 log := zerowrap.FromCtx(ctx)
1044
1045 _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{Force: force})
1046 if err != nil {
1047 return log.WrapErr(err, "failed to remove image")
1048 }
1049
1050 log.Info().Msg("image removed")
1051 return nil
1052}
1053
1054// ListImages lists images.
1055func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
1056 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1057 zerowrap.FieldLayer: "adapter",
1058 zerowrap.FieldAdapter: "docker",
1059 zerowrap.FieldAction: "ListImages",
1060 })
1061 log := zerowrap.FromCtx(ctx)
1062
1063 imageList, err := r.client.ImageList(ctx, client.ImageListOptions{})
1064 if err != nil {
1065 return nil, log.WrapErr(err, "failed to list images")
1066 }
1067
1068 var result []string
1069 for _, img := range imageList.Items {
1070 for _, tag := range img.RepoTags {
1071 if tag != "<none>:<none>" {
1072 result = append(result, tag)
1073 }
1074 }
1075 }
1076
1077 return result, nil
1078}
1079
1080// ListImagesDetailed lists images with metadata.
1081func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDetail, error) {
1082 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1083 zerowrap.FieldLayer: "adapter",
1084 zerowrap.FieldAdapter: "docker",
1085 zerowrap.FieldAction: "ListImagesDetailed",
1086 })
1087 log := zerowrap.FromCtx(ctx)
1088
1089 imageList, err := r.client.ImageList(ctx, client.ImageListOptions{All: true})
1090 if err != nil {
1091 return nil, log.WrapErr(err, "failed to list detailed images")
1092 }
1093
1094 result := make([]runtimepkg.ImageDetail, 0, len(imageList.Items))
1095 for _, img := range imageList.Items {
1096 result = append(result, runtimepkg.ImageDetail{
1097 ID: img.ID,
1098 RepoTags: img.RepoTags,
1099 Size: img.Size,
1100 Created: time.Unix(img.Created, 0),
1101 })
1102 }
1103
1104 return result, nil
1105}
1106
1107// Ping checks if Docker is responsive.
1108func (r *Runtime) Ping(ctx context.Context) error {
1109 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1110 zerowrap.FieldLayer: "adapter",
1111 zerowrap.FieldAdapter: "docker",
1112 zerowrap.FieldAction: "Ping",
1113 })
1114 log := zerowrap.FromCtx(ctx)
1115
1116 _, err := r.client.Ping(ctx, client.PingOptions{})
1117 if err != nil {
1118 return log.WrapErr(err, "Docker ping failed")
1119 }
1120 return nil
1121}
1122
1123// Version returns Docker version.
1124func (r *Runtime) Version(ctx context.Context) (string, error) {
1125 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1126 zerowrap.FieldLayer: "adapter",
1127 zerowrap.FieldAdapter: "docker",
1128 zerowrap.FieldAction: "Version",
1129 })
1130 log := zerowrap.FromCtx(ctx)
1131
1132 version, err := r.client.ServerVersion(ctx, client.ServerVersionOptions{})
1133 if err != nil {
1134 return "", log.WrapErr(err, "failed to get Docker version")
1135 }
1136 return version.Version, nil
1137}
1138
1139// IsContainerRunning checks if a container is running.
1140func (r *Runtime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
1141 ctr, err := r.InspectContainer(ctx, containerID)
1142 if err != nil {
1143 return false, err
1144 }
1145 return ctr.Status == string(domain.ContainerStatusRunning), nil
1146}
1147
1148// GetContainerHealthStatus returns Docker healthcheck status when available.
1149func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) {
1150 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1151 zerowrap.FieldLayer: "adapter",
1152 zerowrap.FieldAdapter: "docker",
1153 zerowrap.FieldAction: "GetContainerHealthStatus",
1154 zerowrap.FieldEntityID: containerID,
1155 })
1156 log := zerowrap.FromCtx(ctx)
1157
1158 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1159 if err != nil {
1160 return "", false, log.WrapErr(err, "failed to inspect container")
1161 }
1162 resp := inspectResult.Container
1163
1164 // Detect configured healthchecks from container config rather than relying on
1165 // State.Health presence. Some runtimes can expose an empty State.Health struct
1166 // even when no healthcheck is configured.
1167 hasHealthcheck := hasConfiguredHealthcheck(resp.Config)
1168 if !hasHealthcheck {
1169 return "", false, nil
1170 }
1171 if resp.State == nil || resp.State.Health == nil {
1172 return "", true, nil
1173 }
1174
1175 return string(resp.State.Health.Status), true, nil
1176}
1177
1178func hasConfiguredHealthcheck(cfg *container.Config) bool {
1179 if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 {
1180 return false
1181 }
1182 // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images.
1183 return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE")
1184}
1185
1186// GetContainerBackendBinds resolves protocol-specific container ports to
1187// their host binds in a single container inspection.
1188func (r *Runtime) GetContainerBackendBinds(ctx context.Context, containerID string, ports []domain.ContainerBackendPort) ([]domain.ContainerBackendBind, error) {
1189 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1190 zerowrap.FieldLayer: "adapter",
1191 zerowrap.FieldAdapter: "docker",
1192 zerowrap.FieldAction: "GetContainerBackendBinds",
1193 zerowrap.FieldEntityID: containerID,
1194 "ports": len(ports),
1195 })
1196 log := zerowrap.FromCtx(ctx)
1197
1198 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1199 if err != nil {
1200 return nil, log.WrapErr(err, "failed to inspect container")
1201 }
1202 resp := inspectResult.Container
1203
1204 if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
1205 return nil, fmt.Errorf("no port mappings found for container %s", containerID)
1206 }
1207
1208 binds := make([]domain.ContainerBackendBind, 0, len(ports))
1209 for _, port := range ports {
1210 containerPort, err := network.ParsePort(fmt.Sprintf("%d/%s", port.ContainerPort, port.Protocol))
1211 if err != nil {
1212 return nil, fmt.Errorf("invalid container port %d: %w", port.ContainerPort, err)
1213 }
1214 bindings, exists := resp.NetworkSettings.Ports[containerPort]
1215 if !exists || len(bindings) == 0 {
1216 return nil, fmt.Errorf("port %d/%s not mapped for container %s", port.ContainerPort, port.Protocol, containerID)
1217 }
1218 // Exactly one loopback binding is required: a wildcard, multiple,
1219 // or non-loopback mapping would expose the backend beyond the
1220 // loopback contract the proxy and readiness rely on.
1221 if len(bindings) != 1 {
1222 return nil, fmt.Errorf("port %d/%s has %d host bindings for container %s; exactly one loopback binding is required", port.ContainerPort, port.Protocol, len(bindings), containerID)
1223 }
1224 binding := bindings[0]
1225 if binding.HostIP != netip.MustParseAddr("127.0.0.1") {
1226 return nil, fmt.Errorf("port %d/%s is published on host IP %q for container %s; loopback is required", port.ContainerPort, port.Protocol, binding.HostIP, containerID)
1227 }
1228 hostPort, err := strconv.Atoi(binding.HostPort)
1229 if err != nil || hostPort < 1 || hostPort > 65535 {
1230 return nil, fmt.Errorf("invalid host port %q for container %s", binding.HostPort, containerID)
1231 }
1232 binds = append(binds, domain.ContainerBackendBind{
1233 ContainerPort: port.ContainerPort,
1234 HostPort: hostPort,
1235 Protocol: port.Protocol,
1236 })
1237 }
1238
1239 return binds, nil
1240}
1241
1242// GetImageExposedPorts gets the exposed ports from an image.
1243func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
1244 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1245 zerowrap.FieldLayer: "adapter",
1246 zerowrap.FieldAdapter: "docker",
1247 zerowrap.FieldAction: "GetImageExposedPorts",
1248 "image": imageRef,
1249 })
1250 log := zerowrap.FromCtx(ctx)
1251
1252 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1253 if err != nil {
1254 return nil, log.WrapErr(err, "failed to inspect image")
1255 }
1256
1257 var ports []int
1258 if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
1259 for portSpec := range imageInspect.Config.ExposedPorts {
1260 // Parse port from format like "80/tcp"
1261 portStr := strings.Split(portSpec, "/")[0]
1262 if port, err := strconv.Atoi(portStr); err == nil {
1263 ports = append(ports, port)
1264 }
1265 }
1266 }
1267
1268 if len(ports) == 0 {
1269 return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
1270 }
1271
1272 // Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.)
1273 // Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending
1274 sortPortsHTTPFirst(ports)
1275
1276 log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")
1277 return ports, nil
1278}
1279
1280// sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22).
1281func sortPortsHTTPFirst(ports []int) {
1282 priority := map[int]int{
1283 80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6,
1284 }
1285 sort.Slice(ports, func(i, j int) bool {
1286 pi, oki := priority[ports[i]]
1287 pj, okj := priority[ports[j]]
1288 if oki && okj {
1289 return pi < pj
1290 }
1291 if oki {
1292 return true
1293 }
1294 if okj {
1295 return false
1296 }
1297 // Neither is a priority port - sort ascending but push 22 to the end
1298 if ports[i] == 22 {
1299 return false
1300 }
1301 if ports[j] == 22 {
1302 return true
1303 }
1304 return ports[i] < ports[j]
1305 })
1306}
1307
1308// GetContainerExposedPorts gets all exposed ports from a running container.
1309func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
1310 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1311 zerowrap.FieldLayer: "adapter",
1312 zerowrap.FieldAdapter: "docker",
1313 zerowrap.FieldAction: "GetContainerExposedPorts",
1314 zerowrap.FieldEntityID: containerID,
1315 })
1316 log := zerowrap.FromCtx(ctx)
1317
1318 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1319 if err != nil {
1320 return nil, log.WrapErr(err, "failed to inspect container")
1321 }
1322 resp := inspectResult.Container
1323
1324 var ports []int
1325 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
1326 for portSpec := range resp.NetworkSettings.Ports {
1327 // Parse port from format like "80/tcp".
1328 portStr := portSpec.Port()
1329 if port, err := strconv.Atoi(portStr); err == nil {
1330 ports = append(ports, port)
1331 }
1332 }
1333 }
1334
1335 return ports, nil
1336}
1337
1338// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use.
1339func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
1340 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1341 zerowrap.FieldLayer: "adapter",
1342 zerowrap.FieldAdapter: "docker",
1343 zerowrap.FieldAction: "GetContainerNetworkInfo",
1344 zerowrap.FieldEntityID: containerID,
1345 })
1346 log := zerowrap.FromCtx(ctx)
1347
1348 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1349 if err != nil {
1350 return "", 0, log.WrapErr(err, "failed to inspect container")
1351 }
1352 resp := inspectResult.Container
1353
1354 // Get container's internal IP address
1355 var containerIP string
1356 if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
1357 // Use the first available network (usually bridge or custom network)
1358 for _, net := range resp.NetworkSettings.Networks {
1359 if net.IPAddress.IsValid() {
1360 containerIP = net.IPAddress.String()
1361 break
1362 }
1363 }
1364 }
1365
1366 if containerIP == "" {
1367 return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
1368 }
1369
1370 // Get exposed ports from container
1371 var exposedPorts []int
1372 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
1373 for portSpec := range resp.NetworkSettings.Ports {
1374 // Parse port from format like "80/tcp".
1375 if port, err := strconv.Atoi(portSpec.Port()); err == nil {
1376 exposedPorts = append(exposedPorts, port)
1377 }
1378 }
1379 }
1380
1381 if len(exposedPorts) == 0 {
1382 return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
1383 }
1384
1385 // Sort ports with HTTP-friendly ports first, pushing SSH (22) to the end
1386 sortPortsHTTPFirst(exposedPorts)
1387 selectedPort := exposedPorts[0]
1388
1389 log.Info().
1390 Str("ip", containerIP).
1391 Int("selected_port", selectedPort).
1392 Ints("exposed_ports", exposedPorts).
1393 Msg("container network info detected")
1394
1395 return containerIP, selectedPort, nil
1396}
1397
1398// GetContainerNetwork returns the network name a container is connected to.
1399func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) {
1400 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1401 zerowrap.FieldLayer: "adapter",
1402 zerowrap.FieldAdapter: "docker",
1403 zerowrap.FieldAction: "GetContainerNetwork",
1404 zerowrap.FieldEntityID: containerID,
1405 })
1406 log := zerowrap.FromCtx(ctx)
1407
1408 inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1409 if err != nil {
1410 return "", log.WrapErr(err, "failed to inspect container")
1411 }
1412
1413 networks := inspectResult.Container.NetworkSettings
1414 if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {
1415 return "bridge", nil
1416 }
1417
1418 // Priority: gordon-* networks first, then bridge, then any other
1419 selected := ""
1420 hasBridge := false
1421 for name := range networks.Networks {
1422 if strings.HasPrefix(name, "gordon-") {
1423 selected = name
1424 break
1425 }
1426 if name == "bridge" {
1427 hasBridge = true
1428 } else if selected == "" {
1429 selected = name
1430 }
1431 }
1432
1433 // If no gordon-* network found, prefer bridge over random network
1434 if selected == "" || (hasBridge && !strings.HasPrefix(selected, "gordon-")) {
1435 if hasBridge {
1436 selected = "bridge"
1437 }
1438 }
1439
1440 if selected == "" {
1441 return "bridge", nil
1442 }
1443
1444 log.Debug().Str("network", selected).Msg("container network detected")
1445 return selected, nil
1446}
1447
1448// InspectImageVolumes gets the volume mount points declared in the image.
1449func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
1450 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1451 zerowrap.FieldLayer: "adapter",
1452 zerowrap.FieldAdapter: "docker",
1453 zerowrap.FieldAction: "InspectImageVolumes",
1454 "image": imageRef,
1455 })
1456 log := zerowrap.FromCtx(ctx)
1457
1458 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1459 if err != nil {
1460 return nil, log.WrapErr(err, "failed to inspect image")
1461 }
1462
1463 var volumes []string
1464 if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
1465 for volumePath := range imageInspect.Config.Volumes {
1466 volumes = append(volumes, volumePath)
1467 }
1468 }
1469
1470 if len(volumes) > 0 {
1471 log.Info().Strs("volumes", volumes).Msg("found VOLUME directives in image")
1472 } else {
1473 log.Debug().Msg("no VOLUME directives found in image")
1474 }
1475
1476 return volumes, nil
1477}
1478
1479// VolumeExists checks if a Docker volume exists.
1480func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
1481 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1482 zerowrap.FieldLayer: "adapter",
1483 zerowrap.FieldAdapter: "docker",
1484 zerowrap.FieldAction: "VolumeExists",
1485 "volume": volumeName,
1486 })
1487 log := zerowrap.FromCtx(ctx)
1488
1489 _, err := r.client.VolumeInspect(ctx, volumeName, client.VolumeInspectOptions{})
1490 if err != nil {
1491 if cerrdefs.IsNotFound(err) {
1492 return false, nil
1493 }
1494 return false, log.WrapErr(err, "failed to inspect volume")
1495 }
1496 return true, nil
1497}
1498
1499// CreateVolume creates a new Docker volume.
1500func (r *Runtime) CreateVolume(ctx context.Context, volumeName string, labels map[string]string) error {
1501 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1502 zerowrap.FieldLayer: "adapter",
1503 zerowrap.FieldAdapter: "docker",
1504 zerowrap.FieldAction: "CreateVolume",
1505 "volume": volumeName,
1506 })
1507 log := zerowrap.FromCtx(ctx)
1508
1509 // The managed marker and creation time are adapter-owned; every
1510 // caller-supplied label is preserved so ownership provenance is
1511 // stamped at creation, not reconstructed later.
1512 merged := make(map[string]string, len(labels)+2)
1513 maps.Copy(merged, labels)
1514 merged[domain.LabelManaged] = "true"
1515 merged[domain.LabelCreated] = time.Now().UTC().Format(time.RFC3339)
1516
1517 _, err := r.client.VolumeCreate(ctx, client.VolumeCreateOptions{
1518 Name: volumeName,
1519 Labels: merged,
1520 })
1521 if err != nil {
1522 return log.WrapErr(err, "failed to create volume")
1523 }
1524
1525 log.Info().Msg("volume created")
1526 return nil
1527}
1528
1529// RemoveVolume removes a Docker volume.
1530func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
1531 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1532 zerowrap.FieldLayer: "adapter",
1533 zerowrap.FieldAdapter: "docker",
1534 zerowrap.FieldAction: "RemoveVolume",
1535 "volume": volumeName,
1536 "force": force,
1537 })
1538 log := zerowrap.FromCtx(ctx)
1539
1540 _, err := r.client.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
1541 if err != nil {
1542 if cerrdefs.IsNotFound(err) {
1543 log.Debug().Msg("volume not found, already removed")
1544 return nil
1545 }
1546 return log.WrapErr(err, "failed to remove volume")
1547 }
1548
1549 log.Info().Msg("volume removed")
1550 return nil
1551}
1552
1553// ListVolumes returns all named volumes with their usage status.
1554func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
1555 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1556 zerowrap.FieldLayer: "adapter",
1557 zerowrap.FieldAdapter: "docker",
1558 zerowrap.FieldAction: "ListVolumes",
1559 })
1560 log := zerowrap.FromCtx(ctx)
1561
1562 volumeList, err := r.client.VolumeList(ctx, client.VolumeListOptions{})
1563 if err != nil {
1564 return nil, log.WrapErr(err, "failed to list volumes")
1565 }
1566
1567 // Get all containers to determine volume usage.
1568 containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: true})
1569 if err != nil {
1570 return nil, log.WrapErr(err, "failed to list containers for volume usage")
1571 }
1572
1573 // Build a map of volume name -> container names.
1574 volumeUsers := make(map[string][]string)
1575 for _, c := range containerList.Items {
1576 for _, m := range c.Mounts {
1577 if m.Type == "volume" {
1578 name := ""
1579 if len(c.Names) > 0 {
1580 name = strings.TrimPrefix(c.Names[0], "/")
1581 }
1582 volumeUsers[m.Name] = append(volumeUsers[m.Name], name)
1583 }
1584 }
1585 }
1586
1587 var result []*domain.VolumeInfo
1588 for _, v := range volumeList.Items {
1589 var size int64
1590 if v.UsageData != nil {
1591 size = v.UsageData.Size
1592 }
1593
1594 created, _ := time.Parse(time.RFC3339, v.CreatedAt)
1595
1596 users := volumeUsers[v.Name]
1597 info := &domain.VolumeInfo{
1598 Name: v.Name,
1599 Driver: v.Driver,
1600 MountPoint: v.Mountpoint,
1601 Size: size,
1602 CreatedAt: created,
1603 InUse: len(users) > 0,
1604 Containers: users,
1605 Labels: v.Labels,
1606 }
1607 result = append(result, info)
1608 }
1609
1610 return result, nil
1611}
1612
1613// ExportVolumeArchive exports a named volume as a compressed tar archive stream.
1614func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
1615 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1616 zerowrap.FieldLayer: "adapter",
1617 zerowrap.FieldAdapter: "docker",
1618 zerowrap.FieldAction: "ExportVolumeArchive",
1619 "volume": req.VolumeName,
1620 })
1621 log := zerowrap.FromCtx(ctx)
1622
1623 if err := validateVolumeArchiveRequest(req); err != nil {
1624 return nil, err
1625 }
1626 if err := r.ensureVolumeArchiveHelperImage(ctx, req.HelperImage, log); err != nil {
1627 return nil, err
1628 }
1629 created, cleanup, err := r.createVolumeArchiveHelper(ctx, req, log)
1630 if err != nil {
1631 return nil, err
1632 }
1633 if _, err := r.client.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
1634 _ = cleanup()
1635 return nil, log.WrapErr(err, "failed to start volume archive helper container")
1636 }
1637
1638 stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log)
1639 if err != nil {
1640 _ = cleanup()
1641 return nil, err
1642 }
1643 return &domain.VolumeArchiveResult{Stream: stream}, nil
1644}
1645
1646func validateVolumeArchiveRequest(req domain.VolumeArchiveRequest) error {
1647 if strings.TrimSpace(req.VolumeName) == "" {
1648 return fmt.Errorf("volume name is required")
1649 }
1650 if strings.TrimSpace(req.HelperImage) == "" {
1651 return fmt.Errorf("helper image is required")
1652 }
1653 return nil
1654}
1655
1656func (r *Runtime) ensureVolumeArchiveHelperImage(ctx context.Context, imageRef string, log zerowrap.Logger) error {
1657 if _, err := r.client.ImageInspect(ctx, imageRef); err != nil {
1658 if !cerrdefs.IsNotFound(err) {
1659 return log.WrapErr(err, "failed to inspect volume archive helper image")
1660 }
1661 return r.PullImage(ctx, imageRef)
1662 }
1663 return nil
1664}
1665
1666func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (client.ContainerCreateResult, func() error, error) {
1667 cmd, err := volumeArchiveCommand(req.Compression)
1668 if err != nil {
1669 return client.ContainerCreateResult{}, nil, err
1670 }
1671 created, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
1672 Config: volumeArchiveContainerConfig(req.HelperImage, cmd),
1673 HostConfig: volumeArchiveHostConfig(req.VolumeName),
1674 Name: fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()),
1675 })
1676 if err != nil {
1677 return client.ContainerCreateResult{}, nil, log.WrapErr(err, "failed to create volume archive helper container")
1678 }
1679 var cleanupOnce sync.Once
1680 cleanup := func() error {
1681 var cleanupErr error
1682 cleanupOnce.Do(func() {
1683 removeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1684 defer cancel()
1685 cleanupErr = r.RemoveContainer(removeCtx, created.ID, true)
1686 })
1687 return cleanupErr
1688 }
1689 return created, cleanup, nil
1690}
1691
1692func volumeArchiveContainerConfig(imageRef, cmd string) *container.Config {
1693 return &container.Config{
1694 Image: imageRef,
1695 Cmd: []string{"sh", "-c", cmd},
1696 AttachStdout: true,
1697 AttachStderr: true,
1698 NetworkDisabled: true,
1699 Labels: map[string]string{
1700 domain.LabelManaged: "true",
1701 "gordon.purpose": "volume-backup",
1702 },
1703 }
1704}
1705
1706func volumeArchiveHostConfig(volumeName string) *container.HostConfig {
1707 return &container.HostConfig{
1708 AutoRemove: false,
1709 Mounts: []mount.Mount{{
1710 Type: mount.TypeVolume,
1711 Source: volumeName,
1712 Target: "/volume",
1713 ReadOnly: true,
1714 }},
1715 }
1716}
1717
1718func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {
1719 logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
1720 if err != nil {
1721 return nil, log.WrapErr(err, "failed to attach volume archive stream")
1722 }
1723 waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
1724 statusCh, errCh := waitResult.Result, waitResult.Error
1725 pr, pw := io.Pipe()
1726 go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
1727 return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil
1728}
1729
1730func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) {
1731 defer logs.Close()
1732 defer func() {
1733 if err := cleanup(); err != nil {
1734 log.Warn().Err(err).Msg("failed to clean up volume archive helper container")
1735 }
1736 }()
1737
1738 var stderr bytes.Buffer
1739 _, copyErr := stdcopy.StdCopy(pw, &stderr, logs)
1740 statusCode, waitErr := waitForVolumeArchiveContainer(statusCh, errCh)
1741 closeVolumeArchivePipe(pw, copyErr, waitErr, statusCode, stderr.String())
1742}
1743
1744func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) {
1745 for statusCh != nil || errCh != nil {
1746 select {
1747 case err, ok := <-errCh:
1748 if !ok {
1749 errCh = nil
1750 continue
1751 }
1752 if err != nil {
1753 return 0, err
1754 }
1755 case status, ok := <-statusCh:
1756 if !ok {
1757 return 0, fmt.Errorf("volume archive helper container wait channel closed")
1758 }
1759 return status.StatusCode, nil
1760 }
1761 }
1762 return 0, fmt.Errorf("volume archive helper container wait channels closed")
1763}
1764
1765func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) {
1766 switch {
1767 case copyErr != nil:
1768 _ = pw.CloseWithError(copyErr)
1769 case waitErr != nil:
1770 _ = pw.CloseWithError(waitErr)
1771 case statusCode != 0:
1772 _ = pw.CloseWithError(fmt.Errorf("volume archive helper exited with code %d: %s", statusCode, strings.TrimSpace(stderr)))
1773 default:
1774 _ = pw.Close()
1775 }
1776}
1777
1778func volumeArchiveCommand(compression domain.VolumeBackupCompression) (string, error) {
1779 switch compression {
1780 case domain.VolumeBackupCompressionGzip:
1781 return "cd /volume && tar -czf - .", nil
1782 case domain.VolumeBackupCompressionZstd:
1783 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
1784 default:
1785 return "", fmt.Errorf("unsupported volume backup compression: %s", compression)
1786 }
1787}
1788
1789// InspectImageEnv gets the environment variables declared in the image.
1790func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
1791 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1792 zerowrap.FieldLayer: "adapter",
1793 zerowrap.FieldAdapter: "docker",
1794 zerowrap.FieldAction: "InspectImageEnv",
1795 "image": imageRef,
1796 })
1797 log := zerowrap.FromCtx(ctx)
1798
1799 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1800 if err != nil {
1801 return nil, log.WrapErr(err, "failed to inspect image")
1802 }
1803
1804 var envVars []string
1805 if imageInspect.Config != nil && imageInspect.Config.Env != nil {
1806 envVars = imageInspect.Config.Env
1807 }
1808
1809 if len(envVars) > 0 {
1810 log.Debug().
1811 Int(zerowrap.FieldCount, len(envVars)).
1812 Strs("env_keys", imageEnvKeys(envVars)).
1813 Msg("found ENV directives in image")
1814 } else {
1815 log.Debug().Msg("no ENV directives found in image")
1816 }
1817
1818 return envVars, nil
1819}
1820
1821// GetImageLabels returns the labels defined on an image.
1822func imageEnvKeys(envVars []string) []string {
1823 keys := make([]string, 0, len(envVars))
1824 for _, envVar := range envVars {
1825 key, _, ok := strings.Cut(envVar, "=")
1826 if !ok || key == "" {
1827 keys = append(keys, "[malformed]")
1828 continue
1829 }
1830 keys = append(keys, key)
1831 }
1832 sort.Strings(keys)
1833 return keys
1834}
1835
1836func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
1837 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1838 zerowrap.FieldLayer: "adapter",
1839 zerowrap.FieldAdapter: "docker",
1840 zerowrap.FieldAction: "GetImageLabels",
1841 "image": imageRef,
1842 })
1843 log := zerowrap.FromCtx(ctx)
1844
1845 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1846 if err != nil {
1847 return nil, log.WrapErr(err, "failed to inspect image")
1848 }
1849
1850 labels := make(map[string]string)
1851 if imageInspect.Config != nil && imageInspect.Config.Labels != nil {
1852 labels = imageInspect.Config.Labels
1853 }
1854
1855 log.Debug().Int("label_count", len(labels)).Msg("retrieved image labels")
1856 return labels, nil
1857}
1858
1859// GetImageID returns the unique image ID (sha256 digest) for the given image reference.
1860func (r *Runtime) VerifyImageDigest(ctx context.Context, imageRef, digest string) error {
1861 inspect, err := r.client.ImageInspect(ctx, imageRef)
1862 if err != nil {
1863 return fmt.Errorf("inspect image digest: %w", err)
1864 }
1865 for _, repoDigest := range inspect.RepoDigests {
1866 if strings.HasSuffix(repoDigest, "@"+digest) {
1867 return nil
1868 }
1869 }
1870 return fmt.Errorf("expected digest %s is absent from local image metadata: %w", digest, domain.ErrImagePullFailed)
1871}
1872
1873func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) {
1874 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1875 zerowrap.FieldLayer: "adapter",
1876 zerowrap.FieldAdapter: "docker",
1877 zerowrap.FieldAction: "GetImageID",
1878 "image": imageRef,
1879 })
1880 log := zerowrap.FromCtx(ctx)
1881
1882 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1883 if err != nil {
1884 return "", log.WrapErr(err, "failed to inspect image")
1885 }
1886
1887 return imageInspect.ID, nil
1888}
1889
1890// CreateNetwork creates a new Docker network.
1891func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
1892 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1893 zerowrap.FieldLayer: "adapter",
1894 zerowrap.FieldAdapter: "docker",
1895 zerowrap.FieldAction: "CreateNetwork",
1896 "network": name,
1897 })
1898 log := zerowrap.FromCtx(ctx)
1899
1900 driver := config.Driver
1901 if driver == "" {
1902 driver = "bridge"
1903 }
1904 labels := make(map[string]string, len(config.Labels)+1)
1905 maps.Copy(labels, config.Labels)
1906 labels[domain.LabelManaged] = "true"
1907
1908 createOptions := client.NetworkCreateOptions{
1909 Driver: driver,
1910 Internal: config.Internal,
1911 Labels: labels,
1912 }
1913
1914 _, err := r.client.NetworkCreate(ctx, name, createOptions)
1915 if err != nil {
1916 return log.WrapErr(err, "failed to create network")
1917 }
1918
1919 log.Info().Str("driver", driver).Msg("network created")
1920 return nil
1921}
1922
1923// RemoveNetwork removes a Docker network.
1924func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error {
1925 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1926 zerowrap.FieldLayer: "adapter",
1927 zerowrap.FieldAdapter: "docker",
1928 zerowrap.FieldAction: "RemoveNetwork",
1929 "network": name,
1930 })
1931 log := zerowrap.FromCtx(ctx)
1932
1933 _, err := r.client.NetworkRemove(ctx, name, client.NetworkRemoveOptions{})
1934 if err != nil {
1935 if cerrdefs.IsNotFound(err) {
1936 log.Debug().Msg("network not found, already removed")
1937 return nil
1938 }
1939 return log.WrapErr(err, "failed to remove network")
1940 }
1941
1942 log.Info().Msg("network removed")
1943 return nil
1944}
1945
1946// ListNetworks lists all Docker networks.
1947func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
1948 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1949 zerowrap.FieldLayer: "adapter",
1950 zerowrap.FieldAdapter: "docker",
1951 zerowrap.FieldAction: "ListNetworks",
1952 })
1953 log := zerowrap.FromCtx(ctx)
1954
1955 networkList, err := r.client.NetworkList(ctx, client.NetworkListOptions{})
1956 if err != nil {
1957 return nil, log.WrapErr(err, "failed to list networks")
1958 }
1959
1960 var result []*domain.NetworkInfo
1961 for _, net := range networkList.Items {
1962 networkInspect, err := r.client.NetworkInspect(ctx, net.ID, client.NetworkInspectOptions{})
1963 if err != nil {
1964 return nil, log.WrapErr(err, "failed to inspect network")
1965 }
1966 var containers []string
1967 for containerID := range networkInspect.Network.Containers {
1968 containers = append(containers, containerID)
1969 }
1970
1971 result = append(result, &domain.NetworkInfo{
1972 ID: net.ID,
1973 Name: net.Name,
1974 Driver: net.Driver,
1975 Containers: containers,
1976 Labels: net.Labels,
1977 })
1978 }
1979
1980 return result, nil
1981}
1982
1983// NetworkExists checks if a Docker network exists.
1984func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error) {
1985 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1986 zerowrap.FieldLayer: "adapter",
1987 zerowrap.FieldAdapter: "docker",
1988 zerowrap.FieldAction: "NetworkExists",
1989 "network": name,
1990 })
1991 log := zerowrap.FromCtx(ctx)
1992
1993 _, err := r.client.NetworkInspect(ctx, name, client.NetworkInspectOptions{})
1994 if err != nil {
1995 if cerrdefs.IsNotFound(err) {
1996 return false, nil
1997 }
1998 return false, log.WrapErr(err, "failed to inspect network")
1999 }
2000 return true, nil
2001}
2002
2003// ConnectContainerToNetwork connects a container to a network.
2004func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
2005 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2006 zerowrap.FieldLayer: "adapter",
2007 zerowrap.FieldAdapter: "docker",
2008 zerowrap.FieldAction: "ConnectContainerToNetwork",
2009 "container": containerName,
2010 "network": networkName,
2011 })
2012 log := zerowrap.FromCtx(ctx)
2013
2014 _, err := r.client.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
2015 Container: containerName,
2016 EndpointConfig: &network.EndpointSettings{},
2017 })
2018 if err != nil {
2019 return log.WrapErr(err, "failed to connect container to network")
2020 }
2021
2022 log.Info().Msg("container connected to network")
2023 return nil
2024}
2025
2026// DisconnectContainerFromNetwork disconnects a container from a network.
2027func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
2028 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2029 zerowrap.FieldLayer: "adapter",
2030 zerowrap.FieldAdapter: "docker",
2031 zerowrap.FieldAction: "DisconnectContainerFromNetwork",
2032 "container": containerName,
2033 "network": networkName,
2034 })
2035 log := zerowrap.FromCtx(ctx)
2036
2037 _, err := r.client.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{
2038 Container: containerName,
2039 })
2040 if err != nil {
2041 return log.WrapErr(err, "failed to disconnect container from network")
2042 }
2043
2044 log.Info().Msg("container disconnected from network")
2045 return nil
2046}
2047
2048// ExecInContainer executes a command in a running container.
2049func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
2050 if len(cmd) == 0 {
2051 return nil, fmt.Errorf("command cannot be empty")
2052 }
2053
2054 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2055 zerowrap.FieldLayer: "adapter",
2056 zerowrap.FieldAdapter: "docker",
2057 zerowrap.FieldAction: "ExecInContainer",
2058 zerowrap.FieldEntityID: containerID,
2059 "command": cmd[0],
2060 "arg_count": len(cmd) - 1,
2061 })
2062 log := zerowrap.FromCtx(ctx)
2063
2064 execResp, err := r.client.ExecCreate(ctx, containerID, client.ExecCreateOptions{
2065 Cmd: cmd,
2066 AttachStdout: true,
2067 AttachStderr: true,
2068 })
2069 if err != nil {
2070 return nil, log.WrapErr(err, "failed to create exec")
2071 }
2072
2073 attachCtx := ctx
2074 var cancel context.CancelFunc
2075 if _, hasDeadline := ctx.Deadline(); !hasDeadline {
2076 attachCtx, cancel = context.WithTimeout(ctx, 5*time.Minute)
2077 defer cancel()
2078 }
2079
2080 attachResp, err := r.client.ExecAttach(attachCtx, execResp.ID, client.ExecAttachOptions{})
2081 if err != nil {
2082 return nil, log.WrapErr(err, "failed to attach to exec")
2083 }
2084 defer attachResp.Close()
2085 if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil {
2086 _ = attachResp.Conn.SetReadDeadline(deadline)
2087 }
2088
2089 stdout, stderr, err := parseExecOutput(attachResp.Reader)
2090 if err != nil {
2091 return nil, log.WrapErr(err, "failed to read exec output")
2092 }
2093
2094 inspectResp, err := r.client.ExecInspect(ctx, execResp.ID, client.ExecInspectOptions{})
2095 if err != nil {
2096 return nil, log.WrapErr(err, "failed to inspect exec result")
2097 }
2098
2099 return &out.ExecResult{
2100 ExitCode: inspectResp.ExitCode,
2101 Stdout: stdout,
2102 Stderr: stderr,
2103 }, nil
2104}
2105
2106const maxExecOutputSize = 8 << 20 // 8 MiB per stream
2107
2108type boundedBuffer struct {
2109 bytes.Buffer
2110 limit int
2111}
2112
2113func (w *boundedBuffer) Write(p []byte) (int, error) {
2114 remaining := w.limit - w.Len()
2115 if remaining <= 0 {
2116 return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
2117 }
2118 if len(p) > remaining {
2119 _, _ = w.Buffer.Write(p[:remaining])
2120 return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
2121 }
2122 return w.Buffer.Write(p)
2123}
2124
2125func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
2126 stdout := boundedBuffer{limit: maxExecOutputSize}
2127 stderr := boundedBuffer{limit: maxExecOutputSize}
2128 buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr}
2129 var header [8]byte
2130
2131 for {
2132 if _, err := io.ReadFull(reader, header[:]); err != nil {
2133 if errors.Is(err, io.EOF) {
2134 break
2135 }
2136 return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err)
2137 }
2138 output, ok := buffers[header[0]]
2139 if !ok {
2140 return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0])
2141 }
2142 payloadSize := int64(binary.BigEndian.Uint32(header[4:]))
2143 remaining := int64(output.limit - output.Len())
2144 if payloadSize > remaining {
2145 return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining)
2146 }
2147 if _, err := io.CopyN(output, reader, payloadSize); err != nil {
2148 return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err)
2149 }
2150 }
2151
2152 return stdout.Bytes(), stderr.Bytes(), nil
2153}
2154
2155// CopyFromContainer copies a file from a container and returns a content stream.
2156func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
2157 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2158 zerowrap.FieldLayer: "adapter",
2159 zerowrap.FieldAdapter: "docker",
2160 zerowrap.FieldAction: "CopyFromContainer",
2161 zerowrap.FieldEntityID: containerID,
2162 "src_path": srcPath,
2163 })
2164 log := zerowrap.FromCtx(ctx)
2165
2166 copyResult, err := r.client.CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{SourcePath: srcPath})
2167 if err != nil {
2168 return nil, log.WrapErr(err, "failed to copy from container")
2169 }
2170
2171 // The response is a tar archive - we need to extract the requested file stream.
2172 reader, err := extractFileFromTar(copyResult.Content, srcPath)
2173 if err != nil {
2174 _ = copyResult.Content.Close()
2175 return nil, log.WrapErr(err, "failed to extract file from tar")
2176 }
2177
2178 return reader, nil
2179}
2180
2181// extractFileFromTar extracts a single file from a tar archive.
2182func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
2183 tr := tar.NewReader(reader)
2184 targetName := normalizedTarPath(targetPath)
2185 targetBase := filepath.Base(targetName)
2186 var fallback *removeOnCloseFile
2187 fallbackMatches := 0
2188
2189 for {
2190 header, err := tr.Next()
2191 if err == io.EOF {
2192 break
2193 }
2194 if err != nil {
2195 cleanupFallback(fallback)
2196 _ = reader.Close()
2197 return nil, err
2198 }
2199 if header.Typeflag != tar.TypeReg {
2200 continue
2201 }
2202
2203 headerName := normalizedTarPath(header.Name)
2204 if headerName == targetName {
2205 cleanupFallback(fallback)
2206 return streamCurrentTarFile(reader, tr, header.Size), nil
2207 }
2208 if headerName == targetBase {
2209 fallbackMatches++
2210 if fallbackMatches == 1 {
2211 fallback, err = bufferCurrentTarFile(tr, header.Size)
2212 if err != nil {
2213 _ = reader.Close()
2214 return nil, err
2215 }
2216 }
2217 }
2218 }
2219
2220 _ = reader.Close()
2221 if fallbackMatches == 1 {
2222 if _, err := fallback.Seek(0, io.SeekStart); err != nil {
2223 cleanupFallback(fallback)
2224 return nil, err
2225 }
2226 return fallback, nil
2227 }
2228 cleanupFallback(fallback)
2229 if fallbackMatches > 1 {
2230 return nil, fmt.Errorf("ambiguous basename matches in container archive for: %s", targetPath)
2231 }
2232 return nil, fmt.Errorf("file not found in container: %s", targetPath)
2233}
2234
2235func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser {
2236 pr, pw := io.Pipe()
2237 go func() {
2238 defer reader.Close()
2239 _, copyErr := io.CopyN(pw, tr, size)
2240 if copyErr != nil {
2241 _ = pw.CloseWithError(copyErr)
2242 return
2243 }
2244 _ = pw.Close()
2245 }()
2246 return &pipeReadCloser{pr: pr, pw: pw, original: reader}
2247}
2248
2249func bufferCurrentTarFile(tr *tar.Reader, size int64) (*removeOnCloseFile, error) {
2250 file, err := os.CreateTemp("", "gordon-copy-from-container-*")
2251 if err != nil {
2252 return nil, err
2253 }
2254 fallback := &removeOnCloseFile{File: file, path: file.Name()}
2255 if _, err := io.CopyN(fallback, tr, size); err != nil {
2256 cleanupFallback(fallback)
2257 return nil, err
2258 }
2259 return fallback, nil
2260}
2261
2262func cleanupFallback(fallback *removeOnCloseFile) {
2263 if fallback != nil {
2264 _ = fallback.Close()
2265 }
2266}
2267
2268func normalizedTarPath(path string) string {
2269 path = filepath.ToSlash(filepath.Clean(path))
2270 return strings.TrimPrefix(path, "/")
2271}
2272
2273const maxExtractedEnvFileSize = 1 << 20 // 1 MiB
2274
2275func readExtractedEnvFile(reader io.Reader) ([]byte, error) {
2276 limited := io.LimitReader(reader, maxExtractedEnvFileSize+1)
2277 data, err := io.ReadAll(limited)
2278 if err != nil {
2279 return nil, err
2280 }
2281 if len(data) > maxExtractedEnvFileSize {
2282 return nil, fmt.Errorf("extracted env file exceeds %d bytes", maxExtractedEnvFileSize)
2283 }
2284 return data, nil
2285}
2286
2287// ExtractEnvFileFromImage extracts an env file from an image.
2288// This creates a temporary container, copies the file, and removes the container.
2289func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
2290 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2291 zerowrap.FieldLayer: "adapter",
2292 zerowrap.FieldAdapter: "docker",
2293 zerowrap.FieldAction: "ExtractEnvFileFromImage",
2294 "image": imageRef,
2295 "env_file": envFilePath,
2296 })
2297 log := zerowrap.FromCtx(ctx)
2298
2299 log.Info().Msg("extracting env file from image")
2300
2301 // Create a temporary container without starting it
2302 containerConfig := &container.Config{
2303 Image: imageRef,
2304 Cmd: []string{"true"}, // Dummy command
2305 }
2306
2307 resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{Config: containerConfig})
2308 if err != nil {
2309 return nil, log.WrapErr(err, "failed to create temporary container")
2310 }
2311
2312 tempContainerID := resp.ID
2313 log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container")
2314
2315 // Ensure cleanup
2316 defer func() {
2317 if _, err := r.client.ContainerRemove(ctx, tempContainerID, client.ContainerRemoveOptions{Force: true}); err != nil {
2318 log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
2319 }
2320 }()
2321
2322 // Copy the env file from the container
2323 reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
2324 if err != nil {
2325 return nil, err
2326 }
2327 defer reader.Close()
2328
2329 data, err := readExtractedEnvFile(reader)
2330 if err != nil {
2331 return nil, log.WrapErr(err, "failed to read extracted env file")
2332 }
2333
2334 log.Info().Int("size", len(data)).Msg("env file extracted from image")
2335 return data, nil
2336}