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