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