detached 44641a3

fix(deploy): resolve live gordon PID across runtime paths

commit
44641a3a52ff6a15af0660ec6f94d22179897db6
author
brice · · 2026-02-06 15:56 UTC
committer
brice · · 2026-02-06 19:34 UTC
parent
d7a1827
signature
unsigned
7 files · +259 -48
M docs/config/deploy.md +13 -0
@@ -7,6 +7,7 @@ Controls how Gordon pulls images during deployment.
77 ```toml
88 [deploy]
99 pull_policy = "if-tag-changed"
10+readiness_delay = "5s"
1011 ```
1112
1213 ## Settings
@@ -21,6 +22,18 @@ How to decide when to pull an image:
2122
2223 Default: `if-tag-changed`
2324
25+### `deploy.readiness_delay`
26+
27+How long Gordon waits after a container first reports `running` before it is
28+considered ready for traffic.
29+
30+- Uses Go duration format (examples: `"5s"`, `"15s"`, `"1m"`).
31+- If the container briefly exits right after this delay, Gordon now waits up to
32+ 30 additional seconds for it to recover before failing the deploy.
33+
34+Default: `"5s"`
35+
2436 ## Notes
2537
2638 - Changes to `deploy.pull_policy` require a restart to take effect.
39+- Changes to `deploy.readiness_delay` require a restart to take effect.
@@ -7,6 +7,7 @@ Controls how Gordon pulls images during deployment.
7 ```toml7 ```toml
8 [deploy]8 [deploy]
9 pull_policy = "if-tag-changed"9 pull_policy = "if-tag-changed"
10+readiness_delay = "5s"
10 ```11 ```
11 12
12 ## Settings13 ## Settings
@@ -21,6 +22,18 @@ How to decide when to pull an image:
21 22
22 Default: `if-tag-changed`23 Default: `if-tag-changed`
23 24
25+### `deploy.readiness_delay`
26+
27+How long Gordon waits after a container first reports `running` before it is
28+considered ready for traffic.
29+
30+- Uses Go duration format (examples: `"5s"`, `"15s"`, `"1m"`).
31+- If the container briefly exits right after this delay, Gordon now waits up to
32+ 30 additional seconds for it to recover before failing the deploy.
33+
34+Default: `"5s"`
35+
24 ## Notes36 ## Notes
25 37
26 - Changes to `deploy.pull_policy` require a restart to take effect.38 - Changes to `deploy.pull_policy` require a restart to take effect.
39+- Changes to `deploy.readiness_delay` require a restart to take effect.
M docs/config/index.md +2 -0
@@ -145,6 +145,7 @@ enabled = false # Auto-create routes from image names
145145 | `api.rate_limit.burst` | `100` |
146146 | `api.rate_limit.trusted_proxies` | `[]` |
147147 | `deploy.pull_policy` | `"if-tag-changed"` |
148+| `deploy.readiness_delay` | `"5s"` |
148149 | `logging.level` | `"info"` |
149150 | `logging.format` | `"console"` |
150151 | `logging.file.enabled` | `false` |
@@ -173,6 +174,7 @@ The following settings require a restart to take effect:
173174 - `server.data_dir`
174175 - `auth` settings
175176 - `deploy.pull_policy`
177+- `deploy.readiness_delay`
176178
177179 ## Environment Variable Override
178180
@@ -145,6 +145,7 @@ enabled = false # Auto-create routes from image names
145 | `api.rate_limit.burst` | `100` |145 | `api.rate_limit.burst` | `100` |
146 | `api.rate_limit.trusted_proxies` | `[]` |146 | `api.rate_limit.trusted_proxies` | `[]` |
147 | `deploy.pull_policy` | `"if-tag-changed"` |147 | `deploy.pull_policy` | `"if-tag-changed"` |
148+| `deploy.readiness_delay` | `"5s"` |
148 | `logging.level` | `"info"` |149 | `logging.level` | `"info"` |
149 | `logging.format` | `"console"` |150 | `logging.format` | `"console"` |
150 | `logging.file.enabled` | `false` |151 | `logging.file.enabled` | `false` |
@@ -173,6 +174,7 @@ The following settings require a restart to take effect:
173 - `server.data_dir`174 - `server.data_dir`
174 - `auth` settings175 - `auth` settings
175 - `deploy.pull_policy`176 - `deploy.pull_policy`
177+- `deploy.readiness_delay`
176 178
177 ## Environment Variable Override179 ## Environment Variable Override
178 180
M docs/config/reference.md +1 -0
@@ -146,6 +146,7 @@ preserve = true # Keep volumes when containers are
146146 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |
147147 | `logging.container_logs.max_age` | `28` | 28 days |
148148 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
149+| `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready |
149150 | `auto_route.enabled` | `false` | Auto-route disabled |
150151 | `network_isolation.enabled` | `false` | Isolation disabled |
151152 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
@@ -146,6 +146,7 @@ preserve = true # Keep volumes when containers are
146 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |146 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |
147 | `logging.container_logs.max_age` | `28` | 28 days |147 | `logging.container_logs.max_age` | `28` | 28 days |
148 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |148 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
149+| `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready |
149 | `auto_route.enabled` | `false` | Auto-route disabled |150 | `auto_route.enabled` | `false` | Auto-route disabled |
150 | `network_isolation.enabled` | `false` | Isolation disabled |151 | `network_isolation.enabled` | `false` | Isolation disabled |
151 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |152 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
M internal/app/run.go +105 -45
@@ -6,12 +6,14 @@ import (
66 "crypto/rand"
77 "encoding/hex"
88 "encoding/json"
9+ "errors"
910 "fmt"
1011 "net"
1112 "net/http"
1213 "os"
1314 "os/signal"
1415 "path/filepath"
16+ "strconv"
1517 "strings"
1618 "syscall"
1719 "time"
@@ -867,6 +869,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
867869 DNSSuffix: v.GetString("network_isolation.dns_suffix"),
868870 NetworkGroups: svc.configSvc.GetNetworkGroups(),
869871 Attachments: svc.configSvc.GetAttachments(),
872+ ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
870873 }
871874
872875 if cfg.Auth.Enabled && svc.authSvc != nil {
@@ -1200,24 +1203,9 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
12001203
12011204 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
12021205 func SendReloadSignal() error {
1203- pidFile := findPidFile()
1204- if pidFile == "" {
1205- return fmt.Errorf("gordon PID file not found, is Gordon running?")
1206- }
1207-
1208- pidBytes, err := os.ReadFile(pidFile)
1209- if err != nil {
1210- return fmt.Errorf("failed to read PID file: %w", err)
1211- }
1212-
1213- var pid int
1214- if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
1215- return fmt.Errorf("failed to parse PID: %w", err)
1216- }
1217-
1218- process, err := os.FindProcess(pid)
1206+ process, _, err := findRunningProcess()
12191207 if err != nil {
1220- return fmt.Errorf("failed to find process: %w", err)
1208+ return err
12211209 }
12221210
12231211 if err := process.Signal(syscall.SIGUSR1); err != nil {
@@ -1270,28 +1258,10 @@ func SendDeploySignal(domain string) (string, error) {
12701258 }
12711259
12721260 // Find PID and send SIGUSR2
1273- pidFile := findPidFile()
1274- if pidFile == "" {
1275- _ = os.Remove(deployFile)
1276- return "", fmt.Errorf("gordon PID file not found, is Gordon running?")
1277- }
1278-
1279- pidBytes, err := os.ReadFile(pidFile)
1261+ process, _, err := findRunningProcess()
12801262 if err != nil {
12811263 _ = os.Remove(deployFile)
1282- return "", fmt.Errorf("failed to read PID file: %w", err)
1283- }
1284-
1285- var pid int
1286- if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
1287- _ = os.Remove(deployFile)
1288- return "", fmt.Errorf("failed to parse PID: %w", err)
1289- }
1290-
1291- process, err := os.FindProcess(pid)
1292- if err != nil {
1293- _ = os.Remove(deployFile)
1294- return "", fmt.Errorf("failed to find process: %w", err)
1264+ return "", err
12951265 }
12961266
12971267 if err := process.Signal(syscall.SIGUSR2); err != nil {
@@ -1374,31 +1344,120 @@ func removePidFile(pidFile string, log zerowrap.Logger) {
13741344 // findPidFile finds the Gordon PID file.
13751345 // Checks secure locations first, then falls back to legacy /tmp location.
13761346 func findPidFile() string {
1347+ location, _, err := findRunningPidFile()
1348+ if err != nil {
1349+ return ""
1350+ }
1351+ return location
1352+}
1353+
1354+func pidFileLocations() []string {
13771355 var locations []string
1356+ seen := make(map[string]struct{})
1357+ add := func(path string) {
1358+ if path == "" {
1359+ return
1360+ }
1361+ if _, ok := seen[path]; ok {
1362+ return
1363+ }
1364+ seen[path] = struct{}{}
1365+ locations = append(locations, path)
1366+ }
13781367
13791368 // Check secure runtime directory first
13801369 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1381- locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1370+ add(filepath.Join(runtimeDir, "gordon.pid"))
1371+ }
1372+
1373+ // Also check canonical /run/user/<uid> runtime path. This handles cases where
1374+ // Gordon started under systemd user services with runtime dir available, but
1375+ // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
1376+ runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
1377+ add(runtimeByUID)
1378+
1379+ // Check explicit XDG_RUNTIME_DIR if present in this process env.
1380+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1381+ add(filepath.Join(runtimeDir, "gordon.pid"))
13821382 }
13831383
13841384 // Check home directory
13851385 if homeDir, err := os.UserHomeDir(); err == nil {
1386- locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1386+ add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
13871387 // Legacy location for backward compatibility
1388- locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
1388+ add(filepath.Join(homeDir, ".gordon.pid"))
13891389 }
13901390
13911391 // Legacy /tmp locations for backward compatibility
1392- locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1393- locations = append(locations, "/tmp/gordon.pid")
1392+ add(filepath.Join(os.TempDir(), "gordon.pid"))
1393+ add("/tmp/gordon.pid")
1394+
1395+ return locations
1396+}
1397+
1398+// findRunningPidFile returns the first PID file whose PID belongs to a live process.
1399+// Stale/invalid PID files are ignored and removed when possible.
1400+func findRunningPidFile() (string, int, error) {
1401+ return findRunningPidFileInLocations(pidFileLocations())
1402+}
1403+
1404+func findRunningPidFileInLocations(locations []string) (string, int, error) {
1405+ foundAny := false
13941406
13951407 for _, location := range locations {
1396- if _, err := os.Stat(location); err == nil {
1397- return location
1408+ pidBytes, err := os.ReadFile(location)
1409+ if err != nil {
1410+ continue
1411+ }
1412+
1413+ foundAny = true
1414+
1415+ var pid int
1416+ if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
1417+ _ = os.Remove(location)
1418+ continue
1419+ }
1420+
1421+ if isProcessAlive(pid) {
1422+ return location, pid, nil
13981423 }
1424+
1425+ _ = os.Remove(location)
13991426 }
14001427
1401- return ""
1428+ if foundAny {
1429+ return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
1430+ }
1431+
1432+ return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
1433+}
1434+
1435+func findRunningProcess() (*os.Process, int, error) {
1436+ _, pid, err := findRunningPidFile()
1437+ if err != nil {
1438+ return nil, 0, err
1439+ }
1440+
1441+ process, err := os.FindProcess(pid)
1442+ if err != nil {
1443+ return nil, 0, fmt.Errorf("failed to find process: %w", err)
1444+ }
1445+
1446+ return process, pid, nil
1447+}
1448+
1449+func isProcessAlive(pid int) bool {
1450+ process, err := os.FindProcess(pid)
1451+ if err != nil {
1452+ return false
1453+ }
1454+
1455+ err = process.Signal(syscall.Signal(0))
1456+ if err == nil {
1457+ return true
1458+ }
1459+
1460+ return errors.Is(err, syscall.EPERM)
14021461 }
14031462
14041463 // loadConfig loads configuration from file and sets defaults.
@@ -1435,6 +1494,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
14351494 v.SetDefault("volumes.prefix", "gordon")
14361495 v.SetDefault("volumes.preserve", true)
14371496 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1497+ v.SetDefault("deploy.readiness_delay", "5s")
14381498
14391499 ConfigureViper(v, configPath)
14401500
@@ -6,12 +6,14 @@ import (
6 "crypto/rand"6 "crypto/rand"
7 "encoding/hex"7 "encoding/hex"
8 "encoding/json"8 "encoding/json"
9+ "errors"
9 "fmt"10 "fmt"
10 "net"11 "net"
11 "net/http"12 "net/http"
12 "os"13 "os"
13 "os/signal"14 "os/signal"
14 "path/filepath"15 "path/filepath"
16+ "strconv"
15 "strings"17 "strings"
16 "syscall"18 "syscall"
17 "time"19 "time"
@@ -867,6 +869,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
867 DNSSuffix: v.GetString("network_isolation.dns_suffix"),869 DNSSuffix: v.GetString("network_isolation.dns_suffix"),
868 NetworkGroups: svc.configSvc.GetNetworkGroups(),870 NetworkGroups: svc.configSvc.GetNetworkGroups(),
869 Attachments: svc.configSvc.GetAttachments(),871 Attachments: svc.configSvc.GetAttachments(),
872+ ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
870 }873 }
871 874
872 if cfg.Auth.Enabled && svc.authSvc != nil {875 if cfg.Auth.Enabled && svc.authSvc != nil {
@@ -1200,24 +1203,9 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
1200 1203
1201 // SendReloadSignal sends SIGUSR1 to the running Gordon process.1204 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
1202 func SendReloadSignal() error {1205 func SendReloadSignal() error {
1203- pidFile := findPidFile()1206+ process, _, err := findRunningProcess()
1204- if pidFile == "" {
1205- return fmt.Errorf("gordon PID file not found, is Gordon running?")
1206- }
1207-
1208- pidBytes, err := os.ReadFile(pidFile)
1209- if err != nil {
1210- return fmt.Errorf("failed to read PID file: %w", err)
1211- }
1212-
1213- var pid int
1214- if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
1215- return fmt.Errorf("failed to parse PID: %w", err)
1216- }
1217-
1218- process, err := os.FindProcess(pid)
1219 if err != nil {1207 if err != nil {
1220- return fmt.Errorf("failed to find process: %w", err)1208+ return err
1221 }1209 }
1222 1210
1223 if err := process.Signal(syscall.SIGUSR1); err != nil {1211 if err := process.Signal(syscall.SIGUSR1); err != nil {
@@ -1270,28 +1258,10 @@ func SendDeploySignal(domain string) (string, error) {
1270 }1258 }
1271 1259
1272 // Find PID and send SIGUSR21260 // Find PID and send SIGUSR2
1273- pidFile := findPidFile()1261+ process, _, err := findRunningProcess()
1274- if pidFile == "" {
1275- _ = os.Remove(deployFile)
1276- return "", fmt.Errorf("gordon PID file not found, is Gordon running?")
1277- }
1278-
1279- pidBytes, err := os.ReadFile(pidFile)
1280 if err != nil {1262 if err != nil {
1281 _ = os.Remove(deployFile)1263 _ = os.Remove(deployFile)
1282- return "", fmt.Errorf("failed to read PID file: %w", err)1264+ return "", err
1283- }
1284-
1285- var pid int
1286- if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
1287- _ = os.Remove(deployFile)
1288- return "", fmt.Errorf("failed to parse PID: %w", err)
1289- }
1290-
1291- process, err := os.FindProcess(pid)
1292- if err != nil {
1293- _ = os.Remove(deployFile)
1294- return "", fmt.Errorf("failed to find process: %w", err)
1295 }1265 }
1296 1266
1297 if err := process.Signal(syscall.SIGUSR2); err != nil {1267 if err := process.Signal(syscall.SIGUSR2); err != nil {
@@ -1374,31 +1344,120 @@ func removePidFile(pidFile string, log zerowrap.Logger) {
1374 // findPidFile finds the Gordon PID file.1344 // findPidFile finds the Gordon PID file.
1375 // Checks secure locations first, then falls back to legacy /tmp location.1345 // Checks secure locations first, then falls back to legacy /tmp location.
1376 func findPidFile() string {1346 func findPidFile() string {
1347+ location, _, err := findRunningPidFile()
1348+ if err != nil {
1349+ return ""
1350+ }
1351+ return location
1352+}
1353+
1354+func pidFileLocations() []string {
1377 var locations []string1355 var locations []string
1356+ seen := make(map[string]struct{})
1357+ add := func(path string) {
1358+ if path == "" {
1359+ return
1360+ }
1361+ if _, ok := seen[path]; ok {
1362+ return
1363+ }
1364+ seen[path] = struct{}{}
1365+ locations = append(locations, path)
1366+ }
1378 1367
1379 // Check secure runtime directory first1368 // Check secure runtime directory first
1380 if runtimeDir, err := getSecureRuntimeDir(); err == nil {1369 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1381- locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))1370+ add(filepath.Join(runtimeDir, "gordon.pid"))
1371+ }
1372+
1373+ // Also check canonical /run/user/<uid> runtime path. This handles cases where
1374+ // Gordon started under systemd user services with runtime dir available, but
1375+ // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
1376+ runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
1377+ add(runtimeByUID)
1378+
1379+ // Check explicit XDG_RUNTIME_DIR if present in this process env.
1380+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1381+ add(filepath.Join(runtimeDir, "gordon.pid"))
1382 }1382 }
1383 1383
1384 // Check home directory1384 // Check home directory
1385 if homeDir, err := os.UserHomeDir(); err == nil {1385 if homeDir, err := os.UserHomeDir(); err == nil {
1386- locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))1386+ add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
1387 // Legacy location for backward compatibility1387 // Legacy location for backward compatibility
1388- locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))1388+ add(filepath.Join(homeDir, ".gordon.pid"))
1389 }1389 }
1390 1390
1391 // Legacy /tmp locations for backward compatibility1391 // Legacy /tmp locations for backward compatibility
1392- locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))1392+ add(filepath.Join(os.TempDir(), "gordon.pid"))
1393- locations = append(locations, "/tmp/gordon.pid")1393+ add("/tmp/gordon.pid")
1394+
1395+ return locations
1396+}
1397+
1398+// findRunningPidFile returns the first PID file whose PID belongs to a live process.
1399+// Stale/invalid PID files are ignored and removed when possible.
1400+func findRunningPidFile() (string, int, error) {
1401+ return findRunningPidFileInLocations(pidFileLocations())
1402+}
1403+
1404+func findRunningPidFileInLocations(locations []string) (string, int, error) {
1405+ foundAny := false
1394 1406
1395 for _, location := range locations {1407 for _, location := range locations {
1396- if _, err := os.Stat(location); err == nil {1408+ pidBytes, err := os.ReadFile(location)
1397- return location1409+ if err != nil {
1410+ continue
1411+ }
1412+
1413+ foundAny = true
1414+
1415+ var pid int
1416+ if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
1417+ _ = os.Remove(location)
1418+ continue
1419+ }
1420+
1421+ if isProcessAlive(pid) {
1422+ return location, pid, nil
1398 }1423 }
1424+
1425+ _ = os.Remove(location)
1399 }1426 }
1400 1427
1401- return ""1428+ if foundAny {
1429+ return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
1430+ }
1431+
1432+ return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
1433+}
1434+
1435+func findRunningProcess() (*os.Process, int, error) {
1436+ _, pid, err := findRunningPidFile()
1437+ if err != nil {
1438+ return nil, 0, err
1439+ }
1440+
1441+ process, err := os.FindProcess(pid)
1442+ if err != nil {
1443+ return nil, 0, fmt.Errorf("failed to find process: %w", err)
1444+ }
1445+
1446+ return process, pid, nil
1447+}
1448+
1449+func isProcessAlive(pid int) bool {
1450+ process, err := os.FindProcess(pid)
1451+ if err != nil {
1452+ return false
1453+ }
1454+
1455+ err = process.Signal(syscall.Signal(0))
1456+ if err == nil {
1457+ return true
1458+ }
1459+
1460+ return errors.Is(err, syscall.EPERM)
1402 }1461 }
1403 1462
1404 // loadConfig loads configuration from file and sets defaults.1463 // loadConfig loads configuration from file and sets defaults.
@@ -1435,6 +1494,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
1435 v.SetDefault("volumes.prefix", "gordon")1494 v.SetDefault("volumes.prefix", "gordon")
1436 v.SetDefault("volumes.preserve", true)1495 v.SetDefault("volumes.preserve", true)
1437 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)1496 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1497+ v.SetDefault("deploy.readiness_delay", "5s")
1438 1498
1439 ConfigureViper(v, configPath)1499 ConfigureViper(v, configPath)
1440 1500
A internal/app/run_pid_test.go +47 -0
@@ -0,0 +1,47 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestFindRunningPidFileInLocations_PrefersLiveProcessOverStale(t *testing.T) {
13+ tmpDir := t.TempDir()
14+ staleFile := filepath.Join(tmpDir, "stale.pid")
15+ liveFile := filepath.Join(tmpDir, "live.pid")
16+
17+ require.NoError(t, os.WriteFile(staleFile, []byte("999999"), 0600))
18+ require.NoError(t, os.WriteFile(liveFile, []byte("1"), 0600))
19+
20+ path, pid, err := findRunningPidFileInLocations([]string{staleFile, liveFile})
21+ require.NoError(t, err)
22+ assert.Equal(t, liveFile, path)
23+ assert.Equal(t, 1, pid)
24+
25+ _, statErr := os.Stat(staleFile)
26+ assert.True(t, os.IsNotExist(statErr))
27+}
28+
29+func TestFindRunningPidFileInLocations_RemovesInvalidAndReturnsError(t *testing.T) {
30+ tmpDir := t.TempDir()
31+ invalidFile := filepath.Join(tmpDir, "invalid.pid")
32+
33+ require.NoError(t, os.WriteFile(invalidFile, []byte("not-a-pid"), 0600))
34+
35+ _, _, err := findRunningPidFileInLocations([]string{invalidFile})
36+ require.Error(t, err)
37+ assert.Contains(t, err.Error(), "stale gordon PID file")
38+
39+ _, statErr := os.Stat(invalidFile)
40+ assert.True(t, os.IsNotExist(statErr))
41+}
42+
43+func TestFindRunningPidFileInLocations_NoFiles(t *testing.T) {
44+ _, _, err := findRunningPidFileInLocations([]string{})
45+ require.Error(t, err)
46+ assert.Contains(t, err.Error(), "gordon PID file not found")
47+}
@@ -0,0 +1,47 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestFindRunningPidFileInLocations_PrefersLiveProcessOverStale(t *testing.T) {
13+ tmpDir := t.TempDir()
14+ staleFile := filepath.Join(tmpDir, "stale.pid")
15+ liveFile := filepath.Join(tmpDir, "live.pid")
16+
17+ require.NoError(t, os.WriteFile(staleFile, []byte("999999"), 0600))
18+ require.NoError(t, os.WriteFile(liveFile, []byte("1"), 0600))
19+
20+ path, pid, err := findRunningPidFileInLocations([]string{staleFile, liveFile})
21+ require.NoError(t, err)
22+ assert.Equal(t, liveFile, path)
23+ assert.Equal(t, 1, pid)
24+
25+ _, statErr := os.Stat(staleFile)
26+ assert.True(t, os.IsNotExist(statErr))
27+}
28+
29+func TestFindRunningPidFileInLocations_RemovesInvalidAndReturnsError(t *testing.T) {
30+ tmpDir := t.TempDir()
31+ invalidFile := filepath.Join(tmpDir, "invalid.pid")
32+
33+ require.NoError(t, os.WriteFile(invalidFile, []byte("not-a-pid"), 0600))
34+
35+ _, _, err := findRunningPidFileInLocations([]string{invalidFile})
36+ require.Error(t, err)
37+ assert.Contains(t, err.Error(), "stale gordon PID file")
38+
39+ _, statErr := os.Stat(invalidFile)
40+ assert.True(t, os.IsNotExist(statErr))
41+}
42+
43+func TestFindRunningPidFileInLocations_NoFiles(t *testing.T) {
44+ _, _, err := findRunningPidFileInLocations([]string{})
45+ require.Error(t, err)
46+ assert.Contains(t, err.Error(), "gordon PID file not found")
47+}
M internal/usecase/container/service.go +32 -3
@@ -41,6 +41,11 @@ const (
4141 PullPolicyAlways = "always"
4242 PullPolicyIfNotPresent = "if-not-present"
4343 PullPolicyIfTagChanged = "if-tag-changed"
44+
45+ // readinessRecoveryWindow is an additional grace window used when a container
46+ // is briefly not running at the end of readiness delay. This avoids false
47+ // negatives during short startup flaps.
48+ readinessRecoveryWindow = 30 * time.Second
4449 )
4550
4651 // Service implements the ContainerService interface.
@@ -1705,11 +1710,35 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
17051710 if err != nil {
17061711 return err
17071712 }
1708- if !running {
1709- return fmt.Errorf("container stopped during readiness delay")
1713+ if running {
1714+ return nil
17101715 }
17111716
1712- return nil
1717+ // Be tolerant to short startup flaps: retry within a bounded recovery window
1718+ // before treating readiness as failed.
1719+ log.Warn().
1720+ Dur("recovery_window", readinessRecoveryWindow).
1721+ Msg("container not running after readiness delay, waiting for recovery")
1722+
1723+ deadline := time.Now().Add(readinessRecoveryWindow)
1724+ for time.Now().Before(deadline) {
1725+ select {
1726+ case <-time.After(time.Second):
1727+ case <-ctx.Done():
1728+ return ctx.Err()
1729+ }
1730+
1731+ running, err = s.runtime.IsContainerRunning(ctx, containerID)
1732+ if err != nil {
1733+ return err
1734+ }
1735+ if running {
1736+ log.Info().Msg("container recovered during readiness recovery window")
1737+ return nil
1738+ }
1739+ }
1740+
1741+ return fmt.Errorf("container stopped during readiness delay")
17131742 }
17141743
17151744 // publishContainerDeployed publishes a container.deployed event.
@@ -41,6 +41,11 @@ const (
41 PullPolicyAlways = "always"41 PullPolicyAlways = "always"
42 PullPolicyIfNotPresent = "if-not-present"42 PullPolicyIfNotPresent = "if-not-present"
43 PullPolicyIfTagChanged = "if-tag-changed"43 PullPolicyIfTagChanged = "if-tag-changed"
44+
45+ // readinessRecoveryWindow is an additional grace window used when a container
46+ // is briefly not running at the end of readiness delay. This avoids false
47+ // negatives during short startup flaps.
48+ readinessRecoveryWindow = 30 * time.Second
44 )49 )
45 50
46 // Service implements the ContainerService interface.51 // Service implements the ContainerService interface.
@@ -1705,11 +1710,35 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1705 if err != nil {1710 if err != nil {
1706 return err1711 return err
1707 }1712 }
1708- if !running {1713+ if running {
1709- return fmt.Errorf("container stopped during readiness delay")1714+ return nil
1710 }1715 }
1711 1716
1712- return nil1717+ // Be tolerant to short startup flaps: retry within a bounded recovery window
1718+ // before treating readiness as failed.
1719+ log.Warn().
1720+ Dur("recovery_window", readinessRecoveryWindow).
1721+ Msg("container not running after readiness delay, waiting for recovery")
1722+
1723+ deadline := time.Now().Add(readinessRecoveryWindow)
1724+ for time.Now().Before(deadline) {
1725+ select {
1726+ case <-time.After(time.Second):
1727+ case <-ctx.Done():
1728+ return ctx.Err()
1729+ }
1730+
1731+ running, err = s.runtime.IsContainerRunning(ctx, containerID)
1732+ if err != nil {
1733+ return err
1734+ }
1735+ if running {
1736+ log.Info().Msg("container recovered during readiness recovery window")
1737+ return nil
1738+ }
1739+ }
1740+
1741+ return fmt.Errorf("container stopped during readiness delay")
1713 }1742 }
1714 1743
1715 // publishContainerDeployed publishes a container.deployed event.1744 // publishContainerDeployed publishes a container.deployed event.
M internal/usecase/container/service_test.go +59 -0
@@ -96,6 +96,65 @@ func TestService_Deploy_Success(t *testing.T) {
9696 assert.Equal(t, "container-123", tracked.ID)
9797 }
9898
99+func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) {
100+ runtime := mocks.NewMockContainerRuntime(t)
101+ envLoader := mocks.NewMockEnvLoader(t)
102+ eventBus := mocks.NewMockEventPublisher(t)
103+
104+ config := Config{
105+ NetworkIsolation: false,
106+ VolumeAutoCreate: false,
107+ ReadinessDelay: time.Millisecond,
108+ }
109+
110+ svc := NewService(runtime, envLoader, eventBus, nil, config)
111+ ctx := testContext()
112+
113+ route := domain.Route{
114+ Domain: "test.example.com",
115+ Image: "myapp:latest",
116+ }
117+
118+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
119+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
120+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
121+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
122+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
123+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
124+
125+ createdContainer := &domain.Container{
126+ ID: "container-123",
127+ Name: "gordon-test.example.com",
128+ Image: "myapp:latest",
129+ Status: "created",
130+ }
131+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
132+ runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
133+
134+ // Readiness check sequence:
135+ // 1) Initial "started" check => running
136+ // 2) Post-delay verification => transient not-running
137+ // 3) Recovery-window poll => running again
138+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
139+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(false, nil).Once()
140+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
141+
142+ runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
143+ ID: "container-123",
144+ Name: "gordon-test.example.com",
145+ Image: "myapp:latest",
146+ Status: "running",
147+ Ports: []int{8080},
148+ }, nil)
149+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
150+
151+ result, err := svc.Deploy(ctx, route)
152+
153+ assert.NoError(t, err)
154+ assert.NotNil(t, result)
155+ assert.Equal(t, "container-123", result.ID)
156+}
157+
99158 func TestService_Deploy_ImagePullFailure(t *testing.T) {
100159 runtime := mocks.NewMockContainerRuntime(t)
101160 envLoader := mocks.NewMockEnvLoader(t)
@@ -96,6 +96,65 @@ func TestService_Deploy_Success(t *testing.T) {
96 assert.Equal(t, "container-123", tracked.ID)96 assert.Equal(t, "container-123", tracked.ID)
97 }97 }
98 98
99+func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) {
100+ runtime := mocks.NewMockContainerRuntime(t)
101+ envLoader := mocks.NewMockEnvLoader(t)
102+ eventBus := mocks.NewMockEventPublisher(t)
103+
104+ config := Config{
105+ NetworkIsolation: false,
106+ VolumeAutoCreate: false,
107+ ReadinessDelay: time.Millisecond,
108+ }
109+
110+ svc := NewService(runtime, envLoader, eventBus, nil, config)
111+ ctx := testContext()
112+
113+ route := domain.Route{
114+ Domain: "test.example.com",
115+ Image: "myapp:latest",
116+ }
117+
118+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
119+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
120+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
121+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
122+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
123+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
124+
125+ createdContainer := &domain.Container{
126+ ID: "container-123",
127+ Name: "gordon-test.example.com",
128+ Image: "myapp:latest",
129+ Status: "created",
130+ }
131+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
132+ runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
133+
134+ // Readiness check sequence:
135+ // 1) Initial "started" check => running
136+ // 2) Post-delay verification => transient not-running
137+ // 3) Recovery-window poll => running again
138+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
139+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(false, nil).Once()
140+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
141+
142+ runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
143+ ID: "container-123",
144+ Name: "gordon-test.example.com",
145+ Image: "myapp:latest",
146+ Status: "running",
147+ Ports: []int{8080},
148+ }, nil)
149+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
150+
151+ result, err := svc.Deploy(ctx, route)
152+
153+ assert.NoError(t, err)
154+ assert.NotNil(t, result)
155+ assert.Equal(t, "container-123", result.ID)
156+}
157+
99 func TestService_Deploy_ImagePullFailure(t *testing.T) {158 func TestService_Deploy_ImagePullFailure(t *testing.T) {
100 runtime := mocks.NewMockContainerRuntime(t)159 runtime := mocks.NewMockContainerRuntime(t)
101 envLoader := mocks.NewMockEnvLoader(t)160 envLoader := mocks.NewMockEnvLoader(t)

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