Merge pull request #160 from bnema/fix/podman-rootless-friction
fix(podman): resolve named runtimes, detect container crashes, update docs
7 files · +226 -22
M docs/config/index.md +25 -13
| @@ -220,19 +220,31 @@ Gordon watches the configuration file and reloads automatically when changes are | |||
| 220 | 220 | gordon reload | |
| 221 | 221 | ``` | |
| 222 | 222 | ||
| 223 | - | The following settings require a restart to take effect: | |
| 224 | - | ||
| 225 | - | - `server.port` | |
| 226 | - | - `server.registry_port` | |
| 227 | - | - `server.data_dir` | |
| 228 | - | - `auth` settings | |
| 229 | - | - `deploy.pull_policy` | |
| 230 | - | - `deploy.readiness_mode` | |
| 231 | - | - `deploy.health_timeout` | |
| 232 | - | - `deploy.readiness_delay` | |
| 233 | - | - `deploy.drain_mode` | |
| 234 | - | - `deploy.drain_timeout` | |
| 235 | - | - `deploy.drain_delay` | |
| 223 | + | ### Hot-reloaded (no restart needed) | |
| 224 | + | ||
| 225 | + | | Setting | | |
| 226 | + | |---------| | |
| 227 | + | | `server.gordon_domain` (registry domain) | | |
| 228 | + | | `server.registry_port` | | |
| 229 | + | | `server.max_proxy_body_size` | | |
| 230 | + | | `server.max_blob_chunk_size` | | |
| 231 | + | | `server.max_proxy_response_size` | | |
| 232 | + | | `server.max_concurrent_conns` | | |
| 233 | + | ||
| 234 | + | > **Note:** Routes are not hot-reloaded from the config file. Use the API or CLI (`gordon routes add/update/remove`) to manage routes at runtime. | |
| 235 | + | ||
| 236 | + | ### Requires restart | |
| 237 | + | ||
| 238 | + | | Setting | | |
| 239 | + | |---------| | |
| 240 | + | | `server.port` | | |
| 241 | + | | `server.data_dir` | | |
| 242 | + | | `auth.*` | | |
| 243 | + | | `deploy.readiness_mode` | | |
| 244 | + | | `deploy.readiness_delay` | | |
| 245 | + | | `deploy.health_timeout` | | |
| 246 | + | | `deploy.drain_mode` | | |
| 247 | + | | `deploy.drain_timeout` | | |
| 236 | 248 | ||
| 237 | 249 | ## Environment Variable Override | |
| 238 | 250 | ||
| @@ -220,19 +220,31 @@ Gordon watches the configuration file and reloads automatically when changes are | |||||
| 220 | gordon reload | 220 | gordon reload | ||
| 221 | ``` | 221 | ``` | ||
| 222 | 222 | ||||
| 223 | - | The following settings require a restart to take effect: | 223 | + | ### Hot-reloaded (no restart needed) |
| 224 | - | 224 | + | ||
| 225 | - | - `server.port` | 225 | + | | Setting | |
| 226 | - | - `server.registry_port` | 226 | + | |---------| |
| 227 | - | - `server.data_dir` | 227 | + | | `server.gordon_domain` (registry domain) | |
| 228 | - | - `auth` settings | 228 | + | | `server.registry_port` | |
| 229 | - | - `deploy.pull_policy` | 229 | + | | `server.max_proxy_body_size` | |
| 230 | - | - `deploy.readiness_mode` | 230 | + | | `server.max_blob_chunk_size` | |
| 231 | - | - `deploy.health_timeout` | 231 | + | | `server.max_proxy_response_size` | |
| 232 | - | - `deploy.readiness_delay` | 232 | + | | `server.max_concurrent_conns` | |
| 233 | - | - `deploy.drain_mode` | 233 | + | |
| 234 | - | - `deploy.drain_timeout` | 234 | + | > **Note:** Routes are not hot-reloaded from the config file. Use the API or CLI (`gordon routes add/update/remove`) to manage routes at runtime. |
| 235 | - | - `deploy.drain_delay` | 235 | + | |
| 236 | + | ### Requires restart | |||
| 237 | + | ||||
| 238 | + | | Setting | | |||
| 239 | + | |---------| | |||
| 240 | + | | `server.port` | | |||
| 241 | + | | `server.data_dir` | | |||
| 242 | + | | `auth.*` | | |||
| 243 | + | | `deploy.readiness_mode` | | |||
| 244 | + | | `deploy.readiness_delay` | | |||
| 245 | + | | `deploy.health_timeout` | | |||
| 246 | + | | `deploy.drain_mode` | | |||
| 247 | + | | `deploy.drain_timeout` | | |||
| 236 | 248 | ||||
| 237 | ## Environment Variable Override | 249 | ## Environment Variable Override | ||
| 238 | 250 | ||||
M docs/installation.md +26 -7
| @@ -104,11 +104,12 @@ sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER | |||
| 104 | 104 | # Enable Podman socket | |
| 105 | 105 | systemctl --user enable --now podman.socket | |
| 106 | 106 | ||
| 107 | - | # Configure insecure localhost registry | |
| 108 | - | mkdir -p ~/.config/containers | |
| 109 | - | cat > ~/.config/containers/registries.conf <<EOF | |
| 110 | - | [registries.insecure] | |
| 111 | - | registries = ['localhost:5000'] | |
| 107 | + | # Configure insecure localhost registry (required — Podman blocks HTTP registries by default) | |
| 108 | + | mkdir -p ~/.config/containers/registries.conf.d | |
| 109 | + | cat > ~/.config/containers/registries.conf.d/gordon.conf <<EOF | |
| 110 | + | [[registry]] | |
| 111 | + | location = "localhost:5000" | |
| 112 | + | insecure = true | |
| 112 | 113 | EOF | |
| 113 | 114 | ``` | |
| 114 | 115 | ||
| @@ -128,7 +129,7 @@ sudo firewall-cmd --permanent --add-service=http | |||
| 128 | 129 | sudo firewall-cmd --permanent --add-service=https | |
| 129 | 130 | ||
| 130 | 131 | # For rootless containers, redirect privileged ports | |
| 131 | - | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080 | |
| 132 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 | |
| 132 | 133 | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | |
| 133 | 134 | ||
| 134 | 135 | # Apply changes | |
| @@ -164,7 +165,7 @@ Minimum required configuration: | |||
| 164 | 165 | ||
| 165 | 166 | ```toml | |
| 166 | 167 | [server] | |
| 167 | - | port = 8080 | |
| 168 | + | port = 8088 | |
| 168 | 169 | registry_port = 5000 | |
| 169 | 170 | gordon_domain = "gordon.yourdomain.com" | |
| 170 | 171 | ||
| @@ -189,12 +190,17 @@ After=podman.socket | |||
| 189 | 190 | Type=simple | |
| 190 | 191 | Restart=always | |
| 191 | 192 | RestartSec=5 | |
| 193 | + | # Required for Podman rootless — tells Gordon where to find the Podman socket | |
| 194 | + | Environment=XDG_RUNTIME_DIR=/run/user/%U | |
| 195 | + | Environment=DOCKER_HOST=unix:///run/user/%U/podman/podman.sock | |
| 192 | 196 | ExecStart=/usr/local/bin/gordon serve | |
| 193 | 197 | ||
| 194 | 198 | [Install] | |
| 195 | 199 | WantedBy=default.target | |
| 196 | 200 | EOF | |
| 197 | 201 | ||
| 202 | + | > **Docker users:** Omit the two `Environment=` lines above — Docker's socket path is set automatically. | |
| 203 | + | ||
| 198 | 204 | # Enable and start | |
| 199 | 205 | systemctl --user daemon-reload | |
| 200 | 206 | systemctl --user enable --now gordon | |
| @@ -241,6 +247,19 @@ Point your domains to your server: | |||
| 241 | 247 | ||
| 242 | 248 | > **Note:** Enable Cloudflare proxy (orange cloud) for automatic HTTPS. Gordon receives HTTP traffic from Cloudflare. | |
| 243 | 249 | ||
| 250 | + | ## Cloudflare SSL Mode | |
| 251 | + | ||
| 252 | + | Gordon serves HTTP by default. Cloudflare must connect to your origin over HTTP unless you explicitly enable TLS. | |
| 253 | + | ||
| 254 | + | | Mode | How it works | Use when | | |
| 255 | + | |------|-------------|----------| | |
| 256 | + | | **Flexible** | Cloudflare terminates TLS; connects to Gordon on HTTP | Gordon has no TLS cert (default setup) | | |
| 257 | + | | **Full (Strict)** | Cloudflare connects to Gordon over HTTPS with a valid cert | Set `server.tls_port` (default 8443) and provide a Cloudflare Origin CA cert via `server.tls_cert_file` and `server.tls_key_file` | | |
| 258 | + | ||
| 259 | + | Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed). | |
| 260 | + | ||
| 261 | + | > **Rootless note:** Unprivileged users can't bind port 80. Forward port 80→8088 (or your configured port) via firewall — see the firewall section above. | |
| 262 | + | ||
| 244 | 263 | ## Verify Installation | |
| 245 | 264 | ||
| 246 | 265 | ```bash | |
| @@ -104,11 +104,12 @@ sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER | |||||
| 104 | # Enable Podman socket | 104 | # Enable Podman socket | ||
| 105 | systemctl --user enable --now podman.socket | 105 | systemctl --user enable --now podman.socket | ||
| 106 | 106 | ||||
| 107 | - | # Configure insecure localhost registry | 107 | + | # Configure insecure localhost registry (required — Podman blocks HTTP registries by default) |
| 108 | - | mkdir -p ~/.config/containers | 108 | + | mkdir -p ~/.config/containers/registries.conf.d |
| 109 | - | cat > ~/.config/containers/registries.conf <<EOF | 109 | + | cat > ~/.config/containers/registries.conf.d/gordon.conf <<EOF |
| 110 | - | [registries.insecure] | 110 | + | [[registry]] |
| 111 | - | registries = ['localhost:5000'] | 111 | + | location = "localhost:5000" |
| 112 | + | insecure = true | |||
| 112 | EOF | 113 | EOF | ||
| 113 | ``` | 114 | ``` | ||
| 114 | 115 | ||||
| @@ -128,7 +129,7 @@ sudo firewall-cmd --permanent --add-service=http | |||||
| 128 | sudo firewall-cmd --permanent --add-service=https | 129 | sudo firewall-cmd --permanent --add-service=https | ||
| 129 | 130 | ||||
| 130 | # For rootless containers, redirect privileged ports | 131 | # For rootless containers, redirect privileged ports | ||
| 131 | - | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080 | 132 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 |
| 132 | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | 133 | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | ||
| 133 | 134 | ||||
| 134 | # Apply changes | 135 | # Apply changes | ||
| @@ -164,7 +165,7 @@ Minimum required configuration: | |||||
| 164 | 165 | ||||
| 165 | ```toml | 166 | ```toml | ||
| 166 | [server] | 167 | [server] | ||
| 167 | - | port = 8080 | 168 | + | port = 8088 |
| 168 | registry_port = 5000 | 169 | registry_port = 5000 | ||
| 169 | gordon_domain = "gordon.yourdomain.com" | 170 | gordon_domain = "gordon.yourdomain.com" | ||
| 170 | 171 | ||||
| @@ -189,12 +190,17 @@ After=podman.socket | |||||
| 189 | Type=simple | 190 | Type=simple | ||
| 190 | Restart=always | 191 | Restart=always | ||
| 191 | RestartSec=5 | 192 | RestartSec=5 | ||
| 193 | + | # Required for Podman rootless — tells Gordon where to find the Podman socket | |||
| 194 | + | Environment=XDG_RUNTIME_DIR=/run/user/%U | |||
| 195 | + | Environment=DOCKER_HOST=unix:///run/user/%U/podman/podman.sock | |||
| 192 | ExecStart=/usr/local/bin/gordon serve | 196 | ExecStart=/usr/local/bin/gordon serve | ||
| 193 | 197 | ||||
| 194 | [Install] | 198 | [Install] | ||
| 195 | WantedBy=default.target | 199 | WantedBy=default.target | ||
| 196 | EOF | 200 | EOF | ||
| 197 | 201 | ||||
| 202 | + | > **Docker users:** Omit the two `Environment=` lines above — Docker's socket path is set automatically. | |||
| 203 | + | ||||
| 198 | # Enable and start | 204 | # Enable and start | ||
| 199 | systemctl --user daemon-reload | 205 | systemctl --user daemon-reload | ||
| 200 | systemctl --user enable --now gordon | 206 | systemctl --user enable --now gordon | ||
| @@ -241,6 +247,19 @@ Point your domains to your server: | |||||
| 241 | 247 | ||||
| 242 | > **Note:** Enable Cloudflare proxy (orange cloud) for automatic HTTPS. Gordon receives HTTP traffic from Cloudflare. | 248 | > **Note:** Enable Cloudflare proxy (orange cloud) for automatic HTTPS. Gordon receives HTTP traffic from Cloudflare. | ||
| 243 | 249 | ||||
| 250 | + | ## Cloudflare SSL Mode | |||
| 251 | + | ||||
| 252 | + | Gordon serves HTTP by default. Cloudflare must connect to your origin over HTTP unless you explicitly enable TLS. | |||
| 253 | + | ||||
| 254 | + | | Mode | How it works | Use when | | |||
| 255 | + | |------|-------------|----------| | |||
| 256 | + | | **Flexible** | Cloudflare terminates TLS; connects to Gordon on HTTP | Gordon has no TLS cert (default setup) | | |||
| 257 | + | | **Full (Strict)** | Cloudflare connects to Gordon over HTTPS with a valid cert | Set `server.tls_port` (default 8443) and provide a Cloudflare Origin CA cert via `server.tls_cert_file` and `server.tls_key_file` | | |||
| 258 | + | ||||
| 259 | + | Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed). | |||
| 260 | + | ||||
| 261 | + | > **Rootless note:** Unprivileged users can't bind port 80. Forward port 80→8088 (or your configured port) via firewall — see the firewall section above. | |||
| 262 | + | ||||
| 244 | ## Verify Installation | 263 | ## Verify Installation | ||
| 245 | 264 | ||||
| 246 | ```bash | 265 | ```bash | ||
M internal/app/run.go +19 -2
| @@ -804,12 +804,29 @@ func resolveLogFilePath(cfg Config) string { | |||
| 804 | 804 | } | |
| 805 | 805 | ||
| 806 | 806 | // resolveRuntimeConfig converts a server.runtime config value to a socket path. | |
| 807 | - | // "auto" or "" means auto-detect; any other value is treated as an explicit socket path. | |
| 808 | - | // URI schemes (unix://, tcp://) are stripped so callers receive a bare path. | |
| 807 | + | // "auto" or "" means auto-detect. | |
| 808 | + | // Named runtimes ("podman", "docker") are resolved to well-known socket paths. | |
| 809 | + | // URI schemes (unix://) are stripped so callers receive a bare path. | |
| 809 | 810 | func resolveRuntimeConfig(value string) string { | |
| 810 | 811 | if value == "" || value == "auto" { | |
| 811 | 812 | return "" | |
| 812 | 813 | } | |
| 814 | + | // Named runtimes: resolve to well-known socket paths. | |
| 815 | + | switch value { | |
| 816 | + | case "podman": | |
| 817 | + | // Check XDG_RUNTIME_DIR first (rootless Podman). | |
| 818 | + | if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" { | |
| 819 | + | candidate := filepath.Join(xdg, "podman", "podman.sock") | |
| 820 | + | if _, err := os.Stat(candidate); err == nil { | |
| 821 | + | return candidate | |
| 822 | + | } | |
| 823 | + | } | |
| 824 | + | // Fallback to system-wide Podman socket. | |
| 825 | + | return "/run/podman/podman.sock" | |
| 826 | + | case "docker": | |
| 827 | + | return "/var/run/docker.sock" | |
| 828 | + | } | |
| 829 | + | // Explicit socket path — strip URI scheme if present. | |
| 813 | 830 | if strings.HasPrefix(value, "unix://") { | |
| 814 | 831 | return strings.TrimPrefix(value, "unix://") | |
| 815 | 832 | } | |
| @@ -804,12 +804,29 @@ func resolveLogFilePath(cfg Config) string { | |||||
| 804 | } | 804 | } | ||
| 805 | 805 | ||||
| 806 | // resolveRuntimeConfig converts a server.runtime config value to a socket path. | 806 | // resolveRuntimeConfig converts a server.runtime config value to a socket path. | ||
| 807 | - | // "auto" or "" means auto-detect; any other value is treated as an explicit socket path. | 807 | + | // "auto" or "" means auto-detect. |
| 808 | - | // URI schemes (unix://, tcp://) are stripped so callers receive a bare path. | 808 | + | // Named runtimes ("podman", "docker") are resolved to well-known socket paths. |
| 809 | + | // URI schemes (unix://) are stripped so callers receive a bare path. | |||
| 809 | func resolveRuntimeConfig(value string) string { | 810 | func resolveRuntimeConfig(value string) string { | ||
| 810 | if value == "" || value == "auto" { | 811 | if value == "" || value == "auto" { | ||
| 811 | return "" | 812 | return "" | ||
| 812 | } | 813 | } | ||
| 814 | + | // Named runtimes: resolve to well-known socket paths. | |||
| 815 | + | switch value { | |||
| 816 | + | case "podman": | |||
| 817 | + | // Check XDG_RUNTIME_DIR first (rootless Podman). | |||
| 818 | + | if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" { | |||
| 819 | + | candidate := filepath.Join(xdg, "podman", "podman.sock") | |||
| 820 | + | if _, err := os.Stat(candidate); err == nil { | |||
| 821 | + | return candidate | |||
| 822 | + | } | |||
| 823 | + | } | |||
| 824 | + | // Fallback to system-wide Podman socket. | |||
| 825 | + | return "/run/podman/podman.sock" | |||
| 826 | + | case "docker": | |||
| 827 | + | return "/var/run/docker.sock" | |||
| 828 | + | } | |||
| 829 | + | // Explicit socket path — strip URI scheme if present. | |||
| 813 | if strings.HasPrefix(value, "unix://") { | 830 | if strings.HasPrefix(value, "unix://") { | ||
| 814 | return strings.TrimPrefix(value, "unix://") | 831 | return strings.TrimPrefix(value, "unix://") | ||
| 815 | } | 832 | } | ||
A internal/app/run_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestResolveRuntimeConfig(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | value string | |
| 15 | + | xdgDir string // non-empty means set XDG_RUNTIME_DIR | |
| 16 | + | createSock bool // whether to create the socket file under xdgDir | |
| 17 | + | want string | |
| 18 | + | }{ | |
| 19 | + | { | |
| 20 | + | name: "empty string returns empty", | |
| 21 | + | value: "", | |
| 22 | + | want: "", | |
| 23 | + | }, | |
| 24 | + | { | |
| 25 | + | name: "auto returns empty", | |
| 26 | + | value: "auto", | |
| 27 | + | want: "", | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | name: "podman with XDG_RUNTIME_DIR and socket present", | |
| 31 | + | value: "podman", | |
| 32 | + | xdgDir: "tmpXDG", // replaced with t.TempDir() in the loop | |
| 33 | + | createSock: true, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "podman with XDG_RUNTIME_DIR but socket absent falls back to system socket", | |
| 37 | + | value: "podman", | |
| 38 | + | xdgDir: "tmpXDG", | |
| 39 | + | want: "/run/podman/podman.sock", | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "podman without XDG_RUNTIME_DIR falls back to system socket", | |
| 43 | + | value: "podman", | |
| 44 | + | want: "/run/podman/podman.sock", | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: "docker returns well-known path", | |
| 48 | + | value: "docker", | |
| 49 | + | want: "/var/run/docker.sock", | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | name: "unix:// prefix is stripped", | |
| 53 | + | value: "unix:///custom/path.sock", | |
| 54 | + | want: "/custom/path.sock", | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | name: "bare path returned as-is", | |
| 58 | + | value: "/custom/path.sock", | |
| 59 | + | want: "/custom/path.sock", | |
| 60 | + | }, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | for _, tt := range tests { | |
| 64 | + | t.Run(tt.name, func(t *testing.T) { | |
| 65 | + | xdgDir := tt.xdgDir | |
| 66 | + | if xdgDir == "tmpXDG" { | |
| 67 | + | xdgDir = t.TempDir() | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | if xdgDir != "" { | |
| 71 | + | t.Setenv("XDG_RUNTIME_DIR", xdgDir) | |
| 72 | + | } else { | |
| 73 | + | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | want := tt.want | |
| 77 | + | if tt.createSock && xdgDir != "" { | |
| 78 | + | sockDir := filepath.Join(xdgDir, "podman") | |
| 79 | + | os.MkdirAll(sockDir, 0o755) | |
| 80 | + | os.WriteFile(filepath.Join(sockDir, "podman.sock"), nil, 0o600) | |
| 81 | + | want = filepath.Join(xdgDir, "podman", "podman.sock") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | got := resolveRuntimeConfig(tt.value) | |
| 85 | + | assert.Equal(t, want, got) | |
| 86 | + | }) | |
| 87 | + | } | |
| 88 | + | } | |
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestResolveRuntimeConfig(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | value string | |||
| 15 | + | xdgDir string // non-empty means set XDG_RUNTIME_DIR | |||
| 16 | + | createSock bool // whether to create the socket file under xdgDir | |||
| 17 | + | want string | |||
| 18 | + | }{ | |||
| 19 | + | { | |||
| 20 | + | name: "empty string returns empty", | |||
| 21 | + | value: "", | |||
| 22 | + | want: "", | |||
| 23 | + | }, | |||
| 24 | + | { | |||
| 25 | + | name: "auto returns empty", | |||
| 26 | + | value: "auto", | |||
| 27 | + | want: "", | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | name: "podman with XDG_RUNTIME_DIR and socket present", | |||
| 31 | + | value: "podman", | |||
| 32 | + | xdgDir: "tmpXDG", // replaced with t.TempDir() in the loop | |||
| 33 | + | createSock: true, | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "podman with XDG_RUNTIME_DIR but socket absent falls back to system socket", | |||
| 37 | + | value: "podman", | |||
| 38 | + | xdgDir: "tmpXDG", | |||
| 39 | + | want: "/run/podman/podman.sock", | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "podman without XDG_RUNTIME_DIR falls back to system socket", | |||
| 43 | + | value: "podman", | |||
| 44 | + | want: "/run/podman/podman.sock", | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | name: "docker returns well-known path", | |||
| 48 | + | value: "docker", | |||
| 49 | + | want: "/var/run/docker.sock", | |||
| 50 | + | }, | |||
| 51 | + | { | |||
| 52 | + | name: "unix:// prefix is stripped", | |||
| 53 | + | value: "unix:///custom/path.sock", | |||
| 54 | + | want: "/custom/path.sock", | |||
| 55 | + | }, | |||
| 56 | + | { | |||
| 57 | + | name: "bare path returned as-is", | |||
| 58 | + | value: "/custom/path.sock", | |||
| 59 | + | want: "/custom/path.sock", | |||
| 60 | + | }, | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | for _, tt := range tests { | |||
| 64 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 65 | + | xdgDir := tt.xdgDir | |||
| 66 | + | if xdgDir == "tmpXDG" { | |||
| 67 | + | xdgDir = t.TempDir() | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | if xdgDir != "" { | |||
| 71 | + | t.Setenv("XDG_RUNTIME_DIR", xdgDir) | |||
| 72 | + | } else { | |||
| 73 | + | t.Setenv("XDG_RUNTIME_DIR", "") | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | want := tt.want | |||
| 77 | + | if tt.createSock && xdgDir != "" { | |||
| 78 | + | sockDir := filepath.Join(xdgDir, "podman") | |||
| 79 | + | os.MkdirAll(sockDir, 0o755) | |||
| 80 | + | os.WriteFile(filepath.Join(sockDir, "podman.sock"), nil, 0o600) | |||
| 81 | + | want = filepath.Join(xdgDir, "podman", "podman.sock") | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | got := resolveRuntimeConfig(tt.value) | |||
| 85 | + | assert.Equal(t, want, got) | |||
| 86 | + | }) | |||
| 87 | + | } | |||
| 88 | + | } | |||
M internal/domain/errors.go +1 -0
| @@ -10,6 +10,7 @@ var ( | |||
| 10 | 10 | ErrContainerExists = errors.New("container already exists") | |
| 11 | 11 | ErrContainerNotRunning = errors.New("container is not running") | |
| 12 | 12 | ErrContainerRunning = errors.New("container is already running") | |
| 13 | + | ErrContainerExited = errors.New("container exited") | |
| 13 | 14 | ||
| 14 | 15 | // Image errors | |
| 15 | 16 | ErrImageNotFound = errors.New("image not found") | |
| @@ -10,6 +10,7 @@ var ( | |||||
| 10 | ErrContainerExists = errors.New("container already exists") | 10 | ErrContainerExists = errors.New("container already exists") | ||
| 11 | ErrContainerNotRunning = errors.New("container is not running") | 11 | ErrContainerNotRunning = errors.New("container is not running") | ||
| 12 | ErrContainerRunning = errors.New("container is already running") | 12 | ErrContainerRunning = errors.New("container is already running") | ||
| 13 | + | ErrContainerExited = errors.New("container exited") | |||
| 13 | 14 | ||||
| 14 | // Image errors | 15 | // Image errors | ||
| 15 | ErrImageNotFound = errors.New("image not found") | 16 | ErrImageNotFound = errors.New("image not found") | ||
M internal/usecase/container/service.go +4 -0
| @@ -2848,6 +2848,10 @@ func (s *Service) pollContainerRunning(ctx context.Context, containerID string) | |||
| 2848 | 2848 | if running { | |
| 2849 | 2849 | return nil | |
| 2850 | 2850 | } | |
| 2851 | + | // Check if the container has already exited (crash) rather than still starting up. | |
| 2852 | + | if info, inspectErr := s.runtime.InspectContainer(ctx, containerID); inspectErr == nil && info != nil && info.Status == string(domain.ContainerStatusExited) { | |
| 2853 | + | return fmt.Errorf("container exited immediately with code %d: %w", info.ExitCode, domain.ErrContainerExited) | |
| 2854 | + | } | |
| 2851 | 2855 | if i == 29 { | |
| 2852 | 2856 | return fmt.Errorf("container did not start within 30 seconds") | |
| 2853 | 2857 | } | |
| @@ -2848,6 +2848,10 @@ func (s *Service) pollContainerRunning(ctx context.Context, containerID string) | |||||
| 2848 | if running { | 2848 | if running { | ||
| 2849 | return nil | 2849 | return nil | ||
| 2850 | } | 2850 | } | ||
| 2851 | + | // Check if the container has already exited (crash) rather than still starting up. | |||
| 2852 | + | if info, inspectErr := s.runtime.InspectContainer(ctx, containerID); inspectErr == nil && info != nil && info.Status == string(domain.ContainerStatusExited) { | |||
| 2853 | + | return fmt.Errorf("container exited immediately with code %d: %w", info.ExitCode, domain.ErrContainerExited) | |||
| 2854 | + | } | |||
| 2851 | if i == 29 { | 2855 | if i == 29 { | ||
| 2852 | return fmt.Errorf("container did not start within 30 seconds") | 2856 | return fmt.Errorf("container did not start within 30 seconds") | ||
| 2853 | } | 2857 | } | ||
A internal/usecase/container/service_poll_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | mocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestPollContainerRunning(t *testing.T) { | |
| 15 | + | t.Run("container starts running immediately", func(t *testing.T) { | |
| 16 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 17 | + | svc := NewService(runtime, nil, nil, nil, Config{}, nil) | |
| 18 | + | ctx := testContext() | |
| 19 | + | ||
| 20 | + | runtime.EXPECT().IsContainerRunning(ctx, "ctr-1").Return(true, nil).Once() | |
| 21 | + | ||
| 22 | + | err := svc.pollContainerRunning(ctx, "ctr-1") | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | }) | |
| 25 | + | ||
| 26 | + | t.Run("container exits immediately with non-zero exit code", func(t *testing.T) { | |
| 27 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 28 | + | svc := NewService(runtime, nil, nil, nil, Config{}, nil) | |
| 29 | + | ctx := testContext() | |
| 30 | + | ||
| 31 | + | runtime.EXPECT().IsContainerRunning(ctx, "ctr-2").Return(false, nil).Once() | |
| 32 | + | runtime.EXPECT().InspectContainer(ctx, "ctr-2").Return(&domain.Container{ | |
| 33 | + | ID: "ctr-2", | |
| 34 | + | Status: string(domain.ContainerStatusExited), | |
| 35 | + | ExitCode: 127, | |
| 36 | + | }, nil).Once() | |
| 37 | + | ||
| 38 | + | err := svc.pollContainerRunning(ctx, "ctr-2") | |
| 39 | + | require.Error(t, err) | |
| 40 | + | assert.Contains(t, err.Error(), "127") | |
| 41 | + | assert.Contains(t, err.Error(), "exited") | |
| 42 | + | assert.True(t, errors.Is(err, domain.ErrContainerExited)) | |
| 43 | + | }) | |
| 44 | + | ||
| 45 | + | t.Run("container starts running after one failed poll", func(t *testing.T) { | |
| 46 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 47 | + | svc := NewService(runtime, nil, nil, nil, Config{}, nil) | |
| 48 | + | ctx := testContext() | |
| 49 | + | ||
| 50 | + | // First poll: not yet running, still in "created" state (not exited) | |
| 51 | + | runtime.EXPECT().IsContainerRunning(ctx, "ctr-3").Return(false, nil).Once() | |
| 52 | + | runtime.EXPECT().InspectContainer(ctx, "ctr-3").Return(&domain.Container{ | |
| 53 | + | ID: "ctr-3", | |
| 54 | + | Status: string(domain.ContainerStatusCreated), | |
| 55 | + | }, nil).Once() | |
| 56 | + | ||
| 57 | + | // Second poll: running | |
| 58 | + | runtime.EXPECT().IsContainerRunning(ctx, "ctr-3").Return(true, nil).Once() | |
| 59 | + | ||
| 60 | + | err := svc.pollContainerRunning(ctx, "ctr-3") | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | }) | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | mocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestPollContainerRunning(t *testing.T) { | |||
| 15 | + | t.Run("container starts running immediately", func(t *testing.T) { | |||
| 16 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 17 | + | svc := NewService(runtime, nil, nil, nil, Config{}, nil) | |||
| 18 | + | ctx := testContext() | |||
| 19 | + | ||||
| 20 | + | runtime.EXPECT().IsContainerRunning(ctx, "ctr-1").Return(true, nil).Once() | |||
| 21 | + | ||||
| 22 | + | err := svc.pollContainerRunning(ctx, "ctr-1") | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | }) | |||
| 25 | + | ||||
| 26 | + | t.Run("container exits immediately with non-zero exit code", func(t *testing.T) { | |||
| 27 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 28 | + | svc := NewService(runtime, nil, nil, nil, Config{}, nil) | |||
| 29 | + | ctx := testContext() | |||
| 30 | + | ||||
| 31 | + | runtime.EXPECT().IsContainerRunning(ctx, "ctr-2").Return(false, nil).Once() | |||
| 32 | + | runtime.EXPECT().InspectContainer(ctx, "ctr-2").Return(&domain.Container{ | |||
| 33 | + | ID: "ctr-2", | |||
| 34 | + | Status: string(domain.ContainerStatusExited), | |||
| 35 | + | ExitCode: 127, | |||
| 36 | + | }, nil).Once() | |||
| 37 | + | ||||
| 38 | + | err := svc.pollContainerRunning(ctx, "ctr-2") | |||
| 39 | + | require.Error(t, err) | |||
| 40 | + | assert.Contains(t, err.Error(), "127") | |||
| 41 | + | assert.Contains(t, err.Error(), "exited") | |||
| 42 | + | assert.True(t, errors.Is(err, domain.ErrContainerExited)) | |||
| 43 | + | }) | |||
| 44 | + | ||||
| 45 | + | t.Run("container starts running after one failed poll", func(t *testing.T) { | |||
| 46 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 47 | + | svc := NewService(runtime, nil, nil, nil, Config{}, nil) | |||
| 48 | + | ctx := testContext() | |||
| 49 | + | ||||
| 50 | + | // First poll: not yet running, still in "created" state (not exited) | |||
| 51 | + | runtime.EXPECT().IsContainerRunning(ctx, "ctr-3").Return(false, nil).Once() | |||
| 52 | + | runtime.EXPECT().InspectContainer(ctx, "ctr-3").Return(&domain.Container{ | |||
| 53 | + | ID: "ctr-3", | |||
| 54 | + | Status: string(domain.ContainerStatusCreated), | |||
| 55 | + | }, nil).Once() | |||
| 56 | + | ||||
| 57 | + | // Second poll: running | |||
| 58 | + | runtime.EXPECT().IsContainerRunning(ctx, "ctr-3").Return(true, nil).Once() | |||
| 59 | + | ||||
| 60 | + | err := svc.pollContainerRunning(ctx, "ctr-3") | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | }) | |||
| 63 | + | } | |||