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