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