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