detached 1693e81

feat: Add unified Docker and Podman runtime support with auto-detection

- Implement runtime factory with auto-detection for Docker, Podman root, and Podman rootless
- Add Podman runtime implementation using Docker-compatible API
- Support custom socket paths via config and CONTAINER_HOST environment variable
- Update configuration to support runtime auto-detection and socket_path option
- Change default runtime from 'docker' to 'auto' for seamless experience
- Update all example configurations and documentation
- Maintain backward compatibility with existing Docker setups
commit
1693e814b02526c3d86207057a84b71e1ff63aac
author
bamen · · 2025-06-07 07:23 UTC
parent
6c434ec
signature
unsigned
14 files · +851 -24
A Makefile +76 -0
@@ -0,0 +1,76 @@
1+# This Makefile is used for dev purposes
2+# Variables
3+REPO := ghcr.io/bnema/gordon
4+TAG := v2-dev
5+DIST_DIR := ./dist
6+ENGINE := podman
7+
8+# Version information
9+VERSION := $(shell git describe --tags --always --dirty)
10+COMMIT := $(shell git rev-parse --short HEAD)
11+BUILD_DATE := $(shell date -u '+%Y-%m-%d_%I:%M:%S%p')
12+
13+# Build flags
14+LDFLAGS := -s -w \
15+ -X main.version=$(VERSION) \
16+ -X main.commit=$(COMMIT) \
17+ -X main.date=$(BUILD_DATE)
18+
19+# Architectures
20+ARCHS := amd64 arm64
21+
22+# Phony targets
23+.PHONY: all build build-push clean
24+
25+# Default target
26+all: build
27+
28+# Build binaries
29+build:
30+ @echo "Building Go binaries..."
31+ @mkdir -p $(DIST_DIR)
32+ @rm -f $(DIST_DIR)/*
33+ @echo "Building with version $(VERSION), commit $(COMMIT), date $(BUILD_DATE)"
34+ @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go
35+ @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go
36+ @echo "Go binaries built successfully"
37+
38+# Build and push Docker images
39+build-push: build
40+ @echo "Cleaning up dangling images..."
41+ @$(ENGINE) image prune -f
42+
43+ @echo "Building and pushing Docker images..."
44+ @for arch in $(ARCHS); do \
45+ cp $(DIST_DIR)/gordon-linux-$$arch gordon; \
46+ $(ENGINE) build -t $(REPO):$(TAG)-$$arch .; \
47+ rm gordon; \
48+ $(ENGINE) push $(REPO):$(TAG)-$$arch; \
49+ done
50+
51+ @echo "Removing existing manifest..."
52+ @$(ENGINE) manifest rm $(REPO):$(TAG) || true
53+
54+ @echo "Creating multi-arch manifest..."
55+ @$(ENGINE) manifest create $(REPO):$(TAG) \
56+ $(REPO):$(TAG)-amd64 \
57+ $(REPO):$(TAG)-arm64
58+
59+ @echo "Annotating arm64 image..."
60+ @$(ENGINE) manifest annotate $(REPO):$(TAG) \
61+ $(REPO):$(TAG)-arm64 --arch arm64 --variant v8
62+
63+ @echo "Inspecting manifest..."
64+ @$(ENGINE) manifest inspect $(REPO):$(TAG)
65+
66+ @echo "Pushing multi-arch manifest..."
67+ @$(ENGINE) manifest push --all $(REPO):$(TAG)
68+
69+ @echo "Script completed successfully."
70+
71+# Clean up
72+clean:
73+ @echo "Cleaning up..."
74+ @rm -rf $(DIST_DIR)
75+ @$(ENGINE) system prune -f
76+ @echo "Cleanup completed."⏎̸
@@ -0,0 +1,76 @@
1+# This Makefile is used for dev purposes
2+# Variables
3+REPO := ghcr.io/bnema/gordon
4+TAG := v2-dev
5+DIST_DIR := ./dist
6+ENGINE := podman
7+
8+# Version information
9+VERSION := $(shell git describe --tags --always --dirty)
10+COMMIT := $(shell git rev-parse --short HEAD)
11+BUILD_DATE := $(shell date -u '+%Y-%m-%d_%I:%M:%S%p')
12+
13+# Build flags
14+LDFLAGS := -s -w \
15+ -X main.version=$(VERSION) \
16+ -X main.commit=$(COMMIT) \
17+ -X main.date=$(BUILD_DATE)
18+
19+# Architectures
20+ARCHS := amd64 arm64
21+
22+# Phony targets
23+.PHONY: all build build-push clean
24+
25+# Default target
26+all: build
27+
28+# Build binaries
29+build:
30+ @echo "Building Go binaries..."
31+ @mkdir -p $(DIST_DIR)
32+ @rm -f $(DIST_DIR)/*
33+ @echo "Building with version $(VERSION), commit $(COMMIT), date $(BUILD_DATE)"
34+ @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go
35+ @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go
36+ @echo "Go binaries built successfully"
37+
38+# Build and push Docker images
39+build-push: build
40+ @echo "Cleaning up dangling images..."
41+ @$(ENGINE) image prune -f
42+
43+ @echo "Building and pushing Docker images..."
44+ @for arch in $(ARCHS); do \
45+ cp $(DIST_DIR)/gordon-linux-$$arch gordon; \
46+ $(ENGINE) build -t $(REPO):$(TAG)-$$arch .; \
47+ rm gordon; \
48+ $(ENGINE) push $(REPO):$(TAG)-$$arch; \
49+ done
50+
51+ @echo "Removing existing manifest..."
52+ @$(ENGINE) manifest rm $(REPO):$(TAG) || true
53+
54+ @echo "Creating multi-arch manifest..."
55+ @$(ENGINE) manifest create $(REPO):$(TAG) \
56+ $(REPO):$(TAG)-amd64 \
57+ $(REPO):$(TAG)-arm64
58+
59+ @echo "Annotating arm64 image..."
60+ @$(ENGINE) manifest annotate $(REPO):$(TAG) \
61+ $(REPO):$(TAG)-arm64 --arch arm64 --variant v8
62+
63+ @echo "Inspecting manifest..."
64+ @$(ENGINE) manifest inspect $(REPO):$(TAG)
65+
66+ @echo "Pushing multi-arch manifest..."
67+ @$(ENGINE) manifest push --all $(REPO):$(TAG)
68+
69+ @echo "Script completed successfully."
70+
71+# Clean up
72+clean:
73+ @echo "Cleaning up..."
74+ @rm -rf $(DIST_DIR)
75+ @$(ENGINE) system prune -f
76+ @echo "Cleanup completed."⏎̸
M README.md +30 -0
@@ -105,6 +105,8 @@ sudo mv gordon-linux-amd64 /usr/local/bin/gordon
105105 [server]
106106 port = 8080
107107 registry_domain = "registry.yourdomain.com"
108+runtime = "auto" # auto-detects Docker/Podman
109+ssl_email = "you@yourdomain.com"
108110
109111 [registry_auth]
110112 enabled = true
@@ -301,6 +303,34 @@ Your Machine → Docker Image → Gordon Registry → Auto-Deploy → Live App
301303
302304 ## 🚀 Advanced Usage
303305
306+### Container Runtime Configuration
307+```toml
308+[server]
309+runtime = "auto" # auto, docker, podman, podman-rootless
310+socket_path = "" # optional custom socket path
311+
312+# Examples:
313+# runtime = "docker"
314+# runtime = "podman"
315+# socket_path = "unix:///run/user/1000/podman/podman.sock"
316+```
317+
318+### Environment Override
319+```bash
320+# Override container socket with environment variable
321+export CONTAINER_HOST=unix:///custom/path/container.sock
322+export CONTAINER_HOST=tcp://remote-docker:2376
323+
324+# Works for both Docker and Podman
325+gordon start
326+```
327+
328+### Runtime Auto-Detection
329+Gordon automatically detects available container runtimes in this order:
330+1. **Docker** (`/var/run/docker.sock`)
331+2. **Podman root** (`/run/podman/podman.sock`)
332+3. **Podman rootless** (`$XDG_RUNTIME_DIR/podman/podman.sock`)
333+
304334 ### Custom Ports
305335 ```toml
306336 # Gordon auto-detects ports: 80, 8080, 3000
@@ -105,6 +105,8 @@ sudo mv gordon-linux-amd64 /usr/local/bin/gordon
105 [server]105 [server]
106 port = 8080106 port = 8080
107 registry_domain = "registry.yourdomain.com"107 registry_domain = "registry.yourdomain.com"
108+runtime = "auto" # auto-detects Docker/Podman
109+ssl_email = "you@yourdomain.com"
108 110
109 [registry_auth]111 [registry_auth]
110 enabled = true112 enabled = true
@@ -301,6 +303,34 @@ Your Machine → Docker Image → Gordon Registry → Auto-Deploy → Live App
301 303
302 ## 🚀 Advanced Usage304 ## 🚀 Advanced Usage
303 305
306+### Container Runtime Configuration
307+```toml
308+[server]
309+runtime = "auto" # auto, docker, podman, podman-rootless
310+socket_path = "" # optional custom socket path
311+
312+# Examples:
313+# runtime = "docker"
314+# runtime = "podman"
315+# socket_path = "unix:///run/user/1000/podman/podman.sock"
316+```
317+
318+### Environment Override
319+```bash
320+# Override container socket with environment variable
321+export CONTAINER_HOST=unix:///custom/path/container.sock
322+export CONTAINER_HOST=tcp://remote-docker:2376
323+
324+# Works for both Docker and Podman
325+gordon start
326+```
327+
328+### Runtime Auto-Detection
329+Gordon automatically detects available container runtimes in this order:
330+1. **Docker** (`/var/run/docker.sock`)
331+2. **Podman root** (`/run/podman/podman.sock`)
332+3. **Podman rootless** (`$XDG_RUNTIME_DIR/podman/podman.sock`)
333+
304 ### Custom Ports334 ### Custom Ports
305 ```toml335 ```toml
306 # Gordon auto-detects ports: 80, 8080, 3000336 # Gordon auto-detects ports: 80, 8080, 3000
M examples/README.md +22 -0
@@ -74,6 +74,28 @@ username = "your-username"
7474 password = "your-secure-password"
7575 ```
7676
77+### Container Runtime Setup
78+Gordon supports Docker and Podman with automatic detection:
79+```toml
80+[server]
81+runtime = "auto" # auto, docker, podman, podman-rootless
82+socket_path = "" # optional custom socket path
83+
84+# Examples:
85+# runtime = "docker" # Force Docker
86+# runtime = "podman" # Force Podman root
87+# runtime = "podman-rootless" # Force Podman rootless
88+# socket_path = "unix:///run/user/1000/podman/podman.sock"
89+```
90+
91+Override with environment variables:
92+```bash
93+# Works for both Docker and Podman
94+export CONTAINER_HOST=unix:///custom/path/container.sock
95+export CONTAINER_HOST=tcp://remote-docker:2376
96+gordon start
97+```
98+
7799 ### Environment Variables
78100 For production, use environment variables for sensitive data:
79101 ```bash
@@ -74,6 +74,28 @@ username = "your-username"
74 password = "your-secure-password"74 password = "your-secure-password"
75 ```75 ```
76 76
77+### Container Runtime Setup
78+Gordon supports Docker and Podman with automatic detection:
79+```toml
80+[server]
81+runtime = "auto" # auto, docker, podman, podman-rootless
82+socket_path = "" # optional custom socket path
83+
84+# Examples:
85+# runtime = "docker" # Force Docker
86+# runtime = "podman" # Force Podman root
87+# runtime = "podman-rootless" # Force Podman rootless
88+# socket_path = "unix:///run/user/1000/podman/podman.sock"
89+```
90+
91+Override with environment variables:
92+```bash
93+# Works for both Docker and Podman
94+export CONTAINER_HOST=unix:///custom/path/container.sock
95+export CONTAINER_HOST=tcp://remote-docker:2376
96+gordon start
97+```
98+
77 ### Environment Variables99 ### Environment Variables
78 For production, use environment variables for sensitive data:100 For production, use environment variables for sensitive data:
79 ```bash101 ```bash
M examples/development.toml +1 -1
@@ -5,7 +5,7 @@
55 port = 8080
66 registry_port = 5000
77 registry_domain = "localhost:8080" # Use localhost for local development
8-runtime = "docker"
8+runtime = "auto" # auto-detects Docker/Podman
99 ssl_email = "dev@localhost"
1010 data_dir = "./dev-data"
1111
@@ -5,7 +5,7 @@
5 port = 80805 port = 8080
6 registry_port = 50006 registry_port = 5000
7 registry_domain = "localhost:8080" # Use localhost for local development7 registry_domain = "localhost:8080" # Use localhost for local development
8-runtime = "docker"8+runtime = "auto" # auto-detects Docker/Podman
9 ssl_email = "dev@localhost"9 ssl_email = "dev@localhost"
10 data_dir = "./dev-data"10 data_dir = "./dev-data"
11 11
M examples/minimal.toml +1 -1
@@ -2,7 +2,7 @@
22 # Simplest possible setup to get started
33
44 [server]
5-runtime = "docker"
5+runtime = "auto" # auto-detects Docker/Podman
66 ssl_email = "you@example.com"
77
88 # Just one route - perfect for getting started
@@ -2,7 +2,7 @@
2 # Simplest possible setup to get started2 # Simplest possible setup to get started
3 3
4 [server]4 [server]
5-runtime = "docker"5+runtime = "auto" # auto-detects Docker/Podman
6 ssl_email = "you@example.com"6 ssl_email = "you@example.com"
7 7
8 # Just one route - perfect for getting started8 # Just one route - perfect for getting started
M examples/production.toml +2 -1
@@ -5,7 +5,8 @@
55 port = 8080
66 registry_port = 5000
77 registry_domain = "registry.company.com"
8-runtime = "docker"
8+runtime = "auto" # auto-detects Docker/Podman
9+# socket_path = "" # optional: override with custom socket path
910 ssl_email = "devops@company.com"
1011 data_dir = "/var/lib/gordon"
1112
@@ -5,7 +5,8 @@
5 port = 80805 port = 8080
6 registry_port = 50006 registry_port = 5000
7 registry_domain = "registry.company.com"7 registry_domain = "registry.company.com"
8-runtime = "docker"8+runtime = "auto" # auto-detects Docker/Podman
9+# socket_path = "" # optional: override with custom socket path
9 ssl_email = "devops@company.com"10 ssl_email = "devops@company.com"
10 data_dir = "/var/lib/gordon"11 data_dir = "/var/lib/gordon"
11 12
M examples/saas-multi-tenant.toml +1 -1
@@ -5,7 +5,7 @@
55 port = 8080
66 registry_port = 5000
77 registry_domain = "registry.saas-platform.com"
8-runtime = "docker"
8+runtime = "auto" # auto-detects Docker/Podman
99 ssl_email = "ops@saas-platform.com"
1010 data_dir = "/var/lib/gordon"
1111
@@ -5,7 +5,7 @@
5 port = 80805 port = 8080
6 registry_port = 50006 registry_port = 5000
7 registry_domain = "registry.saas-platform.com" 7 registry_domain = "registry.saas-platform.com"
8-runtime = "docker"8+runtime = "auto" # auto-detects Docker/Podman
9 ssl_email = "ops@saas-platform.com"9 ssl_email = "ops@saas-platform.com"
10 data_dir = "/var/lib/gordon"10 data_dir = "/var/lib/gordon"
11 11
M examples/staging.toml +1 -1
@@ -5,7 +5,7 @@
55 port = 8080
66 registry_port = 5000
77 registry_domain = "staging-registry.company.com"
8-runtime = "docker"
8+runtime = "auto" # auto-detects Docker/Podman
99 ssl_email = "devops@company.com"
1010 data_dir = "./data"
1111
@@ -5,7 +5,7 @@
5 port = 80805 port = 8080
6 registry_port = 50006 registry_port = 5000
7 registry_domain = "staging-registry.company.com"7 registry_domain = "staging-registry.company.com"
8-runtime = "docker"8+runtime = "auto" # auto-detects Docker/Podman
9 ssl_email = "devops@company.com"9 ssl_email = "devops@company.com"
10 data_dir = "./data"10 data_dir = "./data"
11 11
M go.mod +1 -0
@@ -9,6 +9,7 @@ require (
99 github.com/spf13/cobra v1.9.1
1010 github.com/spf13/viper v1.20.1
1111 )
12+
1213 require (
1314 github.com/Microsoft/go-winio v0.4.14 // indirect
1415 github.com/containerd/errdefs v1.0.0 // indirect
@@ -9,6 +9,7 @@ require (
9 github.com/spf13/cobra v1.9.19 github.com/spf13/cobra v1.9.1
10 github.com/spf13/viper v1.20.110 github.com/spf13/viper v1.20.1
11 )11 )
12+
12 require (13 require (
13 github.com/Microsoft/go-winio v0.4.14 // indirect14 github.com/Microsoft/go-winio v0.4.14 // indirect
14 github.com/containerd/errdefs v1.0.0 // indirect15 github.com/containerd/errdefs v1.0.0 // indirect
M go.sum +0 -2
@@ -39,8 +39,6 @@ github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
3939 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
4040 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
4141 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
42-github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
43-github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
4442 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4543 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
4644 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -39,8 +39,6 @@ github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
39 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=39 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
40 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=40 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
41 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=41 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
42-github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
43-github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
44 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=42 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
45 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=43 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
46 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=44 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
M internal/config/config.go +13 -3
@@ -18,6 +18,7 @@ type ServerConfig struct {
1818 RegistryPort int `mapstructure:"registry_port"`
1919 RegistryDomain string `mapstructure:"registry_domain"`
2020 Runtime string `mapstructure:"runtime"`
21+ SocketPath string `mapstructure:"socket_path"`
2122 SSLEmail string `mapstructure:"ssl_email"`
2223 DataDir string `mapstructure:"data_dir"`
2324 }
@@ -40,7 +41,8 @@ func Load() (*Config, error) {
4041 // Set defaults
4142 viper.SetDefault("server.port", 8080)
4243 viper.SetDefault("server.registry_port", 5000)
43- viper.SetDefault("server.runtime", "docker")
44+ viper.SetDefault("server.runtime", "auto")
45+ viper.SetDefault("server.socket_path", "")
4446 viper.SetDefault("server.data_dir", "./data")
4547 viper.SetDefault("registry_auth.enabled", true)
4648
@@ -74,8 +76,16 @@ func Load() (*Config, error) {
7476 return nil, fmt.Errorf("server.ssl_email is required")
7577 }
7678
77- if cfg.Server.Runtime != "docker" && cfg.Server.Runtime != "podman" {
78- return nil, fmt.Errorf("server.runtime must be 'docker' or 'podman'")
79+ validRuntimes := []string{"auto", "docker", "podman", "podman-rootless"}
80+ isValid := false
81+ for _, valid := range validRuntimes {
82+ if cfg.Server.Runtime == valid {
83+ isValid = true
84+ break
85+ }
86+ }
87+ if !isValid {
88+ return nil, fmt.Errorf("server.runtime must be one of: %s", strings.Join(validRuntimes, ", "))
7989 }
8090
8191 // Validate registry auth config
@@ -18,6 +18,7 @@ type ServerConfig struct {
18 RegistryPort int `mapstructure:"registry_port"`18 RegistryPort int `mapstructure:"registry_port"`
19 RegistryDomain string `mapstructure:"registry_domain"`19 RegistryDomain string `mapstructure:"registry_domain"`
20 Runtime string `mapstructure:"runtime"`20 Runtime string `mapstructure:"runtime"`
21+ SocketPath string `mapstructure:"socket_path"`
21 SSLEmail string `mapstructure:"ssl_email"`22 SSLEmail string `mapstructure:"ssl_email"`
22 DataDir string `mapstructure:"data_dir"`23 DataDir string `mapstructure:"data_dir"`
23 }24 }
@@ -40,7 +41,8 @@ func Load() (*Config, error) {
40 // Set defaults41 // Set defaults
41 viper.SetDefault("server.port", 8080)42 viper.SetDefault("server.port", 8080)
42 viper.SetDefault("server.registry_port", 5000)43 viper.SetDefault("server.registry_port", 5000)
43- viper.SetDefault("server.runtime", "docker")44+ viper.SetDefault("server.runtime", "auto")
45+ viper.SetDefault("server.socket_path", "")
44 viper.SetDefault("server.data_dir", "./data")46 viper.SetDefault("server.data_dir", "./data")
45 viper.SetDefault("registry_auth.enabled", true)47 viper.SetDefault("registry_auth.enabled", true)
46 48
@@ -74,8 +76,16 @@ func Load() (*Config, error) {
74 return nil, fmt.Errorf("server.ssl_email is required")76 return nil, fmt.Errorf("server.ssl_email is required")
75 }77 }
76 78
77- if cfg.Server.Runtime != "docker" && cfg.Server.Runtime != "podman" {79+ validRuntimes := []string{"auto", "docker", "podman", "podman-rootless"}
78- return nil, fmt.Errorf("server.runtime must be 'docker' or 'podman'")80+ isValid := false
81+ for _, valid := range validRuntimes {
82+ if cfg.Server.Runtime == valid {
83+ isValid = true
84+ break
85+ }
86+ }
87+ if !isValid {
88+ return nil, fmt.Errorf("server.runtime must be one of: %s", strings.Join(validRuntimes, ", "))
79 }89 }
80 90
81 // Validate registry auth config91 // Validate registry auth config
A internal/container/factory.go +268 -0
@@ -0,0 +1,268 @@
1+package container
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/user"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/docker/docker/client"
12+ "github.com/rs/zerolog/log"
13+
14+ "gordon/internal/config"
15+ "gordon/pkg/runtime"
16+)
17+
18+// RuntimeType represents the container runtime type
19+type RuntimeType string
20+
21+const (
22+ RuntimeAuto RuntimeType = "auto"
23+ RuntimeDocker RuntimeType = "docker"
24+ RuntimePodman RuntimeType = "podman"
25+ RuntimePodmanRootless RuntimeType = "podman-rootless"
26+)
27+
28+// RuntimeInfo contains information about a detected runtime
29+type RuntimeInfo struct {
30+ Type RuntimeType
31+ SocketPath string
32+ Version string
33+}
34+
35+// CreateRuntime creates a container runtime based on configuration
36+func CreateRuntime(cfg *config.Config) (runtime.Runtime, error) {
37+ runtimeType := RuntimeType(cfg.Server.Runtime)
38+
39+ // Get socket path from config or environment
40+ socketPath := getSocketPath(cfg.Server.SocketPath)
41+
42+ switch runtimeType {
43+ case RuntimeAuto:
44+ return createAutoRuntime(socketPath)
45+ case RuntimeDocker:
46+ return createDockerRuntime(socketPath)
47+ case RuntimePodman, RuntimePodmanRootless:
48+ return createPodmanRuntime(socketPath, runtimeType == RuntimePodmanRootless)
49+ default:
50+ return nil, fmt.Errorf("unsupported runtime: %s", runtimeType)
51+ }
52+}
53+
54+// getSocketPath determines the socket path to use
55+func getSocketPath(configPath string) string {
56+ // 1. Use config if provided
57+ if configPath != "" {
58+ log.Debug().Str("path", configPath).Msg("Using socket path from config")
59+ return configPath
60+ }
61+
62+ // 2. Check CONTAINER_HOST environment variable
63+ if envPath := os.Getenv("CONTAINER_HOST"); envPath != "" {
64+ log.Debug().Str("path", envPath).Msg("Using socket path from CONTAINER_HOST")
65+ return envPath
66+ }
67+
68+ // 3. Return empty string for auto-detection
69+ return ""
70+}
71+
72+// createAutoRuntime automatically detects and creates the best available runtime
73+func createAutoRuntime(customSocketPath string) (runtime.Runtime, error) {
74+ var candidatePaths []string
75+
76+ if customSocketPath != "" {
77+ // If custom path provided, try it first
78+ candidatePaths = []string{customSocketPath}
79+ } else {
80+ // Auto-detect socket paths
81+ candidatePaths = getDefaultSocketPaths()
82+ }
83+
84+ // Try each socket path
85+ for _, socketPath := range candidatePaths {
86+ if rt, info, err := tryCreateRuntime(socketPath); err == nil {
87+ log.Info().
88+ Str("runtime", string(info.Type)).
89+ Str("socket", info.SocketPath).
90+ Str("version", info.Version).
91+ Msg("Container runtime auto-detected")
92+ return rt, nil
93+ }
94+ }
95+
96+ return nil, fmt.Errorf("no container runtime found. Tried paths: %v", candidatePaths)
97+}
98+
99+// createDockerRuntime creates a Docker runtime with optional custom socket
100+func createDockerRuntime(socketPath string) (runtime.Runtime, error) {
101+ var opts []client.Opt
102+ opts = append(opts, client.FromEnv, client.WithAPIVersionNegotiation())
103+
104+ if socketPath != "" {
105+ opts = append(opts, client.WithHost(socketPath))
106+ }
107+
108+ cli, err := client.NewClientWithOpts(opts...)
109+ if err != nil {
110+ return nil, fmt.Errorf("failed to create Docker client: %w", err)
111+ }
112+
113+ // Test connectivity
114+ ctx := context.Background()
115+ if _, err := cli.Ping(ctx); err != nil {
116+ return nil, fmt.Errorf("Docker ping failed: %w", err)
117+ }
118+
119+ return &DockerRuntime{client: cli}, nil
120+}
121+
122+// createPodmanRuntime creates a Podman runtime with optional custom socket
123+func createPodmanRuntime(socketPath string, rootless bool) (runtime.Runtime, error) {
124+ if socketPath == "" {
125+ socketPath = getDefaultPodmanSocket(rootless)
126+ }
127+
128+ return createPodmanRuntimeWithSocket(socketPath)
129+}
130+
131+// tryCreateRuntime attempts to create a runtime for a given socket path
132+func tryCreateRuntime(socketPath string) (runtime.Runtime, *RuntimeInfo, error) {
133+ // Try to create client
134+ opts := []client.Opt{
135+ client.WithHost(socketPath),
136+ client.WithAPIVersionNegotiation(),
137+ }
138+
139+ cli, err := client.NewClientWithOpts(opts...)
140+ if err != nil {
141+ return nil, nil, fmt.Errorf("failed to create client: %w", err)
142+ }
143+
144+ // Test connectivity and get version
145+ ctx := context.Background()
146+ ping, err := cli.Ping(ctx)
147+ if err != nil {
148+ return nil, nil, fmt.Errorf("ping failed: %w", err)
149+ }
150+
151+ // Determine runtime type based on socket path and server info
152+ runtimeType := detectRuntimeType(socketPath, ping.APIVersion)
153+
154+ var rt runtime.Runtime
155+ if runtimeType == RuntimeDocker {
156+ rt = &DockerRuntime{client: cli}
157+ } else {
158+ rt = &PodmanRuntime{client: cli}
159+ }
160+
161+ info := &RuntimeInfo{
162+ Type: runtimeType,
163+ SocketPath: socketPath,
164+ Version: ping.APIVersion,
165+ }
166+
167+ return rt, info, nil
168+}
169+
170+// detectRuntimeType determines if the socket is Docker or Podman
171+func detectRuntimeType(socketPath, apiVersion string) RuntimeType {
172+ // Check socket path patterns
173+ if strings.Contains(socketPath, "podman") {
174+ if strings.Contains(socketPath, "/run/user/") || strings.Contains(socketPath, "$XDG_RUNTIME_DIR") {
175+ return RuntimePodmanRootless
176+ }
177+ return RuntimePodman
178+ }
179+
180+ if strings.Contains(socketPath, "docker") {
181+ return RuntimeDocker
182+ }
183+
184+ // Default socket paths
185+ switch socketPath {
186+ case "/var/run/docker.sock", "unix:///var/run/docker.sock":
187+ return RuntimeDocker
188+ case "/run/podman/podman.sock", "unix:///run/podman/podman.sock":
189+ return RuntimePodman
190+ }
191+
192+ // Check if it's in user runtime directory (rootless)
193+ if strings.Contains(socketPath, "/run/user/") {
194+ return RuntimePodmanRootless
195+ }
196+
197+ // Default to Docker for unknown paths
198+ return RuntimeDocker
199+}
200+
201+// getDefaultSocketPaths returns the default socket paths to try
202+func getDefaultSocketPaths() []string {
203+ paths := []string{
204+ "unix:///var/run/docker.sock", // Docker
205+ "unix:///run/podman/podman.sock", // Podman root
206+ }
207+
208+ // Add Podman rootless socket
209+ if rootlessSocket := getDefaultPodmanSocket(true); rootlessSocket != "" {
210+ paths = append(paths, rootlessSocket)
211+ }
212+
213+ return paths
214+}
215+
216+// getDefaultPodmanSocket returns the default Podman socket path
217+func getDefaultPodmanSocket(rootless bool) string {
218+ if !rootless {
219+ return "unix:///run/podman/podman.sock"
220+ }
221+
222+ // Get user runtime directory for rootless Podman
223+ if xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR"); xdgRuntimeDir != "" {
224+ return fmt.Sprintf("unix://%s/podman/podman.sock", xdgRuntimeDir)
225+ }
226+
227+ // Fallback: construct from user ID
228+ if currentUser, err := user.Current(); err == nil {
229+ return fmt.Sprintf("unix:///run/user/%s/podman/podman.sock", currentUser.Uid)
230+ }
231+
232+ log.Warn().Msg("Could not determine rootless Podman socket path")
233+ return ""
234+}
235+
236+// createPodmanRuntimeWithSocket creates a Podman runtime for a specific socket
237+func createPodmanRuntimeWithSocket(socketPath string) (runtime.Runtime, error) {
238+ opts := []client.Opt{
239+ client.WithHost(socketPath),
240+ client.WithAPIVersionNegotiation(),
241+ }
242+
243+ cli, err := client.NewClientWithOpts(opts...)
244+ if err != nil {
245+ return nil, fmt.Errorf("failed to create Podman client: %w", err)
246+ }
247+
248+ // Test connectivity
249+ ctx := context.Background()
250+ if _, err := cli.Ping(ctx); err != nil {
251+ return nil, fmt.Errorf("Podman ping failed: %w", err)
252+ }
253+
254+ return &PodmanRuntime{client: cli}, nil
255+}
256+
257+// GetAvailableRuntimes returns information about all available runtimes
258+func GetAvailableRuntimes() []RuntimeInfo {
259+ var runtimes []RuntimeInfo
260+
261+ for _, socketPath := range getDefaultSocketPaths() {
262+ if _, info, err := tryCreateRuntime(socketPath); err == nil {
263+ runtimes = append(runtimes, *info)
264+ }
265+ }
266+
267+ return runtimes
268+}⏎̸
@@ -0,0 +1,268 @@
1+package container
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/user"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/docker/docker/client"
12+ "github.com/rs/zerolog/log"
13+
14+ "gordon/internal/config"
15+ "gordon/pkg/runtime"
16+)
17+
18+// RuntimeType represents the container runtime type
19+type RuntimeType string
20+
21+const (
22+ RuntimeAuto RuntimeType = "auto"
23+ RuntimeDocker RuntimeType = "docker"
24+ RuntimePodman RuntimeType = "podman"
25+ RuntimePodmanRootless RuntimeType = "podman-rootless"
26+)
27+
28+// RuntimeInfo contains information about a detected runtime
29+type RuntimeInfo struct {
30+ Type RuntimeType
31+ SocketPath string
32+ Version string
33+}
34+
35+// CreateRuntime creates a container runtime based on configuration
36+func CreateRuntime(cfg *config.Config) (runtime.Runtime, error) {
37+ runtimeType := RuntimeType(cfg.Server.Runtime)
38+
39+ // Get socket path from config or environment
40+ socketPath := getSocketPath(cfg.Server.SocketPath)
41+
42+ switch runtimeType {
43+ case RuntimeAuto:
44+ return createAutoRuntime(socketPath)
45+ case RuntimeDocker:
46+ return createDockerRuntime(socketPath)
47+ case RuntimePodman, RuntimePodmanRootless:
48+ return createPodmanRuntime(socketPath, runtimeType == RuntimePodmanRootless)
49+ default:
50+ return nil, fmt.Errorf("unsupported runtime: %s", runtimeType)
51+ }
52+}
53+
54+// getSocketPath determines the socket path to use
55+func getSocketPath(configPath string) string {
56+ // 1. Use config if provided
57+ if configPath != "" {
58+ log.Debug().Str("path", configPath).Msg("Using socket path from config")
59+ return configPath
60+ }
61+
62+ // 2. Check CONTAINER_HOST environment variable
63+ if envPath := os.Getenv("CONTAINER_HOST"); envPath != "" {
64+ log.Debug().Str("path", envPath).Msg("Using socket path from CONTAINER_HOST")
65+ return envPath
66+ }
67+
68+ // 3. Return empty string for auto-detection
69+ return ""
70+}
71+
72+// createAutoRuntime automatically detects and creates the best available runtime
73+func createAutoRuntime(customSocketPath string) (runtime.Runtime, error) {
74+ var candidatePaths []string
75+
76+ if customSocketPath != "" {
77+ // If custom path provided, try it first
78+ candidatePaths = []string{customSocketPath}
79+ } else {
80+ // Auto-detect socket paths
81+ candidatePaths = getDefaultSocketPaths()
82+ }
83+
84+ // Try each socket path
85+ for _, socketPath := range candidatePaths {
86+ if rt, info, err := tryCreateRuntime(socketPath); err == nil {
87+ log.Info().
88+ Str("runtime", string(info.Type)).
89+ Str("socket", info.SocketPath).
90+ Str("version", info.Version).
91+ Msg("Container runtime auto-detected")
92+ return rt, nil
93+ }
94+ }
95+
96+ return nil, fmt.Errorf("no container runtime found. Tried paths: %v", candidatePaths)
97+}
98+
99+// createDockerRuntime creates a Docker runtime with optional custom socket
100+func createDockerRuntime(socketPath string) (runtime.Runtime, error) {
101+ var opts []client.Opt
102+ opts = append(opts, client.FromEnv, client.WithAPIVersionNegotiation())
103+
104+ if socketPath != "" {
105+ opts = append(opts, client.WithHost(socketPath))
106+ }
107+
108+ cli, err := client.NewClientWithOpts(opts...)
109+ if err != nil {
110+ return nil, fmt.Errorf("failed to create Docker client: %w", err)
111+ }
112+
113+ // Test connectivity
114+ ctx := context.Background()
115+ if _, err := cli.Ping(ctx); err != nil {
116+ return nil, fmt.Errorf("Docker ping failed: %w", err)
117+ }
118+
119+ return &DockerRuntime{client: cli}, nil
120+}
121+
122+// createPodmanRuntime creates a Podman runtime with optional custom socket
123+func createPodmanRuntime(socketPath string, rootless bool) (runtime.Runtime, error) {
124+ if socketPath == "" {
125+ socketPath = getDefaultPodmanSocket(rootless)
126+ }
127+
128+ return createPodmanRuntimeWithSocket(socketPath)
129+}
130+
131+// tryCreateRuntime attempts to create a runtime for a given socket path
132+func tryCreateRuntime(socketPath string) (runtime.Runtime, *RuntimeInfo, error) {
133+ // Try to create client
134+ opts := []client.Opt{
135+ client.WithHost(socketPath),
136+ client.WithAPIVersionNegotiation(),
137+ }
138+
139+ cli, err := client.NewClientWithOpts(opts...)
140+ if err != nil {
141+ return nil, nil, fmt.Errorf("failed to create client: %w", err)
142+ }
143+
144+ // Test connectivity and get version
145+ ctx := context.Background()
146+ ping, err := cli.Ping(ctx)
147+ if err != nil {
148+ return nil, nil, fmt.Errorf("ping failed: %w", err)
149+ }
150+
151+ // Determine runtime type based on socket path and server info
152+ runtimeType := detectRuntimeType(socketPath, ping.APIVersion)
153+
154+ var rt runtime.Runtime
155+ if runtimeType == RuntimeDocker {
156+ rt = &DockerRuntime{client: cli}
157+ } else {
158+ rt = &PodmanRuntime{client: cli}
159+ }
160+
161+ info := &RuntimeInfo{
162+ Type: runtimeType,
163+ SocketPath: socketPath,
164+ Version: ping.APIVersion,
165+ }
166+
167+ return rt, info, nil
168+}
169+
170+// detectRuntimeType determines if the socket is Docker or Podman
171+func detectRuntimeType(socketPath, apiVersion string) RuntimeType {
172+ // Check socket path patterns
173+ if strings.Contains(socketPath, "podman") {
174+ if strings.Contains(socketPath, "/run/user/") || strings.Contains(socketPath, "$XDG_RUNTIME_DIR") {
175+ return RuntimePodmanRootless
176+ }
177+ return RuntimePodman
178+ }
179+
180+ if strings.Contains(socketPath, "docker") {
181+ return RuntimeDocker
182+ }
183+
184+ // Default socket paths
185+ switch socketPath {
186+ case "/var/run/docker.sock", "unix:///var/run/docker.sock":
187+ return RuntimeDocker
188+ case "/run/podman/podman.sock", "unix:///run/podman/podman.sock":
189+ return RuntimePodman
190+ }
191+
192+ // Check if it's in user runtime directory (rootless)
193+ if strings.Contains(socketPath, "/run/user/") {
194+ return RuntimePodmanRootless
195+ }
196+
197+ // Default to Docker for unknown paths
198+ return RuntimeDocker
199+}
200+
201+// getDefaultSocketPaths returns the default socket paths to try
202+func getDefaultSocketPaths() []string {
203+ paths := []string{
204+ "unix:///var/run/docker.sock", // Docker
205+ "unix:///run/podman/podman.sock", // Podman root
206+ }
207+
208+ // Add Podman rootless socket
209+ if rootlessSocket := getDefaultPodmanSocket(true); rootlessSocket != "" {
210+ paths = append(paths, rootlessSocket)
211+ }
212+
213+ return paths
214+}
215+
216+// getDefaultPodmanSocket returns the default Podman socket path
217+func getDefaultPodmanSocket(rootless bool) string {
218+ if !rootless {
219+ return "unix:///run/podman/podman.sock"
220+ }
221+
222+ // Get user runtime directory for rootless Podman
223+ if xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR"); xdgRuntimeDir != "" {
224+ return fmt.Sprintf("unix://%s/podman/podman.sock", xdgRuntimeDir)
225+ }
226+
227+ // Fallback: construct from user ID
228+ if currentUser, err := user.Current(); err == nil {
229+ return fmt.Sprintf("unix:///run/user/%s/podman/podman.sock", currentUser.Uid)
230+ }
231+
232+ log.Warn().Msg("Could not determine rootless Podman socket path")
233+ return ""
234+}
235+
236+// createPodmanRuntimeWithSocket creates a Podman runtime for a specific socket
237+func createPodmanRuntimeWithSocket(socketPath string) (runtime.Runtime, error) {
238+ opts := []client.Opt{
239+ client.WithHost(socketPath),
240+ client.WithAPIVersionNegotiation(),
241+ }
242+
243+ cli, err := client.NewClientWithOpts(opts...)
244+ if err != nil {
245+ return nil, fmt.Errorf("failed to create Podman client: %w", err)
246+ }
247+
248+ // Test connectivity
249+ ctx := context.Background()
250+ if _, err := cli.Ping(ctx); err != nil {
251+ return nil, fmt.Errorf("Podman ping failed: %w", err)
252+ }
253+
254+ return &PodmanRuntime{client: cli}, nil
255+}
256+
257+// GetAvailableRuntimes returns information about all available runtimes
258+func GetAvailableRuntimes() []RuntimeInfo {
259+ var runtimes []RuntimeInfo
260+
261+ for _, socketPath := range getDefaultSocketPaths() {
262+ if _, info, err := tryCreateRuntime(socketPath); err == nil {
263+ runtimes = append(runtimes, *info)
264+ }
265+ }
266+
267+ return runtimes
268+}⏎̸
M internal/container/manager.go +4 -14
@@ -21,20 +21,10 @@ type Manager struct {
2121
2222 // NewManager creates a new container manager
2323 func NewManager(cfg *config.Config) (*Manager, error) {
24- var rt runtime.Runtime
25- var err error
26-
27- switch cfg.Server.Runtime {
28- case "docker":
29- rt, err = NewDockerRuntime()
30- if err != nil {
31- return nil, fmt.Errorf("failed to create Docker runtime: %w", err)
32- }
33- case "podman":
34- // TODO: Implement Podman runtime
35- return nil, fmt.Errorf("Podman runtime not yet implemented")
36- default:
37- return nil, fmt.Errorf("unsupported runtime: %s", cfg.Server.Runtime)
24+ // Create runtime using the factory
25+ rt, err := CreateRuntime(cfg)
26+ if err != nil {
27+ return nil, fmt.Errorf("failed to create container runtime: %w", err)
3828 }
3929
4030 // Test runtime connectivity
@@ -21,20 +21,10 @@ type Manager struct {
21 21
22 // NewManager creates a new container manager22 // NewManager creates a new container manager
23 func NewManager(cfg *config.Config) (*Manager, error) {23 func NewManager(cfg *config.Config) (*Manager, error) {
24- var rt runtime.Runtime24+ // Create runtime using the factory
25- var err error25+ rt, err := CreateRuntime(cfg)
26-26+ if err != nil {
27- switch cfg.Server.Runtime {27+ return nil, fmt.Errorf("failed to create container runtime: %w", err)
28- case "docker":
29- rt, err = NewDockerRuntime()
30- if err != nil {
31- return nil, fmt.Errorf("failed to create Docker runtime: %w", err)
32- }
33- case "podman":
34- // TODO: Implement Podman runtime
35- return nil, fmt.Errorf("Podman runtime not yet implemented")
36- default:
37- return nil, fmt.Errorf("unsupported runtime: %s", cfg.Server.Runtime)
38 }28 }
39 29
40 // Test runtime connectivity30 // Test runtime connectivity
A internal/container/podman.go +431 -0
@@ -0,0 +1,431 @@
1+package container
2+
3+import (
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
21+type PodmanRuntime struct {
22+ client *client.Client
23+}
24+
25+// NewPodmanRuntime creates a new Podman runtime instance
26+func 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
43+func (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
89+func (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
100+func (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
112+func (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
124+func (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
135+func (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
171+func (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
205+func (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
220+func (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
240+func (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
251+func (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
270+func (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
279+func (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
288+func (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
297+func (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
322+func (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
350+func (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
371+func (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+}⏎̸
@@ -0,0 +1,431 @@
1+package container
2+
3+import (
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
21+type PodmanRuntime struct {
22+ client *client.Client
23+}
24+
25+// NewPodmanRuntime creates a new Podman runtime instance
26+func 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
43+func (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
89+func (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
100+func (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
112+func (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
124+func (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
135+func (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
171+func (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
205+func (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
220+func (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
240+func (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
251+func (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
270+func (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
279+func (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
288+func (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
297+func (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
322+func (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
350+func (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
371+func (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+}⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close