1package container
2
3import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "gordon/pkg/runtime"
9 "io"
10 "strconv"
11 "strings"
12
13 "github.com/docker/docker/api/types/container"
14 "github.com/docker/docker/api/types/image"
15 "github.com/docker/docker/api/types/network"
16 "github.com/docker/docker/api/types/registry"
17 "github.com/docker/docker/api/types/volume"
18 "github.com/docker/docker/client"
19 "github.com/docker/docker/errdefs"
20 "github.com/docker/go-connections/nat"
21 "github.com/rs/zerolog/log"
22)
23
24// DockerRuntime implements the Runtime interface using Docker API
25type DockerRuntime struct {
26 client *client.Client
27}
28
29// NewDockerRuntime creates a new Docker runtime instance
30func NewDockerRuntime() (*DockerRuntime, error) {
31 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
32 if err != nil {
33 return nil, fmt.Errorf("failed to create Docker client: %w", err)
34 }
35
36 return &DockerRuntime{
37 client: cli,
38 }, nil
39}
40
41// NewDockerRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing)
42func NewDockerRuntimeWithClient(cli *client.Client) *DockerRuntime {
43 return &DockerRuntime{
44 client: cli,
45 }
46}
47
48// CreateContainer creates a new container
49func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) {
50 // Convert ports to Docker format
51 exposedPorts := make(nat.PortSet)
52 portBindings := make(nat.PortMap)
53
54 for _, port := range config.Ports {
55 containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
56 exposedPorts[containerPort] = struct{}{}
57
58 // Bind to random available port on host
59 portBindings[containerPort] = []nat.PortBinding{
60 {
61 HostIP: "0.0.0.0",
62 HostPort: "0", // Docker will assign a random available port
63 },
64 }
65 }
66
67 // Convert volumes to Docker format
68 var binds []string
69 if config.Volumes != nil {
70 for containerPath, volumeName := range config.Volumes {
71 bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
72 binds = append(binds, bind)
73 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("Adding volume mount")
74 }
75 }
76
77 // Create container configuration
78 containerConfig := &container.Config{
79 Image: config.Image,
80 Env: config.Env,
81 ExposedPorts: exposedPorts,
82 WorkingDir: config.WorkingDir,
83 Cmd: config.Cmd,
84 Labels: config.Labels,
85 }
86
87 hostConfig := &container.HostConfig{
88 PortBindings: portBindings,
89 AutoRemove: config.AutoRemove,
90 Binds: binds,
91 NetworkMode: container.NetworkMode(config.NetworkMode),
92 }
93
94 // Create network configuration for container
95 var networkConfig *network.NetworkingConfig
96 if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
97 networkConfig = &network.NetworkingConfig{
98 EndpointsConfig: map[string]*network.EndpointSettings{
99 config.NetworkMode: {
100 Aliases: config.Aliases,
101 },
102 },
103 }
104 if config.Hostname != "" {
105 networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
106 }
107 }
108
109 // Create the container
110 resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)
111 if err != nil {
112 return nil, fmt.Errorf("failed to create container: %w", err)
113 }
114
115 log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Container created")
116
117 // Inspect the created container to get full details
118 return d.InspectContainer(ctx, resp.ID)
119}
120
121// StartContainer starts a container
122func (d *DockerRuntime) StartContainer(ctx context.Context, containerID string) error {
123 err := d.client.ContainerStart(ctx, containerID, container.StartOptions{})
124 if err != nil {
125 return fmt.Errorf("failed to start container %s: %w", containerID, err)
126 }
127
128 log.Info().Str("id", containerID).Msg("Container started")
129 return nil
130}
131
132// StopContainer stops a container
133func (d *DockerRuntime) StopContainer(ctx context.Context, containerID string) error {
134 timeout := 30 // 30 seconds timeout
135 err := d.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
136 if err != nil {
137 return fmt.Errorf("failed to stop container %s: %w", containerID, err)
138 }
139
140 log.Info().Str("id", containerID).Msg("Container stopped")
141 return nil
142}
143
144// RestartContainer restarts a container
145func (d *DockerRuntime) RestartContainer(ctx context.Context, containerID string) error {
146 timeout := 30 // 30 seconds timeout
147 err := d.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
148 if err != nil {
149 return fmt.Errorf("failed to restart container %s: %w", containerID, err)
150 }
151
152 log.Info().Str("id", containerID).Msg("Container restarted")
153 return nil
154}
155
156// RemoveContainer removes a container
157func (d *DockerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
158 err := d.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
159 if err != nil {
160 return fmt.Errorf("failed to remove container %s: %w", containerID, err)
161 }
162
163 log.Info().Str("id", containerID).Bool("force", force).Msg("Container removed")
164 return nil
165}
166
167// ListContainers lists containers
168func (d *DockerRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) {
169 containers, err := d.client.ContainerList(ctx, container.ListOptions{All: all})
170 if err != nil {
171 return nil, fmt.Errorf("failed to list containers: %w", err)
172 }
173
174 var result []*runtime.Container
175 for _, c := range containers {
176 // Extract ports
177 var ports []int
178 for _, port := range c.Ports {
179 if port.PublicPort > 0 {
180 ports = append(ports, int(port.PublicPort))
181 }
182 }
183
184 // Get the primary name (remove leading slash)
185 name := ""
186 if len(c.Names) > 0 {
187 name = strings.TrimPrefix(c.Names[0], "/")
188 }
189
190 result = append(result, &runtime.Container{
191 ID: c.ID,
192 Image: c.Image,
193 Name: name,
194 Status: c.Status,
195 Ports: ports,
196 Labels: c.Labels,
197 })
198 }
199
200 return result, nil
201}
202
203// InspectContainer inspects a container
204func (d *DockerRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) {
205 resp, err := d.client.ContainerInspect(ctx, containerID)
206 if err != nil {
207 return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
208 }
209
210 // Extract published ports
211 var ports []int
212 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
213 for _, bindings := range resp.NetworkSettings.Ports {
214 for _, binding := range bindings {
215 if binding.HostPort != "" {
216 if port, err := strconv.Atoi(binding.HostPort); err == nil {
217 ports = append(ports, port)
218 }
219 }
220 }
221 }
222 }
223
224 // Get container name (remove leading slash)
225 name := strings.TrimPrefix(resp.Name, "/")
226
227 return &runtime.Container{
228 ID: resp.ID,
229 Image: resp.Config.Image,
230 Name: name,
231 Status: resp.State.Status,
232 Ports: ports,
233 Labels: resp.Config.Labels,
234 }, nil
235}
236
237// GetContainerLogs gets container logs
238func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
239 logs, err := d.client.ContainerLogs(ctx, containerID, container.LogsOptions{
240 ShowStdout: true,
241 ShowStderr: true,
242 Follow: follow,
243 Timestamps: true,
244 })
245 if err != nil {
246 return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err)
247 }
248
249 return logs, nil
250}
251
252// PullImage pulls an image
253func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error {
254 log.Info().Str("image", imageRef).Msg("Pulling image")
255
256 reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{})
257 if err != nil {
258 return fmt.Errorf("failed to pull image %s: %w", imageRef, err)
259 }
260 defer reader.Close()
261
262 // Read the response to completion (this is required for the pull to complete)
263 _, err = io.Copy(io.Discard, reader)
264 if err != nil {
265 return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
266 }
267
268 log.Info().Str("image", imageRef).Msg("Image pulled successfully")
269 return nil
270}
271
272// PullImageWithAuth pulls an image with authentication
273func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
274 log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication")
275
276 // Create authentication configuration
277 authConfig := registry.AuthConfig{
278 Username: username,
279 Password: password,
280 }
281
282 // Encode authentication to base64 JSON
283 authConfigBytes, err := json.Marshal(authConfig)
284 if err != nil {
285 return fmt.Errorf("failed to marshal auth config: %w", err)
286 }
287 authStr := base64.URLEncoding.EncodeToString(authConfigBytes)
288
289 // Pull with authentication
290 reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{
291 RegistryAuth: authStr,
292 })
293 if err != nil {
294 return fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
295 }
296 defer reader.Close()
297
298 // Read the response to completion (this is required for the pull to complete)
299 _, err = io.Copy(io.Discard, reader)
300 if err != nil {
301 return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
302 }
303
304 log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
305 return nil
306}
307
308// RemoveImage removes an image
309func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
310 _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
311 if err != nil {
312 return fmt.Errorf("failed to remove image %s: %w", imageRef, err)
313 }
314
315 log.Info().Str("image", imageRef).Bool("force", force).Msg("Image removed")
316 return nil
317}
318
319// ListImages lists images
320func (d *DockerRuntime) ListImages(ctx context.Context) ([]string, error) {
321 images, err := d.client.ImageList(ctx, image.ListOptions{})
322 if err != nil {
323 return nil, fmt.Errorf("failed to list images: %w", err)
324 }
325
326 var result []string
327 for _, img := range images {
328 for _, tag := range img.RepoTags {
329 if tag != "<none>:<none>" {
330 result = append(result, tag)
331 }
332 }
333 }
334
335 return result, nil
336}
337
338// Ping checks if Docker is responsive
339func (d *DockerRuntime) Ping(ctx context.Context) error {
340 _, err := d.client.Ping(ctx)
341 if err != nil {
342 return fmt.Errorf("Docker ping failed: %w", err)
343 }
344 return nil
345}
346
347// Version returns Docker version
348func (d *DockerRuntime) Version(ctx context.Context) (string, error) {
349 version, err := d.client.ServerVersion(ctx)
350 if err != nil {
351 return "", fmt.Errorf("failed to get Docker version: %w", err)
352 }
353 return version.Version, nil
354}
355
356// IsContainerRunning checks if a container is running
357func (d *DockerRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
358 container, err := d.InspectContainer(ctx, containerID)
359 if err != nil {
360 return false, err
361 }
362 return container.Status == "running", nil
363}
364
365// GetContainerPort gets the host port for a container's internal port
366func (d *DockerRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
367 resp, err := d.client.ContainerInspect(ctx, containerID)
368 if err != nil {
369 return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
370 }
371
372 if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
373 return 0, fmt.Errorf("no port mappings found for container %s", containerID)
374 }
375
376 containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
377 bindings, exists := resp.NetworkSettings.Ports[containerPort]
378 if !exists || len(bindings) == 0 {
379 return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
380 }
381
382 hostPort, err := strconv.Atoi(bindings[0].HostPort)
383 if err != nil {
384 return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
385 }
386
387 return hostPort, nil
388}
389
390// GetImageExposedPorts gets the exposed ports from an image
391func (d *DockerRuntime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
392 imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
393 if err != nil {
394 return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
395 }
396
397 var ports []int
398 if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
399 for portSpec := range imageInspect.Config.ExposedPorts {
400 // Parse port from format like "80/tcp"
401 portStr := strings.Split(string(portSpec), "/")[0]
402 if port, err := strconv.Atoi(portStr); err == nil {
403 ports = append(ports, port)
404 }
405 }
406 }
407
408 if len(ports) == 0 {
409 return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
410 }
411
412 log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")
413 return ports, nil
414}
415
416// GetContainerExposedPorts gets all exposed ports from a running container
417func (d *DockerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
418 resp, err := d.client.ContainerInspect(ctx, containerID)
419 if err != nil {
420 return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
421 }
422
423 var ports []int
424 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
425 for portSpec := range resp.NetworkSettings.Ports {
426 // Parse port from format like "80/tcp"
427 portStr := strings.Split(string(portSpec), "/")[0]
428 if port, err := strconv.Atoi(portStr); err == nil {
429 ports = append(ports, port)
430 }
431 }
432 }
433
434 return ports, nil
435}
436
437// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use
438func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
439 resp, err := d.client.ContainerInspect(ctx, containerID)
440 if err != nil {
441 return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
442 }
443
444 // Get container's internal IP address
445 var containerIP string
446 if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
447 // Use the first available network (usually bridge or custom network)
448 for _, network := range resp.NetworkSettings.Networks {
449 if network.IPAddress != "" {
450 containerIP = network.IPAddress
451 break
452 }
453 }
454 }
455
456 if containerIP == "" {
457 return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
458 }
459
460 // Get exposed ports from container
461 var exposedPorts []int
462 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
463 for portSpec := range resp.NetworkSettings.Ports {
464 // Parse port from format like "80/tcp"
465 portStr := strings.Split(string(portSpec), "/")[0]
466 if port, err := strconv.Atoi(portStr); err == nil {
467 exposedPorts = append(exposedPorts, port)
468 }
469 }
470 }
471
472 if len(exposedPorts) == 0 {
473 return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
474 }
475
476 // Use the first exposed port
477 selectedPort := exposedPorts[0]
478
479 log.Info().
480 Str("container_id", containerID).
481 Str("ip", containerIP).
482 Int("selected_port", selectedPort).
483 Ints("exposed_ports", exposedPorts).
484 Msg("Container network info detected")
485
486 return containerIP, selectedPort, nil
487}
488
489// InspectImageVolumes gets the volume mount points declared in the image
490func (d *DockerRuntime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
491 imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
492 if err != nil {
493 return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
494 }
495
496 var volumes []string
497 if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
498 for volumePath := range imageInspect.Config.Volumes {
499 volumes = append(volumes, volumePath)
500 }
501 }
502
503 if len(volumes) > 0 {
504 log.Info().Str("image", imageRef).Strs("volumes", volumes).Msg("Found VOLUME directives in image")
505 } else {
506 log.Debug().Str("image", imageRef).Msg("No VOLUME directives found in image")
507 }
508
509 return volumes, nil
510}
511
512// VolumeExists checks if a Docker volume exists
513func (d *DockerRuntime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
514 _, err := d.client.VolumeInspect(ctx, volumeName)
515 if err != nil {
516 if errdefs.IsNotFound(err) {
517 return false, nil
518 }
519 return false, fmt.Errorf("failed to inspect volume %s: %w", volumeName, err)
520 }
521 return true, nil
522}
523
524// CreateVolume creates a new Docker volume
525func (d *DockerRuntime) CreateVolume(ctx context.Context, volumeName string) error {
526 _, err := d.client.VolumeCreate(ctx, volume.CreateOptions{
527 Name: volumeName,
528 Labels: map[string]string{
529 "gordon.managed": "true",
530 "gordon.created": "auto",
531 },
532 })
533 if err != nil {
534 return fmt.Errorf("failed to create volume %s: %w", volumeName, err)
535 }
536
537 log.Info().Str("volume", volumeName).Msg("Volume created")
538 return nil
539}
540
541// RemoveVolume removes a Docker volume
542func (d *DockerRuntime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
543 err := d.client.VolumeRemove(ctx, volumeName, force)
544 if err != nil {
545 if errdefs.IsNotFound(err) {
546 log.Debug().Str("volume", volumeName).Msg("Volume not found, already removed")
547 return nil
548 }
549 return fmt.Errorf("failed to remove volume %s: %w", volumeName, err)
550 }
551
552 log.Info().Str("volume", volumeName).Msg("Volume removed")
553 return nil
554}
555
556// InspectImageEnv gets the environment variables declared in the image
557func (d *DockerRuntime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
558 imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
559 if err != nil {
560 return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
561 }
562
563 var envVars []string
564 if imageInspect.Config != nil && imageInspect.Config.Env != nil {
565 envVars = imageInspect.Config.Env
566 }
567
568 if len(envVars) > 0 {
569 log.Debug().Str("image", imageRef).Strs("env_vars", envVars).Msg("Found ENV directives in image")
570 } else {
571 log.Debug().Str("image", imageRef).Msg("No ENV directives found in image")
572 }
573
574 return envVars, nil
575}
576
577// CreateNetwork creates a new Docker network
578func (d *DockerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
579 // Set default driver to bridge if not specified
580 driver := "bridge"
581 if driverOption, exists := options["driver"]; exists {
582 driver = driverOption
583 }
584
585 createOptions := network.CreateOptions{
586 Driver: driver,
587 Labels: map[string]string{
588 "gordon.managed": "true",
589 },
590 }
591
592 // Add any additional options to labels
593 for key, value := range options {
594 if key != "driver" {
595 createOptions.Labels["gordon."+key] = value
596 }
597 }
598
599 _, err := d.client.NetworkCreate(ctx, name, createOptions)
600 if err != nil {
601 return fmt.Errorf("failed to create network %s: %w", name, err)
602 }
603
604 log.Info().Str("network", name).Str("driver", driver).Msg("Network created")
605 return nil
606}
607
608// RemoveNetwork removes a Docker network
609func (d *DockerRuntime) RemoveNetwork(ctx context.Context, name string) error {
610 err := d.client.NetworkRemove(ctx, name)
611 if err != nil {
612 if errdefs.IsNotFound(err) {
613 log.Debug().Str("network", name).Msg("Network not found, already removed")
614 return nil
615 }
616 return fmt.Errorf("failed to remove network %s: %w", name, err)
617 }
618
619 log.Info().Str("network", name).Msg("Network removed")
620 return nil
621}
622
623// ListNetworks lists all Docker networks
624func (d *DockerRuntime) ListNetworks(ctx context.Context) ([]*runtime.NetworkInfo, error) {
625 networks, err := d.client.NetworkList(ctx, network.ListOptions{})
626 if err != nil {
627 return nil, fmt.Errorf("failed to list networks: %w", err)
628 }
629
630 var result []*runtime.NetworkInfo
631 for _, net := range networks {
632 var containers []string
633 for containerID := range net.Containers {
634 containers = append(containers, containerID)
635 }
636
637 result = append(result, &runtime.NetworkInfo{
638 ID: net.ID,
639 Name: net.Name,
640 Driver: net.Driver,
641 Containers: containers,
642 Labels: net.Labels,
643 })
644 }
645
646 return result, nil
647}
648
649// NetworkExists checks if a Docker network exists
650func (d *DockerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) {
651 _, err := d.client.NetworkInspect(ctx, name, network.InspectOptions{})
652 if err != nil {
653 if errdefs.IsNotFound(err) {
654 return false, nil
655 }
656 return false, fmt.Errorf("failed to inspect network %s: %w", name, err)
657 }
658 return true, nil
659}
660
661// ConnectContainerToNetwork connects a container to a network
662func (d *DockerRuntime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
663 err := d.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
664 if err != nil {
665 return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err)
666 }
667
668 log.Info().Str("container", containerName).Str("network", networkName).Msg("Container connected to network")
669 return nil
670}
671
672// DisconnectContainerFromNetwork disconnects a container from a network
673func (d *DockerRuntime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
674 err := d.client.NetworkDisconnect(ctx, networkName, containerName, false)
675 if err != nil {
676 return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err)
677 }
678
679 log.Info().Str("container", containerName).Str("network", networkName).Msg("Container disconnected from network")
680 return nil
681}