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