detached e0293e4

fix(podman): resolve named runtimes and detect container crashes

Address friction points from Podman rootless deployment testing:

Code fixes:
- resolveRuntimeConfig() now handles 'podman' and 'docker' as named
  runtimes, resolving to well-known socket paths. 'podman' checks
  XDG_RUNTIME_DIR for rootless setups before falling back to the
  system-wide socket. Fixes the crash when setting runtime = 'podman'.
- pollContainerRunning() inspects container state when not running.
  If the container has already exited (crash), it returns immediately
  with the exit code instead of waiting the full 30 seconds.

Documentation fixes:
- Replace deprecated registries.conf format with correct [[registry]]
  TOML array syntax, using a drop-in file to avoid clobbering config.
- Add DOCKER_HOST and XDG_RUNTIME_DIR to the Podman rootless systemd
  unit example.
- Add Cloudflare SSL Mode section covering Flexible vs Full (Strict).
- Document which config settings hot-reload vs require restart.
- Fix bootstrap --build references in SKILL.md to show two-step flow.
commit
e0293e4aa36fa1712ee5926909ecf7541a51871c
author
brice · · 2026-04-03 18:14 UTC
committer
brice · · 2026-04-03 18:24 UTC
parent
2bb8b58
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
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
220220 gordon reload
221221 ```
222222
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` |
236248
237249 ## Environment Variable Override
238250
@@ -220,19 +220,31 @@ Gordon watches the configuration file and reloads automatically when changes are
220 gordon reload220 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` settings228+| `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 Override249 ## 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
104104 # Enable Podman socket
105105 systemctl --user enable --now podman.socket
106106
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
112113 EOF
113114 ```
114115
@@ -128,7 +129,7 @@ sudo firewall-cmd --permanent --add-service=http
128129 sudo firewall-cmd --permanent --add-service=https
129130
130131 # 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
132133 sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
133134
134135 # Apply changes
@@ -164,7 +165,7 @@ Minimum required configuration:
164165
165166 ```toml
166167 [server]
167-port = 8080
168+port = 8088
168169 registry_port = 5000
169170 gordon_domain = "gordon.yourdomain.com"
170171
@@ -189,12 +190,17 @@ After=podman.socket
189190 Type=simple
190191 Restart=always
191192 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
192196 ExecStart=/usr/local/bin/gordon serve
193197
194198 [Install]
195199 WantedBy=default.target
196200 EOF
197201
202+> **Docker users:** Omit the two `Environment=` lines above — Docker's socket path is set automatically.
203+
198204 # Enable and start
199205 systemctl --user daemon-reload
200206 systemctl --user enable --now gordon
@@ -241,6 +247,19 @@ Point your domains to your server:
241247
242248 > **Note:** Enable Cloudflare proxy (orange cloud) for automatic HTTPS. Gordon receives HTTP traffic from Cloudflare.
243249
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+
244263 ## Verify Installation
245264
246265 ```bash
@@ -104,11 +104,12 @@ sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
104 # Enable Podman socket104 # Enable Podman socket
105 systemctl --user enable --now podman.socket105 systemctl --user enable --now podman.socket
106 106
107-# Configure insecure localhost registry107+# Configure insecure localhost registry (required — Podman blocks HTTP registries by default)
108-mkdir -p ~/.config/containers108+mkdir -p ~/.config/containers/registries.conf.d
109-cat > ~/.config/containers/registries.conf <<EOF109+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 EOF113 EOF
113 ```114 ```
114 115
@@ -128,7 +129,7 @@ sudo firewall-cmd --permanent --add-service=http
128 sudo firewall-cmd --permanent --add-service=https129 sudo firewall-cmd --permanent --add-service=https
129 130
130 # For rootless containers, redirect privileged ports131 # For rootless containers, redirect privileged ports
131-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080132+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=8443133 sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
133 134
134 # Apply changes135 # Apply changes
@@ -164,7 +165,7 @@ Minimum required configuration:
164 165
165 ```toml166 ```toml
166 [server]167 [server]
167-port = 8080168+port = 8088
168 registry_port = 5000169 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=simple190 Type=simple
190 Restart=always191 Restart=always
191 RestartSec=5192 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 serve196 ExecStart=/usr/local/bin/gordon serve
193 197
194 [Install]198 [Install]
195 WantedBy=default.target199 WantedBy=default.target
196 EOF200 EOF
197 201
202+> **Docker users:** Omit the two `Environment=` lines above — Docker's socket path is set automatically.
203+
198 # Enable and start204 # Enable and start
199 systemctl --user daemon-reload205 systemctl --user daemon-reload
200 systemctl --user enable --now gordon206 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 Installation263 ## Verify Installation
245 264
246 ```bash265 ```bash
M internal/app/run.go +19 -2
@@ -804,12 +804,29 @@ func resolveLogFilePath(cfg Config) string {
804804 }
805805
806806 // 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.
809810 func resolveRuntimeConfig(value string) string {
810811 if value == "" || value == "auto" {
811812 return ""
812813 }
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.
813830 if strings.HasPrefix(value, "unix://") {
814831 return strings.TrimPrefix(value, "unix://")
815832 }
@@ -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 (
1010 ErrContainerExists = errors.New("container already exists")
1111 ErrContainerNotRunning = errors.New("container is not running")
1212 ErrContainerRunning = errors.New("container is already running")
13+ ErrContainerExited = errors.New("container exited")
1314
1415 // Image errors
1516 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 errors15 // 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)
28482848 if running {
28492849 return nil
28502850 }
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+ }
28512855 if i == 29 {
28522856 return fmt.Errorf("container did not start within 30 seconds")
28532857 }
@@ -2848,6 +2848,10 @@ func (s *Service) pollContainerRunning(ctx context.Context, containerID string)
2848 if running {2848 if running {
2849 return nil2849 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+}

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