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