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