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// InspectImageVolumes gets the volume mount points declared in the image.
750func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
751 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
752 zerowrap.FieldLayer: "adapter",
753 zerowrap.FieldAdapter: "docker",
754 zerowrap.FieldAction: "InspectImageVolumes",
755 "image": imageRef,
756 })
757 log := zerowrap.FromCtx(ctx)
758
759 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
760 if err != nil {
761 return nil, log.WrapErr(err, "failed to inspect image")
762 }
763
764 var volumes []string
765 if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
766 for volumePath := range imageInspect.Config.Volumes {
767 volumes = append(volumes, volumePath)
768 }
769 }
770
771 if len(volumes) > 0 {
772 log.Info().Strs("volumes", volumes).Msg("found VOLUME directives in image")
773 } else {
774 log.Debug().Msg("no VOLUME directives found in image")
775 }
776
777 return volumes, nil
778}
779
780// VolumeExists checks if a Docker volume exists.
781func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
782 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
783 zerowrap.FieldLayer: "adapter",
784 zerowrap.FieldAdapter: "docker",
785 zerowrap.FieldAction: "VolumeExists",
786 "volume": volumeName,
787 })
788 log := zerowrap.FromCtx(ctx)
789
790 _, err := r.client.VolumeInspect(ctx, volumeName)
791 if err != nil {
792 if cerrdefs.IsNotFound(err) {
793 return false, nil
794 }
795 return false, log.WrapErr(err, "failed to inspect volume")
796 }
797 return true, nil
798}
799
800// CreateVolume creates a new Docker volume.
801func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error {
802 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
803 zerowrap.FieldLayer: "adapter",
804 zerowrap.FieldAdapter: "docker",
805 zerowrap.FieldAction: "CreateVolume",
806 "volume": volumeName,
807 })
808 log := zerowrap.FromCtx(ctx)
809
810 _, err := r.client.VolumeCreate(ctx, volume.CreateOptions{
811 Name: volumeName,
812 Labels: map[string]string{
813 "gordon.managed": "true",
814 "gordon.created": "auto",
815 },
816 })
817 if err != nil {
818 return log.WrapErr(err, "failed to create volume")
819 }
820
821 log.Info().Msg("volume created")
822 return nil
823}
824
825// RemoveVolume removes a Docker volume.
826func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
827 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
828 zerowrap.FieldLayer: "adapter",
829 zerowrap.FieldAdapter: "docker",
830 zerowrap.FieldAction: "RemoveVolume",
831 "volume": volumeName,
832 "force": force,
833 })
834 log := zerowrap.FromCtx(ctx)
835
836 err := r.client.VolumeRemove(ctx, volumeName, force)
837 if err != nil {
838 if cerrdefs.IsNotFound(err) {
839 log.Debug().Msg("volume not found, already removed")
840 return nil
841 }
842 return log.WrapErr(err, "failed to remove volume")
843 }
844
845 log.Info().Msg("volume removed")
846 return nil
847}
848
849// InspectImageEnv gets the environment variables declared in the image.
850func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
851 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
852 zerowrap.FieldLayer: "adapter",
853 zerowrap.FieldAdapter: "docker",
854 zerowrap.FieldAction: "InspectImageEnv",
855 "image": imageRef,
856 })
857 log := zerowrap.FromCtx(ctx)
858
859 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
860 if err != nil {
861 return nil, log.WrapErr(err, "failed to inspect image")
862 }
863
864 var envVars []string
865 if imageInspect.Config != nil && imageInspect.Config.Env != nil {
866 envVars = imageInspect.Config.Env
867 }
868
869 if len(envVars) > 0 {
870 log.Debug().Strs("env_vars", envVars).Msg("found ENV directives in image")
871 } else {
872 log.Debug().Msg("no ENV directives found in image")
873 }
874
875 return envVars, nil
876}
877
878// GetImageLabels returns the labels defined on an image.
879func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
880 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
881 zerowrap.FieldLayer: "adapter",
882 zerowrap.FieldAdapter: "docker",
883 zerowrap.FieldAction: "GetImageLabels",
884 "image": imageRef,
885 })
886 log := zerowrap.FromCtx(ctx)
887
888 imageInspect, err := r.client.ImageInspect(ctx, imageRef)
889 if err != nil {
890 return nil, log.WrapErr(err, "failed to inspect image")
891 }
892
893 labels := make(map[string]string)
894 if imageInspect.Config != nil && imageInspect.Config.Labels != nil {
895 labels = imageInspect.Config.Labels
896 }
897
898 log.Debug().Int("label_count", len(labels)).Msg("retrieved image labels")
899 return labels, nil
900}
901
902// CreateNetwork creates a new Docker network.
903func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
904 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
905 zerowrap.FieldLayer: "adapter",
906 zerowrap.FieldAdapter: "docker",
907 zerowrap.FieldAction: "CreateNetwork",
908 "network": name,
909 })
910 log := zerowrap.FromCtx(ctx)
911
912 // Set default driver to bridge if not specified
913 driver := "bridge"
914 if driverOption, exists := options["driver"]; exists {
915 driver = driverOption
916 }
917
918 createOptions := network.CreateOptions{
919 Driver: driver,
920 Labels: map[string]string{
921 "gordon.managed": "true",
922 },
923 }
924
925 // Add any additional options to labels
926 for key, value := range options {
927 if key != "driver" {
928 createOptions.Labels["gordon."+key] = value
929 }
930 }
931
932 _, err := r.client.NetworkCreate(ctx, name, createOptions)
933 if err != nil {
934 return log.WrapErr(err, "failed to create network")
935 }
936
937 log.Info().Str("driver", driver).Msg("network created")
938 return nil
939}
940
941// RemoveNetwork removes a Docker network.
942func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error {
943 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
944 zerowrap.FieldLayer: "adapter",
945 zerowrap.FieldAdapter: "docker",
946 zerowrap.FieldAction: "RemoveNetwork",
947 "network": name,
948 })
949 log := zerowrap.FromCtx(ctx)
950
951 err := r.client.NetworkRemove(ctx, name)
952 if err != nil {
953 if cerrdefs.IsNotFound(err) {
954 log.Debug().Msg("network not found, already removed")
955 return nil
956 }
957 return log.WrapErr(err, "failed to remove network")
958 }
959
960 log.Info().Msg("network removed")
961 return nil
962}
963
964// ListNetworks lists all Docker networks.
965func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
966 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
967 zerowrap.FieldLayer: "adapter",
968 zerowrap.FieldAdapter: "docker",
969 zerowrap.FieldAction: "ListNetworks",
970 })
971 log := zerowrap.FromCtx(ctx)
972
973 networks, err := r.client.NetworkList(ctx, network.ListOptions{})
974 if err != nil {
975 return nil, log.WrapErr(err, "failed to list networks")
976 }
977
978 var result []*domain.NetworkInfo
979 for _, net := range networks {
980 var containers []string
981 for containerID := range net.Containers {
982 containers = append(containers, containerID)
983 }
984
985 result = append(result, &domain.NetworkInfo{
986 ID: net.ID,
987 Name: net.Name,
988 Driver: net.Driver,
989 Containers: containers,
990 Labels: net.Labels,
991 })
992 }
993
994 return result, nil
995}
996
997// NetworkExists checks if a Docker network exists.
998func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error) {
999 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1000 zerowrap.FieldLayer: "adapter",
1001 zerowrap.FieldAdapter: "docker",
1002 zerowrap.FieldAction: "NetworkExists",
1003 "network": name,
1004 })
1005 log := zerowrap.FromCtx(ctx)
1006
1007 _, err := r.client.NetworkInspect(ctx, name, network.InspectOptions{})
1008 if err != nil {
1009 if cerrdefs.IsNotFound(err) {
1010 return false, nil
1011 }
1012 return false, log.WrapErr(err, "failed to inspect network")
1013 }
1014 return true, nil
1015}
1016
1017// ConnectContainerToNetwork connects a container to a network.
1018func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
1019 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1020 zerowrap.FieldLayer: "adapter",
1021 zerowrap.FieldAdapter: "docker",
1022 zerowrap.FieldAction: "ConnectContainerToNetwork",
1023 "container": containerName,
1024 "network": networkName,
1025 })
1026 log := zerowrap.FromCtx(ctx)
1027
1028 err := r.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
1029 if err != nil {
1030 return log.WrapErr(err, "failed to connect container to network")
1031 }
1032
1033 log.Info().Msg("container connected to network")
1034 return nil
1035}
1036
1037// DisconnectContainerFromNetwork disconnects a container from a network.
1038func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
1039 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1040 zerowrap.FieldLayer: "adapter",
1041 zerowrap.FieldAdapter: "docker",
1042 zerowrap.FieldAction: "DisconnectContainerFromNetwork",
1043 "container": containerName,
1044 "network": networkName,
1045 })
1046 log := zerowrap.FromCtx(ctx)
1047
1048 err := r.client.NetworkDisconnect(ctx, networkName, containerName, false)
1049 if err != nil {
1050 return log.WrapErr(err, "failed to disconnect container from network")
1051 }
1052
1053 log.Info().Msg("container disconnected from network")
1054 return nil
1055}
1056
1057// CopyFromContainer copies a file from a container to the host.
1058// Returns the file contents as a byte slice.
1059func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) {
1060 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1061 zerowrap.FieldLayer: "adapter",
1062 zerowrap.FieldAdapter: "docker",
1063 zerowrap.FieldAction: "CopyFromContainer",
1064 zerowrap.FieldEntityID: containerID,
1065 "src_path": srcPath,
1066 })
1067 log := zerowrap.FromCtx(ctx)
1068
1069 reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1070 if err != nil {
1071 return nil, log.WrapErr(err, "failed to copy from container")
1072 }
1073 defer reader.Close()
1074
1075 // The response is a tar archive - we need to extract the file
1076 data, err := extractFileFromTar(reader, srcPath)
1077 if err != nil {
1078 return nil, log.WrapErr(err, "failed to extract file from tar")
1079 }
1080
1081 log.Debug().Int("size", len(data)).Msg("file copied from container")
1082 return data, nil
1083}
1084
1085// extractFileFromTar extracts a single file from a tar archive.
1086func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
1087 tr := tar.NewReader(reader)
1088
1089 // The path in the tar may be relative or absolute
1090 // We need to match based on the filename
1091 targetName := filepath.Base(targetPath)
1092
1093 for {
1094 header, err := tr.Next()
1095 if err == io.EOF {
1096 break
1097 }
1098 if err != nil {
1099 return nil, err
1100 }
1101
1102 // Check if this is our target file
1103 if header.Typeflag == tar.TypeReg {
1104 headerName := filepath.Base(header.Name)
1105 if headerName == targetName {
1106 data, err := io.ReadAll(tr)
1107 if err != nil {
1108 return nil, err
1109 }
1110 return data, nil
1111 }
1112 }
1113 }
1114
1115 return nil, fmt.Errorf("file not found in container: %s", targetPath)
1116}
1117
1118// ExtractEnvFileFromImage extracts an env file from an image.
1119// This creates a temporary container, copies the file, and removes the container.
1120func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
1121 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1122 zerowrap.FieldLayer: "adapter",
1123 zerowrap.FieldAdapter: "docker",
1124 zerowrap.FieldAction: "ExtractEnvFileFromImage",
1125 "image": imageRef,
1126 "env_file": envFilePath,
1127 })
1128 log := zerowrap.FromCtx(ctx)
1129
1130 log.Info().Msg("extracting env file from image")
1131
1132 // Create a temporary container without starting it
1133 containerConfig := &container.Config{
1134 Image: imageRef,
1135 Cmd: []string{"true"}, // Dummy command
1136 }
1137
1138 resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "")
1139 if err != nil {
1140 return nil, log.WrapErr(err, "failed to create temporary container")
1141 }
1142
1143 tempContainerID := resp.ID
1144 log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container")
1145
1146 // Ensure cleanup
1147 defer func() {
1148 if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil {
1149 log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
1150 }
1151 }()
1152
1153 // Copy the env file from the container
1154 data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1155 if err != nil {
1156 return nil, err
1157 }
1158
1159 log.Info().Int("size", len(data)).Msg("env file extracted from image")
1160 return data, nil
1161}