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. | |||
| 7 | 7 | ```toml | |
| 8 | 8 | [deploy] | |
| 9 | 9 | pull_policy = "if-tag-changed" | |
| 10 | + | readiness_delay = "5s" | |
| 10 | 11 | ``` | |
| 11 | 12 | ||
| 12 | 13 | ## Settings | |
| @@ -21,6 +22,18 @@ How to decide when to pull an image: | |||
| 21 | 22 | ||
| 22 | 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 | 36 | ## Notes | |
| 25 | 37 | ||
| 26 | 38 | - 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 | ```toml | 7 | ```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 | ## Settings | 13 | ## 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 | ## Notes | 36 | ## 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 | |||
| 145 | 145 | | `api.rate_limit.burst` | `100` | | |
| 146 | 146 | | `api.rate_limit.trusted_proxies` | `[]` | | |
| 147 | 147 | | `deploy.pull_policy` | `"if-tag-changed"` | | |
| 148 | + | | `deploy.readiness_delay` | `"5s"` | | |
| 148 | 149 | | `logging.level` | `"info"` | | |
| 149 | 150 | | `logging.format` | `"console"` | | |
| 150 | 151 | | `logging.file.enabled` | `false` | | |
| @@ -173,6 +174,7 @@ The following settings require a restart to take effect: | |||
| 173 | 174 | - `server.data_dir` | |
| 174 | 175 | - `auth` settings | |
| 175 | 176 | - `deploy.pull_policy` | |
| 177 | + | - `deploy.readiness_delay` | |
| 176 | 178 | ||
| 177 | 179 | ## Environment Variable Override | |
| 178 | 180 | ||
| @@ -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` settings | 175 | - `auth` settings | ||
| 175 | - `deploy.pull_policy` | 176 | - `deploy.pull_policy` | ||
| 177 | + | - `deploy.readiness_delay` | |||
| 176 | 178 | ||||
| 177 | ## Environment Variable Override | 179 | ## Environment Variable Override | ||
| 178 | 180 | ||||
M docs/config/reference.md +1 -0
| @@ -146,6 +146,7 @@ preserve = true # Keep volumes when containers are | |||
| 146 | 146 | | `logging.container_logs.max_backups` | `3` | Keep 3 old files | | |
| 147 | 147 | | `logging.container_logs.max_age` | `28` | 28 days | | |
| 148 | 148 | | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change | | |
| 149 | + | | `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready | | |
| 149 | 150 | | `auto_route.enabled` | `false` | Auto-route disabled | | |
| 150 | 151 | | `network_isolation.enabled` | `false` | Isolation disabled | | |
| 151 | 152 | | `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 ( | |||
| 6 | 6 | "crypto/rand" | |
| 7 | 7 | "encoding/hex" | |
| 8 | 8 | "encoding/json" | |
| 9 | + | "errors" | |
| 9 | 10 | "fmt" | |
| 10 | 11 | "net" | |
| 11 | 12 | "net/http" | |
| 12 | 13 | "os" | |
| 13 | 14 | "os/signal" | |
| 14 | 15 | "path/filepath" | |
| 16 | + | "strconv" | |
| 15 | 17 | "strings" | |
| 16 | 18 | "syscall" | |
| 17 | 19 | "time" | |
| @@ -867,6 +869,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 867 | 869 | DNSSuffix: v.GetString("network_isolation.dns_suffix"), | |
| 868 | 870 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | |
| 869 | 871 | Attachments: svc.configSvc.GetAttachments(), | |
| 872 | + | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | |
| 870 | 873 | } | |
| 871 | 874 | ||
| 872 | 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 | 1204 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | |
| 1202 | 1205 | 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() | |
| 1219 | 1207 | if err != nil { | |
| 1220 | - | return fmt.Errorf("failed to find process: %w", err) | |
| 1208 | + | return err | |
| 1221 | 1209 | } | |
| 1222 | 1210 | ||
| 1223 | 1211 | if err := process.Signal(syscall.SIGUSR1); err != nil { | |
| @@ -1270,28 +1258,10 @@ func SendDeploySignal(domain string) (string, error) { | |||
| 1270 | 1258 | } | |
| 1271 | 1259 | ||
| 1272 | 1260 | // 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() | |
| 1280 | 1262 | if err != nil { | |
| 1281 | 1263 | _ = 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 | |
| 1295 | 1265 | } | |
| 1296 | 1266 | ||
| 1297 | 1267 | if err := process.Signal(syscall.SIGUSR2); err != nil { | |
| @@ -1374,31 +1344,120 @@ func removePidFile(pidFile string, log zerowrap.Logger) { | |||
| 1374 | 1344 | // findPidFile finds the Gordon PID file. | |
| 1375 | 1345 | // Checks secure locations first, then falls back to legacy /tmp location. | |
| 1376 | 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 | 1355 | 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 | 1368 | // Check secure runtime directory first | |
| 1380 | 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 | 1384 | // Check home directory | |
| 1385 | 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 | 1387 | // 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 | 1391 | // 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 | |
| 1394 | 1406 | ||
| 1395 | 1407 | 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 | |
| 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 | 1463 | // loadConfig loads configuration from file and sets defaults. | |
| @@ -1435,6 +1494,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1435 | 1494 | v.SetDefault("volumes.prefix", "gordon") | |
| 1436 | 1495 | v.SetDefault("volumes.preserve", true) | |
| 1437 | 1496 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | |
| 1497 | + | v.SetDefault("deploy.readiness_delay", "5s") | |
| 1438 | 1498 | ||
| 1439 | 1499 | ConfigureViper(v, configPath) | |
| 1440 | 1500 | ||
| @@ -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 SIGUSR2 | 1260 | // 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 []string | 1355 | 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 first | 1368 | // 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 directory | 1384 | // 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 compatibility | 1387 | // 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 compatibility | 1391 | // 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 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 | |||
| 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 ( | |||
| 41 | 41 | PullPolicyAlways = "always" | |
| 42 | 42 | PullPolicyIfNotPresent = "if-not-present" | |
| 43 | 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 | 51 | // Service implements the ContainerService interface. | |
| @@ -1705,11 +1710,35 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||
| 1705 | 1710 | if err != nil { | |
| 1706 | 1711 | return err | |
| 1707 | 1712 | } | |
| 1708 | - | if !running { | |
| 1709 | - | return fmt.Errorf("container stopped during readiness delay") | |
| 1713 | + | if running { | |
| 1714 | + | return nil | |
| 1710 | 1715 | } | |
| 1711 | 1716 | ||
| 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") | |
| 1713 | 1742 | } | |
| 1714 | 1743 | ||
| 1715 | 1744 | // 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 err | 1711 | 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 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") | |||
| 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) { | |||
| 96 | 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 | 158 | func TestService_Deploy_ImagePullFailure(t *testing.T) { | |
| 100 | 159 | runtime := mocks.NewMockContainerRuntime(t) | |
| 101 | 160 | 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) | ||