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