1package container
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "strconv"
8 "strings"
9
10 "github.com/docker/docker/api/types/container"
11 "github.com/docker/docker/api/types/image"
12 "github.com/docker/docker/client"
13 "github.com/docker/go-connections/nat"
14 "github.com/rs/zerolog/log"
15
16 "gordon/pkg/runtime"
17)
18
19// DockerRuntime implements the Runtime interface using Docker API
20type DockerRuntime struct {
21 client *client.Client
22}
23
24// NewDockerRuntime creates a new Docker runtime instance
25func NewDockerRuntime() (*DockerRuntime, error) {
26 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
27 if err != nil {
28 return nil, fmt.Errorf("failed to create Docker client: %w", err)
29 }
30
31 return &DockerRuntime{
32 client: cli,
33 }, nil
34}
35
36// CreateContainer creates a new container
37func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) {
38 // Convert ports to Docker format
39 exposedPorts := make(nat.PortSet)
40 portBindings := make(nat.PortMap)
41
42 for _, port := range config.Ports {
43 containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
44 exposedPorts[containerPort] = struct{}{}
45
46 // Bind to random available port on host
47 portBindings[containerPort] = []nat.PortBinding{
48 {
49 HostIP: "0.0.0.0",
50 HostPort: "0", // Docker will assign a random available port
51 },
52 }
53 }
54
55 // Create container configuration
56 containerConfig := &container.Config{
57 Image: config.Image,
58 Env: config.Env,
59 ExposedPorts: exposedPorts,
60 WorkingDir: config.WorkingDir,
61 Cmd: config.Cmd,
62 Labels: config.Labels,
63 }
64
65 hostConfig := &container.HostConfig{
66 PortBindings: portBindings,
67 AutoRemove: config.AutoRemove,
68 }
69
70 // Create the container
71 resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, config.Name)
72 if err != nil {
73 return nil, fmt.Errorf("failed to create container: %w", err)
74 }
75
76 log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Container created")
77
78 // Inspect the created container to get full details
79 return d.InspectContainer(ctx, resp.ID)
80}
81
82// StartContainer starts a container
83func (d *DockerRuntime) StartContainer(ctx context.Context, containerID string) error {
84 err := d.client.ContainerStart(ctx, containerID, container.StartOptions{})
85 if err != nil {
86 return fmt.Errorf("failed to start container %s: %w", containerID, err)
87 }
88
89 log.Info().Str("id", containerID).Msg("Container started")
90 return nil
91}
92
93// StopContainer stops a container
94func (d *DockerRuntime) StopContainer(ctx context.Context, containerID string) error {
95 timeout := 30 // 30 seconds timeout
96 err := d.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
97 if err != nil {
98 return fmt.Errorf("failed to stop container %s: %w", containerID, err)
99 }
100
101 log.Info().Str("id", containerID).Msg("Container stopped")
102 return nil
103}
104
105// RestartContainer restarts a container
106func (d *DockerRuntime) RestartContainer(ctx context.Context, containerID string) error {
107 timeout := 30 // 30 seconds timeout
108 err := d.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
109 if err != nil {
110 return fmt.Errorf("failed to restart container %s: %w", containerID, err)
111 }
112
113 log.Info().Str("id", containerID).Msg("Container restarted")
114 return nil
115}
116
117// RemoveContainer removes a container
118func (d *DockerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
119 err := d.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
120 if err != nil {
121 return fmt.Errorf("failed to remove container %s: %w", containerID, err)
122 }
123
124 log.Info().Str("id", containerID).Bool("force", force).Msg("Container removed")
125 return nil
126}
127
128// ListContainers lists containers
129func (d *DockerRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) {
130 containers, err := d.client.ContainerList(ctx, container.ListOptions{All: all})
131 if err != nil {
132 return nil, fmt.Errorf("failed to list containers: %w", err)
133 }
134
135 var result []*runtime.Container
136 for _, c := range containers {
137 // Extract ports
138 var ports []int
139 for _, port := range c.Ports {
140 if port.PublicPort > 0 {
141 ports = append(ports, int(port.PublicPort))
142 }
143 }
144
145 // Get the primary name (remove leading slash)
146 name := ""
147 if len(c.Names) > 0 {
148 name = strings.TrimPrefix(c.Names[0], "/")
149 }
150
151 result = append(result, &runtime.Container{
152 ID: c.ID,
153 Image: c.Image,
154 Name: name,
155 Status: c.Status,
156 Ports: ports,
157 Labels: c.Labels,
158 })
159 }
160
161 return result, nil
162}
163
164// InspectContainer inspects a container
165func (d *DockerRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) {
166 resp, err := d.client.ContainerInspect(ctx, containerID)
167 if err != nil {
168 return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
169 }
170
171 // Extract published ports
172 var ports []int
173 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
174 for _, bindings := range resp.NetworkSettings.Ports {
175 for _, binding := range bindings {
176 if binding.HostPort != "" {
177 if port, err := strconv.Atoi(binding.HostPort); err == nil {
178 ports = append(ports, port)
179 }
180 }
181 }
182 }
183 }
184
185 // Get container name (remove leading slash)
186 name := strings.TrimPrefix(resp.Name, "/")
187
188 return &runtime.Container{
189 ID: resp.ID,
190 Image: resp.Config.Image,
191 Name: name,
192 Status: resp.State.Status,
193 Ports: ports,
194 Labels: resp.Config.Labels,
195 }, nil
196}
197
198// GetContainerLogs gets container logs
199func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
200 logs, err := d.client.ContainerLogs(ctx, containerID, container.LogsOptions{
201 ShowStdout: true,
202 ShowStderr: true,
203 Follow: follow,
204 Timestamps: true,
205 })
206 if err != nil {
207 return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err)
208 }
209
210 return logs, nil
211}
212
213// PullImage pulls an image
214func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error {
215 log.Info().Str("image", imageRef).Msg("Pulling image")
216
217 reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{})
218 if err != nil {
219 return fmt.Errorf("failed to pull image %s: %w", imageRef, err)
220 }
221 defer reader.Close()
222
223 // Read the response to completion (this is required for the pull to complete)
224 _, err = io.Copy(io.Discard, reader)
225 if err != nil {
226 return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
227 }
228
229 log.Info().Str("image", imageRef).Msg("Image pulled successfully")
230 return nil
231}
232
233// RemoveImage removes an image
234func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
235 _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
236 if err != nil {
237 return fmt.Errorf("failed to remove image %s: %w", imageRef, err)
238 }
239
240 log.Info().Str("image", imageRef).Bool("force", force).Msg("Image removed")
241 return nil
242}
243
244// ListImages lists images
245func (d *DockerRuntime) ListImages(ctx context.Context) ([]string, error) {
246 images, err := d.client.ImageList(ctx, image.ListOptions{})
247 if err != nil {
248 return nil, fmt.Errorf("failed to list images: %w", err)
249 }
250
251 var result []string
252 for _, img := range images {
253 for _, tag := range img.RepoTags {
254 if tag != "<none>:<none>" {
255 result = append(result, tag)
256 }
257 }
258 }
259
260 return result, nil
261}
262
263// Ping checks if Docker is responsive
264func (d *DockerRuntime) Ping(ctx context.Context) error {
265 _, err := d.client.Ping(ctx)
266 if err != nil {
267 return fmt.Errorf("Docker ping failed: %w", err)
268 }
269 return nil
270}
271
272// Version returns Docker version
273func (d *DockerRuntime) Version(ctx context.Context) (string, error) {
274 version, err := d.client.ServerVersion(ctx)
275 if err != nil {
276 return "", fmt.Errorf("failed to get Docker version: %w", err)
277 }
278 return version.Version, nil
279}
280
281// IsContainerRunning checks if a container is running
282func (d *DockerRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
283 container, err := d.InspectContainer(ctx, containerID)
284 if err != nil {
285 return false, err
286 }
287 return container.Status == "running", nil
288}
289
290// GetContainerPort gets the host port for a container's internal port
291func (d *DockerRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
292 resp, err := d.client.ContainerInspect(ctx, containerID)
293 if err != nil {
294 return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
295 }
296
297 if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
298 return 0, fmt.Errorf("no port mappings found for container %s", containerID)
299 }
300
301 containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
302 bindings, exists := resp.NetworkSettings.Ports[containerPort]
303 if !exists || len(bindings) == 0 {
304 return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
305 }
306
307 hostPort, err := strconv.Atoi(bindings[0].HostPort)
308 if err != nil {
309 return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
310 }
311
312 return hostPort, nil
313}
314
315// GetImageExposedPorts gets the exposed ports from an image
316func (d *DockerRuntime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
317 imageInspect, err := d.client.ImageInspect(ctx, imageRef)
318 if err != nil {
319 return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
320 }
321
322 var ports []int
323 if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
324 for portSpec := range imageInspect.Config.ExposedPorts {
325 // Parse port from format like "80/tcp"
326 portStr := strings.Split(string(portSpec), "/")[0]
327 if port, err := strconv.Atoi(portStr); err == nil {
328 ports = append(ports, port)
329 }
330 }
331 }
332
333 // If no exposed ports found, return common web ports as fallback
334 if len(ports) == 0 {
335 log.Warn().Str("image", imageRef).Msg("No EXPOSE directives found in image, using common web ports")
336 return []int{80, 8080, 3000}, nil
337 }
338
339 log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")
340 return ports, nil
341}
342
343// GetContainerExposedPorts gets all exposed ports from a running container
344func (d *DockerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
345 resp, err := d.client.ContainerInspect(ctx, containerID)
346 if err != nil {
347 return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
348 }
349
350 var ports []int
351 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
352 for portSpec := range resp.NetworkSettings.Ports {
353 // Parse port from format like "80/tcp"
354 portStr := strings.Split(string(portSpec), "/")[0]
355 if port, err := strconv.Atoi(portStr); err == nil {
356 ports = append(ports, port)
357 }
358 }
359 }
360
361 return ports, nil
362}
363
364// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use
365func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
366 resp, err := d.client.ContainerInspect(ctx, containerID)
367 if err != nil {
368 return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
369 }
370
371 // Get container's internal IP address
372 var containerIP string
373 if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
374 // Use the first available network (usually bridge or custom network)
375 for _, network := range resp.NetworkSettings.Networks {
376 if network.IPAddress != "" {
377 containerIP = network.IPAddress
378 break
379 }
380 }
381 }
382
383 if containerIP == "" {
384 return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
385 }
386
387 // Get exposed ports and select the best one (Traefik-style logic)
388 var exposedPorts []int
389 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
390 for portSpec := range resp.NetworkSettings.Ports {
391 // Parse port from format like "80/tcp"
392 portStr := strings.Split(string(portSpec), "/")[0]
393 if port, err := strconv.Atoi(portStr); err == nil {
394 exposedPorts = append(exposedPorts, port)
395 }
396 }
397 }
398
399 if len(exposedPorts) == 0 {
400 return "", 0, fmt.Errorf("no exposed ports found for container %s", containerID)
401 }
402
403 // Sort ports and select the lowest one (Traefik strategy)
404 var selectedPort int
405 if len(exposedPorts) == 1 {
406 selectedPort = exposedPorts[0]
407 } else {
408 // Find the lowest port number
409 selectedPort = exposedPorts[0]
410 for _, port := range exposedPorts {
411 if port < selectedPort {
412 selectedPort = port
413 }
414 }
415 }
416
417 log.Info().
418 Str("container_id", containerID).
419 Str("ip", containerIP).
420 Int("selected_port", selectedPort).
421 Ints("available_ports", exposedPorts).
422 Msg("Container network info detected")
423
424 return containerIP, selectedPort, nil
425}