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