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