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