feat(admin): add deploy-intent endpoint to suppress event-based deploys
- commit
- 8365f3130f1422c8cfeaed53c0a0ae4764918f35
- author
- brice · · 2026-03-04 06:40 UTC
- parent
- 82c3ab4
- signature
- unsigned
6 files · +201 -24
M internal/adapters/in/http/admin/handler.go +26 -0
| @@ -213,6 +213,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 213 | 213 | {"/routes/by-image", h.handleRoutesByImage}, | |
| 214 | 214 | {"/routes", h.handleRoutes}, | |
| 215 | 215 | {"/secrets", h.handleSecrets}, | |
| 216 | + | {"/deploy-intent", h.handleDeployIntent}, | |
| 216 | 217 | {"/deploy", h.handleDeploy}, | |
| 217 | 218 | {"/restart", h.handleRestart}, | |
| 218 | 219 | {"/tags", h.handleTags}, | |
| @@ -228,6 +229,31 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 228 | 229 | return nil, false | |
| 229 | 230 | } | |
| 230 | 231 | ||
| 232 | + | // handleDeployIntent handles /admin/deploy-intent/:image endpoint. | |
| 233 | + | // It registers a deploy intent, suppressing event-based deploys for the image. | |
| 234 | + | func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) { | |
| 235 | + | if r.Method != http.MethodPost { | |
| 236 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | imageName := strings.TrimPrefix(path, "/deploy-intent/") | |
| 241 | + | if imageName == "" || imageName == "/deploy-intent" { | |
| 242 | + | h.sendError(w, http.StatusBadRequest, "image name required") | |
| 243 | + | return | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | log := zerowrap.FromCtx(r.Context()) | |
| 247 | + | log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events") | |
| 248 | + | ||
| 249 | + | h.registrySvc.SuppressDeployEvent(imageName) | |
| 250 | + | ||
| 251 | + | h.sendJSON(w, http.StatusOK, map[string]string{ | |
| 252 | + | "status": "ok", | |
| 253 | + | "image": imageName, | |
| 254 | + | }) | |
| 255 | + | } | |
| 256 | + | ||
| 231 | 257 | // sendJSON sends a JSON response. | |
| 232 | 258 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | |
| 233 | 259 | w.Header().Set("Content-Type", "application/json") | |
| @@ -213,6 +213,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 213 | {"/routes/by-image", h.handleRoutesByImage}, | 213 | {"/routes/by-image", h.handleRoutesByImage}, | ||
| 214 | {"/routes", h.handleRoutes}, | 214 | {"/routes", h.handleRoutes}, | ||
| 215 | {"/secrets", h.handleSecrets}, | 215 | {"/secrets", h.handleSecrets}, | ||
| 216 | + | {"/deploy-intent", h.handleDeployIntent}, | |||
| 216 | {"/deploy", h.handleDeploy}, | 217 | {"/deploy", h.handleDeploy}, | ||
| 217 | {"/restart", h.handleRestart}, | 218 | {"/restart", h.handleRestart}, | ||
| 218 | {"/tags", h.handleTags}, | 219 | {"/tags", h.handleTags}, | ||
| @@ -228,6 +229,31 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 228 | return nil, false | 229 | return nil, false | ||
| 229 | } | 230 | } | ||
| 230 | 231 | ||||
| 232 | + | // handleDeployIntent handles /admin/deploy-intent/:image endpoint. | |||
| 233 | + | // It registers a deploy intent, suppressing event-based deploys for the image. | |||
| 234 | + | func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) { | |||
| 235 | + | if r.Method != http.MethodPost { | |||
| 236 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | imageName := strings.TrimPrefix(path, "/deploy-intent/") | |||
| 241 | + | if imageName == "" || imageName == "/deploy-intent" { | |||
| 242 | + | h.sendError(w, http.StatusBadRequest, "image name required") | |||
| 243 | + | return | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | log := zerowrap.FromCtx(r.Context()) | |||
| 247 | + | log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events") | |||
| 248 | + | ||||
| 249 | + | h.registrySvc.SuppressDeployEvent(imageName) | |||
| 250 | + | ||||
| 251 | + | h.sendJSON(w, http.StatusOK, map[string]string{ | |||
| 252 | + | "status": "ok", | |||
| 253 | + | "image": imageName, | |||
| 254 | + | }) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 231 | // sendJSON sends a JSON response. | 257 | // sendJSON sends a JSON response. | ||
| 232 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | 258 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | ||
| 233 | w.Header().Set("Content-Type", "application/json") | 259 | w.Header().Set("Content-Type", "application/json") | ||
M internal/boundaries/in/mocks/mock_registry_service.go +80 -0
| @@ -231,6 +231,46 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte | |||
| 231 | 231 | return _c | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | + | // ClearDeployEventSuppression provides a mock function for the type MockRegistryService | |
| 235 | + | func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) { | |
| 236 | + | _mock.Called(imageName) | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression' | |
| 241 | + | type MockRegistryService_ClearDeployEventSuppression_Call struct { | |
| 242 | + | *mock.Call | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // ClearDeployEventSuppression is a helper method to define mock.On call | |
| 246 | + | // - imageName string | |
| 247 | + | func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 248 | + | return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)} | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 252 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 253 | + | var arg0 string | |
| 254 | + | if args[0] != nil { | |
| 255 | + | arg0 = args[0].(string) | |
| 256 | + | } | |
| 257 | + | run( | |
| 258 | + | arg0, | |
| 259 | + | ) | |
| 260 | + | }) | |
| 261 | + | return _c | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 265 | + | _c.Call.Return() | |
| 266 | + | return _c | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 270 | + | _c.Run(run) | |
| 271 | + | return _c | |
| 272 | + | } | |
| 273 | + | ||
| 234 | 274 | // DeleteManifest provides a mock function for the type MockRegistryService | |
| 235 | 275 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | |
| 236 | 276 | ret := _mock.Called(ctx, name, reference) | |
| @@ -895,3 +935,43 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex | |||
| 895 | 935 | _c.Call.Return(run) | |
| 896 | 936 | return _c | |
| 897 | 937 | } | |
| 938 | + | ||
| 939 | + | // SuppressDeployEvent provides a mock function for the type MockRegistryService | |
| 940 | + | func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) { | |
| 941 | + | _mock.Called(imageName) | |
| 942 | + | return | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | // MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent' | |
| 946 | + | type MockRegistryService_SuppressDeployEvent_Call struct { | |
| 947 | + | *mock.Call | |
| 948 | + | } | |
| 949 | + | ||
| 950 | + | // SuppressDeployEvent is a helper method to define mock.On call | |
| 951 | + | // - imageName string | |
| 952 | + | func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call { | |
| 953 | + | return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)} | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |
| 957 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 958 | + | var arg0 string | |
| 959 | + | if args[0] != nil { | |
| 960 | + | arg0 = args[0].(string) | |
| 961 | + | } | |
| 962 | + | run( | |
| 963 | + | arg0, | |
| 964 | + | ) | |
| 965 | + | }) | |
| 966 | + | return _c | |
| 967 | + | } | |
| 968 | + | ||
| 969 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call { | |
| 970 | + | _c.Call.Return() | |
| 971 | + | return _c | |
| 972 | + | } | |
| 973 | + | ||
| 974 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |
| 975 | + | _c.Run(run) | |
| 976 | + | return _c | |
| 977 | + | } | |
| @@ -231,6 +231,46 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte | |||||
| 231 | return _c | 231 | return _c | ||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | + | // ClearDeployEventSuppression provides a mock function for the type MockRegistryService | |||
| 235 | + | func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) { | |||
| 236 | + | _mock.Called(imageName) | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression' | |||
| 241 | + | type MockRegistryService_ClearDeployEventSuppression_Call struct { | |||
| 242 | + | *mock.Call | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // ClearDeployEventSuppression is a helper method to define mock.On call | |||
| 246 | + | // - imageName string | |||
| 247 | + | func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 248 | + | return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)} | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 252 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 253 | + | var arg0 string | |||
| 254 | + | if args[0] != nil { | |||
| 255 | + | arg0 = args[0].(string) | |||
| 256 | + | } | |||
| 257 | + | run( | |||
| 258 | + | arg0, | |||
| 259 | + | ) | |||
| 260 | + | }) | |||
| 261 | + | return _c | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 265 | + | _c.Call.Return() | |||
| 266 | + | return _c | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 270 | + | _c.Run(run) | |||
| 271 | + | return _c | |||
| 272 | + | } | |||
| 273 | + | ||||
| 234 | // DeleteManifest provides a mock function for the type MockRegistryService | 274 | // DeleteManifest provides a mock function for the type MockRegistryService | ||
| 235 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | 275 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | ||
| 236 | ret := _mock.Called(ctx, name, reference) | 276 | ret := _mock.Called(ctx, name, reference) | ||
| @@ -895,3 +935,43 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex | |||||
| 895 | _c.Call.Return(run) | 935 | _c.Call.Return(run) | ||
| 896 | return _c | 936 | return _c | ||
| 897 | } | 937 | } | ||
| 938 | + | ||||
| 939 | + | // SuppressDeployEvent provides a mock function for the type MockRegistryService | |||
| 940 | + | func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) { | |||
| 941 | + | _mock.Called(imageName) | |||
| 942 | + | return | |||
| 943 | + | } | |||
| 944 | + | ||||
| 945 | + | // MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent' | |||
| 946 | + | type MockRegistryService_SuppressDeployEvent_Call struct { | |||
| 947 | + | *mock.Call | |||
| 948 | + | } | |||
| 949 | + | ||||
| 950 | + | // SuppressDeployEvent is a helper method to define mock.On call | |||
| 951 | + | // - imageName string | |||
| 952 | + | func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call { | |||
| 953 | + | return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)} | |||
| 954 | + | } | |||
| 955 | + | ||||
| 956 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |||
| 957 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 958 | + | var arg0 string | |||
| 959 | + | if args[0] != nil { | |||
| 960 | + | arg0 = args[0].(string) | |||
| 961 | + | } | |||
| 962 | + | run( | |||
| 963 | + | arg0, | |||
| 964 | + | ) | |||
| 965 | + | }) | |||
| 966 | + | return _c | |||
| 967 | + | } | |||
| 968 | + | ||||
| 969 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call { | |||
| 970 | + | _c.Call.Return() | |||
| 971 | + | return _c | |||
| 972 | + | } | |||
| 973 | + | ||||
| 974 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |||
| 975 | + | _c.Run(run) | |||
| 976 | + | return _c | |||
| 977 | + | } | |||
M internal/boundaries/in/registry.go +4 -0
| @@ -29,4 +29,8 @@ type RegistryService interface { | |||
| 29 | 29 | // Tag operations | |
| 30 | 30 | ListTags(ctx context.Context, name string) ([]string, error) | |
| 31 | 31 | ListRepositories(ctx context.Context) ([]string, error) | |
| 32 | + | ||
| 33 | + | // Deploy event suppression (prevents double-deploy when CLI manages the push) | |
| 34 | + | SuppressDeployEvent(imageName string) | |
| 35 | + | ClearDeployEventSuppression(imageName string) | |
| 32 | 36 | } | |
| @@ -29,4 +29,8 @@ type RegistryService interface { | |||||
| 29 | // Tag operations | 29 | // Tag operations | ||
| 30 | ListTags(ctx context.Context, name string) ([]string, error) | 30 | ListTags(ctx context.Context, name string) ([]string, error) | ||
| 31 | ListRepositories(ctx context.Context) ([]string, error) | 31 | ListRepositories(ctx context.Context) ([]string, error) | ||
| 32 | + | ||||
| 33 | + | // Deploy event suppression (prevents double-deploy when CLI manages the push) | |||
| 34 | + | SuppressDeployEvent(imageName string) | |||
| 35 | + | ClearDeployEventSuppression(imageName string) | |||
| 32 | } | 36 | } | ||
M internal/usecase/container/readiness.go +12 -1
| @@ -6,20 +6,25 @@ import ( | |||
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | // tcpProbe attempts a TCP connection to addr, retrying every 500ms until | |
| 12 | 14 | // success or timeout. This is the universal fallback readiness check — | |
| 13 | 15 | // it verifies the process is at least accepting connections. | |
| 14 | 16 | func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error { | |
| 17 | + | log := zerowrap.FromCtx(ctx) | |
| 15 | 18 | deadline := time.Now().Add(timeout) | |
| 16 | 19 | dialer := &net.Dialer{} | |
| 20 | + | attempts := 0 | |
| 21 | + | var lastErr error | |
| 17 | 22 | for { | |
| 18 | 23 | if err := ctx.Err(); err != nil { | |
| 19 | 24 | return err | |
| 20 | 25 | } | |
| 21 | 26 | if time.Now().After(deadline) { | |
| 22 | - | return fmt.Errorf("TCP probe timeout after %s: %s not reachable", timeout, addr) | |
| 27 | + | return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr) | |
| 23 | 28 | } | |
| 24 | 29 | ||
| 25 | 30 | remaining := time.Until(deadline) | |
| @@ -30,10 +35,16 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error { | |||
| 30 | 35 | attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout) | |
| 31 | 36 | conn, err := dialer.DialContext(attemptCtx, "tcp", addr) | |
| 32 | 37 | cancel() | |
| 38 | + | attempts++ | |
| 33 | 39 | if err == nil { | |
| 34 | 40 | conn.Close() | |
| 41 | + | log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected") | |
| 35 | 42 | return nil | |
| 36 | 43 | } | |
| 44 | + | lastErr = err | |
| 45 | + | if attempts <= 3 || attempts%10 == 0 { | |
| 46 | + | log.Debug().Err(err).Str("addr", addr).Int("attempt", attempts).Msg("TCP probe attempt failed") | |
| 47 | + | } | |
| 37 | 48 | ||
| 38 | 49 | select { | |
| 39 | 50 | case <-time.After(500 * time.Millisecond): | |
| @@ -6,20 +6,25 @@ import ( | |||||
| 6 | "net" | 6 | "net" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | // tcpProbe attempts a TCP connection to addr, retrying every 500ms until | 13 | // tcpProbe attempts a TCP connection to addr, retrying every 500ms until | ||
| 12 | // success or timeout. This is the universal fallback readiness check — | 14 | // success or timeout. This is the universal fallback readiness check — | ||
| 13 | // it verifies the process is at least accepting connections. | 15 | // it verifies the process is at least accepting connections. | ||
| 14 | func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error { | 16 | func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error { | ||
| 17 | + | log := zerowrap.FromCtx(ctx) | |||
| 15 | deadline := time.Now().Add(timeout) | 18 | deadline := time.Now().Add(timeout) | ||
| 16 | dialer := &net.Dialer{} | 19 | dialer := &net.Dialer{} | ||
| 20 | + | attempts := 0 | |||
| 21 | + | var lastErr error | |||
| 17 | for { | 22 | for { | ||
| 18 | if err := ctx.Err(); err != nil { | 23 | if err := ctx.Err(); err != nil { | ||
| 19 | return err | 24 | return err | ||
| 20 | } | 25 | } | ||
| 21 | if time.Now().After(deadline) { | 26 | if time.Now().After(deadline) { | ||
| 22 | - | return fmt.Errorf("TCP probe timeout after %s: %s not reachable", timeout, addr) | 27 | + | return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr) |
| 23 | } | 28 | } | ||
| 24 | 29 | ||||
| 25 | remaining := time.Until(deadline) | 30 | remaining := time.Until(deadline) | ||
| @@ -30,10 +35,16 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error { | |||||
| 30 | attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout) | 35 | attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout) | ||
| 31 | conn, err := dialer.DialContext(attemptCtx, "tcp", addr) | 36 | conn, err := dialer.DialContext(attemptCtx, "tcp", addr) | ||
| 32 | cancel() | 37 | cancel() | ||
| 38 | + | attempts++ | |||
| 33 | if err == nil { | 39 | if err == nil { | ||
| 34 | conn.Close() | 40 | conn.Close() | ||
| 41 | + | log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected") | |||
| 35 | return nil | 42 | return nil | ||
| 36 | } | 43 | } | ||
| 44 | + | lastErr = err | |||
| 45 | + | if attempts <= 3 || attempts%10 == 0 { | |||
| 46 | + | log.Debug().Err(err).Str("addr", addr).Int("attempt", attempts).Msg("TCP probe attempt failed") | |||
| 47 | + | } | |||
| 37 | 48 | ||||
| 38 | select { | 49 | select { | ||
| 39 | case <-time.After(500 * time.Millisecond): | 50 | case <-time.After(500 * time.Millisecond): | ||
M internal/usecase/container/service.go +75 -23
| @@ -407,6 +407,11 @@ type deployResources struct { | |||
| 407 | 407 | // It first checks the in-memory map (fast path). If not found, it queries the | |
| 408 | 408 | // runtime for a running container with matching name and managed label. This | |
| 409 | 409 | // handles cases where Gordon restarted and in-memory state is stale. | |
| 410 | + | // | |
| 411 | + | // The slow path checks canonical, -new, and -next names because a previous | |
| 412 | + | // deploy may have been interrupted (e.g. eventbus timeout, Gordon restart) | |
| 413 | + | // leaving the active container under a temp name. Canonical is preferred when | |
| 414 | + | // multiple matches exist. | |
| 410 | 415 | func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) { | |
| 411 | 416 | // Fast path: check in-memory state | |
| 412 | 417 | s.mu.RLock() | |
| @@ -418,7 +423,12 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin | |||
| 418 | 423 | ||
| 419 | 424 | // Slow path: query runtime for running containers | |
| 420 | 425 | log := zerowrap.FromCtx(ctx) | |
| 421 | - | expectedName := fmt.Sprintf("gordon-%s", domainName) | |
| 426 | + | canonicalName := fmt.Sprintf("gordon-%s", domainName) | |
| 427 | + | candidateNames := map[string]bool{ | |
| 428 | + | canonicalName: true, | |
| 429 | + | canonicalName + "-new": true, | |
| 430 | + | canonicalName + "-next": true, | |
| 431 | + | } | |
| 422 | 432 | ||
| 423 | 433 | running, err := s.runtime.ListContainers(ctx, false) | |
| 424 | 434 | if err != nil { | |
| @@ -426,23 +436,32 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin | |||
| 426 | 436 | return nil, false | |
| 427 | 437 | } | |
| 428 | 438 | ||
| 439 | + | // Prefer canonical name; fall back to any running managed temp container. | |
| 440 | + | var best *domain.Container | |
| 429 | 441 | for _, c := range running { | |
| 430 | - | if c.Name == expectedName && c.Labels[domain.LabelManaged] == "true" { | |
| 431 | - | log.Info(). | |
| 432 | - | Str("container_id", c.ID). | |
| 433 | - | Str("container_name", c.Name). | |
| 434 | - | Msg("resolved existing container from runtime (in-memory state was stale)") | |
| 435 | - | ||
| 436 | - | // Update in-memory state so subsequent lookups are fast | |
| 437 | - | s.mu.Lock() | |
| 438 | - | if _, alreadyTracked := s.containers[domainName]; !alreadyTracked { | |
| 439 | - | s.managedCount++ | |
| 440 | - | } | |
| 441 | - | s.containers[domainName] = c | |
| 442 | - | s.mu.Unlock() | |
| 442 | + | if !candidateNames[c.Name] || c.Labels[domain.LabelManaged] != "true" { | |
| 443 | + | continue | |
| 444 | + | } | |
| 445 | + | if best == nil || c.Name == canonicalName { | |
| 446 | + | best = c | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | if best != nil { | |
| 451 | + | log.Info(). | |
| 452 | + | Str("container_id", best.ID). | |
| 453 | + | Str("container_name", best.Name). | |
| 454 | + | Msg("resolved existing container from runtime (in-memory state was stale)") | |
| 443 | 455 | ||
| 444 | - | return c, true | |
| 456 | + | // Update in-memory state so subsequent lookups are fast | |
| 457 | + | s.mu.Lock() | |
| 458 | + | if _, alreadyTracked := s.containers[domainName]; !alreadyTracked { | |
| 459 | + | s.managedCount++ | |
| 445 | 460 | } | |
| 461 | + | s.containers[domainName] = best | |
| 462 | + | s.mu.Unlock() | |
| 463 | + | ||
| 464 | + | return best, true | |
| 446 | 465 | } | |
| 447 | 466 | ||
| 448 | 467 | return nil, false | |
| @@ -1744,19 +1763,26 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||
| 1744 | 1763 | ||
| 1745 | 1764 | for _, c := range allContainers { | |
| 1746 | 1765 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | |
| 1747 | - | // Never kill a running container with the canonical name — it may be | |
| 1748 | - | // the active container serving traffic. Only temp (-new/-next) containers | |
| 1749 | - | // and stopped canonical containers are safe to remove. | |
| 1766 | + | // The skipContainerID is the container we resolved as the active | |
| 1767 | + | // one serving traffic. Any other container with a matching name is | |
| 1768 | + | // an orphan from a previous (possibly interrupted) deploy. | |
| 1769 | + | // | |
| 1770 | + | // Running containers with the canonical name are protected — they | |
| 1771 | + | // could be legitimate if resolveExistingContainer found a temp | |
| 1772 | + | // container instead. Running temp containers (-new/-next) that are | |
| 1773 | + | // NOT the skip container are safe to remove: they are leftovers | |
| 1774 | + | // from failed deploys. | |
| 1750 | 1775 | isCanonical := c.Name == expectedName | |
| 1751 | 1776 | if isCanonical && c.Status == "running" { | |
| 1752 | 1777 | log.Debug(). | |
| 1753 | 1778 | Str(zerowrap.FieldEntityID, c.ID). | |
| 1779 | + | Str("container_name", c.Name). | |
| 1754 | 1780 | Str(zerowrap.FieldStatus, c.Status). | |
| 1755 | 1781 | Msg("skipping running canonical container during orphan cleanup") | |
| 1756 | 1782 | continue | |
| 1757 | 1783 | } | |
| 1758 | 1784 | ||
| 1759 | - | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | |
| 1785 | + | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str("container_name", c.Name).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | |
| 1760 | 1786 | ||
| 1761 | 1787 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | |
| 1762 | 1788 | log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID}) | |
| @@ -2344,14 +2370,40 @@ func (s *Service) tryTCPProbe(ctx context.Context, containerID string, container | |||
| 2344 | 2370 | return true, tcpProbe(ctx, addr, timeout) | |
| 2345 | 2371 | } | |
| 2346 | 2372 | ||
| 2347 | - | // resolveContainerEndpoint retrieves the container's IP and first port via | |
| 2348 | - | // GetContainerNetworkInfo. The result can be used for HTTP or TCP probes. | |
| 2373 | + | // resolveContainerEndpoint returns a host-reachable address for probing. | |
| 2374 | + | // In rootless podman/Docker setups, container internal IPs are not routable | |
| 2375 | + | // from the host. We use the host port binding (127.0.0.1:<mapped_port>) | |
| 2376 | + | // which is always reachable. Falls back to the container's internal IP | |
| 2377 | + | // only if no host port mapping exists (e.g. host-network mode). | |
| 2349 | 2378 | func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) { | |
| 2350 | - | ip, port, err := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |
| 2379 | + | log := zerowrap.FromCtx(ctx) | |
| 2380 | + | ||
| 2381 | + | // First, get the container's internal port from network info | |
| 2382 | + | _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |
| 2351 | 2383 | if err != nil { | |
| 2352 | 2384 | return "", 0, err | |
| 2353 | 2385 | } | |
| 2354 | - | return ip, port, nil | |
| 2386 | + | ||
| 2387 | + | // Try to resolve via host port binding (works in rootless podman/Docker) | |
| 2388 | + | hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort) | |
| 2389 | + | if hostErr == nil && hostPort > 0 { | |
| 2390 | + | log.Debug(). | |
| 2391 | + | Int("internal_port", internalPort). | |
| 2392 | + | Int("host_port", hostPort). | |
| 2393 | + | Msg("resolved container endpoint via host port binding") | |
| 2394 | + | return "127.0.0.1", hostPort, nil | |
| 2395 | + | } | |
| 2396 | + | ||
| 2397 | + | // Fallback: use internal IP (works in Docker rootful / host network) | |
| 2398 | + | ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |
| 2399 | + | if fallbackErr != nil { | |
| 2400 | + | return "", 0, fallbackErr | |
| 2401 | + | } | |
| 2402 | + | log.Debug(). | |
| 2403 | + | Str("ip", ip). | |
| 2404 | + | Int("port", internalPort). | |
| 2405 | + | Msg("resolved container endpoint via internal IP (no host port binding)") | |
| 2406 | + | return ip, internalPort, nil | |
| 2355 | 2407 | } | |
| 2356 | 2408 | ||
| 2357 | 2409 | // waitForReadyByDelay waits using the legacy running+delay strategy. | |
| @@ -407,6 +407,11 @@ type deployResources struct { | |||||
| 407 | // It first checks the in-memory map (fast path). If not found, it queries the | 407 | // It first checks the in-memory map (fast path). If not found, it queries the | ||
| 408 | // runtime for a running container with matching name and managed label. This | 408 | // runtime for a running container with matching name and managed label. This | ||
| 409 | // handles cases where Gordon restarted and in-memory state is stale. | 409 | // handles cases where Gordon restarted and in-memory state is stale. | ||
| 410 | + | // | |||
| 411 | + | // The slow path checks canonical, -new, and -next names because a previous | |||
| 412 | + | // deploy may have been interrupted (e.g. eventbus timeout, Gordon restart) | |||
| 413 | + | // leaving the active container under a temp name. Canonical is preferred when | |||
| 414 | + | // multiple matches exist. | |||
| 410 | func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) { | 415 | func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) { | ||
| 411 | // Fast path: check in-memory state | 416 | // Fast path: check in-memory state | ||
| 412 | s.mu.RLock() | 417 | s.mu.RLock() | ||
| @@ -418,7 +423,12 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin | |||||
| 418 | 423 | ||||
| 419 | // Slow path: query runtime for running containers | 424 | // Slow path: query runtime for running containers | ||
| 420 | log := zerowrap.FromCtx(ctx) | 425 | log := zerowrap.FromCtx(ctx) | ||
| 421 | - | expectedName := fmt.Sprintf("gordon-%s", domainName) | 426 | + | canonicalName := fmt.Sprintf("gordon-%s", domainName) |
| 427 | + | candidateNames := map[string]bool{ | |||
| 428 | + | canonicalName: true, | |||
| 429 | + | canonicalName + "-new": true, | |||
| 430 | + | canonicalName + "-next": true, | |||
| 431 | + | } | |||
| 422 | 432 | ||||
| 423 | running, err := s.runtime.ListContainers(ctx, false) | 433 | running, err := s.runtime.ListContainers(ctx, false) | ||
| 424 | if err != nil { | 434 | if err != nil { | ||
| @@ -426,23 +436,32 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin | |||||
| 426 | return nil, false | 436 | return nil, false | ||
| 427 | } | 437 | } | ||
| 428 | 438 | ||||
| 439 | + | // Prefer canonical name; fall back to any running managed temp container. | |||
| 440 | + | var best *domain.Container | |||
| 429 | for _, c := range running { | 441 | for _, c := range running { | ||
| 430 | - | if c.Name == expectedName && c.Labels[domain.LabelManaged] == "true" { | 442 | + | if !candidateNames[c.Name] || c.Labels[domain.LabelManaged] != "true" { |
| 431 | - | log.Info(). | 443 | + | continue |
| 432 | - | Str("container_id", c.ID). | 444 | + | } |
| 433 | - | Str("container_name", c.Name). | 445 | + | if best == nil || c.Name == canonicalName { |
| 434 | - | Msg("resolved existing container from runtime (in-memory state was stale)") | 446 | + | best = c |
| 435 | - | 447 | + | } | |
| 436 | - | // Update in-memory state so subsequent lookups are fast | 448 | + | } |
| 437 | - | s.mu.Lock() | 449 | + | |
| 438 | - | if _, alreadyTracked := s.containers[domainName]; !alreadyTracked { | 450 | + | if best != nil { |
| 439 | - | s.managedCount++ | 451 | + | log.Info(). |
| 440 | - | } | 452 | + | Str("container_id", best.ID). |
| 441 | - | s.containers[domainName] = c | 453 | + | Str("container_name", best.Name). |
| 442 | - | s.mu.Unlock() | 454 | + | Msg("resolved existing container from runtime (in-memory state was stale)") |
| 443 | 455 | ||||
| 444 | - | return c, true | 456 | + | // Update in-memory state so subsequent lookups are fast |
| 457 | + | s.mu.Lock() | |||
| 458 | + | if _, alreadyTracked := s.containers[domainName]; !alreadyTracked { | |||
| 459 | + | s.managedCount++ | |||
| 445 | } | 460 | } | ||
| 461 | + | s.containers[domainName] = best | |||
| 462 | + | s.mu.Unlock() | |||
| 463 | + | ||||
| 464 | + | return best, true | |||
| 446 | } | 465 | } | ||
| 447 | 466 | ||||
| 448 | return nil, false | 467 | return nil, false | ||
| @@ -1744,19 +1763,26 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||||
| 1744 | 1763 | ||||
| 1745 | for _, c := range allContainers { | 1764 | for _, c := range allContainers { | ||
| 1746 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | 1765 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | ||
| 1747 | - | // Never kill a running container with the canonical name — it may be | 1766 | + | // The skipContainerID is the container we resolved as the active |
| 1748 | - | // the active container serving traffic. Only temp (-new/-next) containers | 1767 | + | // one serving traffic. Any other container with a matching name is |
| 1749 | - | // and stopped canonical containers are safe to remove. | 1768 | + | // an orphan from a previous (possibly interrupted) deploy. |
| 1769 | + | // | |||
| 1770 | + | // Running containers with the canonical name are protected — they | |||
| 1771 | + | // could be legitimate if resolveExistingContainer found a temp | |||
| 1772 | + | // container instead. Running temp containers (-new/-next) that are | |||
| 1773 | + | // NOT the skip container are safe to remove: they are leftovers | |||
| 1774 | + | // from failed deploys. | |||
| 1750 | isCanonical := c.Name == expectedName | 1775 | isCanonical := c.Name == expectedName | ||
| 1751 | if isCanonical && c.Status == "running" { | 1776 | if isCanonical && c.Status == "running" { | ||
| 1752 | log.Debug(). | 1777 | log.Debug(). | ||
| 1753 | Str(zerowrap.FieldEntityID, c.ID). | 1778 | Str(zerowrap.FieldEntityID, c.ID). | ||
| 1779 | + | Str("container_name", c.Name). | |||
| 1754 | Str(zerowrap.FieldStatus, c.Status). | 1780 | Str(zerowrap.FieldStatus, c.Status). | ||
| 1755 | Msg("skipping running canonical container during orphan cleanup") | 1781 | Msg("skipping running canonical container during orphan cleanup") | ||
| 1756 | continue | 1782 | continue | ||
| 1757 | } | 1783 | } | ||
| 1758 | 1784 | ||||
| 1759 | - | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | 1785 | + | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str("container_name", c.Name).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") |
| 1760 | 1786 | ||||
| 1761 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | 1787 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | ||
| 1762 | log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID}) | 1788 | log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID}) | ||
| @@ -2344,14 +2370,40 @@ func (s *Service) tryTCPProbe(ctx context.Context, containerID string, container | |||||
| 2344 | return true, tcpProbe(ctx, addr, timeout) | 2370 | return true, tcpProbe(ctx, addr, timeout) | ||
| 2345 | } | 2371 | } | ||
| 2346 | 2372 | ||||
| 2347 | - | // resolveContainerEndpoint retrieves the container's IP and first port via | 2373 | + | // resolveContainerEndpoint returns a host-reachable address for probing. |
| 2348 | - | // GetContainerNetworkInfo. The result can be used for HTTP or TCP probes. | 2374 | + | // In rootless podman/Docker setups, container internal IPs are not routable |
| 2375 | + | // from the host. We use the host port binding (127.0.0.1:<mapped_port>) | |||
| 2376 | + | // which is always reachable. Falls back to the container's internal IP | |||
| 2377 | + | // only if no host port mapping exists (e.g. host-network mode). | |||
| 2349 | func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) { | 2378 | func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) { | ||
| 2350 | - | ip, port, err := s.runtime.GetContainerNetworkInfo(ctx, containerID) | 2379 | + | log := zerowrap.FromCtx(ctx) |
| 2380 | + | ||||
| 2381 | + | // First, get the container's internal port from network info | |||
| 2382 | + | _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |||
| 2351 | if err != nil { | 2383 | if err != nil { | ||
| 2352 | return "", 0, err | 2384 | return "", 0, err | ||
| 2353 | } | 2385 | } | ||
| 2354 | - | return ip, port, nil | 2386 | + | |
| 2387 | + | // Try to resolve via host port binding (works in rootless podman/Docker) | |||
| 2388 | + | hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort) | |||
| 2389 | + | if hostErr == nil && hostPort > 0 { | |||
| 2390 | + | log.Debug(). | |||
| 2391 | + | Int("internal_port", internalPort). | |||
| 2392 | + | Int("host_port", hostPort). | |||
| 2393 | + | Msg("resolved container endpoint via host port binding") | |||
| 2394 | + | return "127.0.0.1", hostPort, nil | |||
| 2395 | + | } | |||
| 2396 | + | ||||
| 2397 | + | // Fallback: use internal IP (works in Docker rootful / host network) | |||
| 2398 | + | ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |||
| 2399 | + | if fallbackErr != nil { | |||
| 2400 | + | return "", 0, fallbackErr | |||
| 2401 | + | } | |||
| 2402 | + | log.Debug(). | |||
| 2403 | + | Str("ip", ip). | |||
| 2404 | + | Int("port", internalPort). | |||
| 2405 | + | Msg("resolved container endpoint via internal IP (no host port binding)") | |||
| 2406 | + | return ip, internalPort, nil | |||
| 2355 | } | 2407 | } | ||
| 2356 | 2408 | ||||
| 2357 | // waitForReadyByDelay waits using the legacy running+delay strategy. | 2409 | // waitForReadyByDelay waits using the legacy running+delay strategy. | ||
M internal/usecase/container/service_readiness_test.go +4 -0
| @@ -70,6 +70,8 @@ func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabe | |||
| 70 | 70 | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 71 | 71 | // Cascade resolves container endpoint for TCP probe | |
| 72 | 72 | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | |
| 73 | + | // Host port binding resolution — return the same loopback addr so probe hits it | |
| 74 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once() | |
| 73 | 75 | ||
| 74 | 76 | // TCP probe will try to connect — which will fail since the port is closed. | |
| 75 | 77 | // Use a short health timeout so the test doesn't hang. | |
| @@ -109,6 +111,8 @@ func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *test | |||
| 109 | 111 | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 110 | 112 | // Cascade resolves container endpoint for HTTP probe | |
| 111 | 113 | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | |
| 114 | + | // Host port binding resolution — return the same loopback addr so probe hits it | |
| 115 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once() | |
| 112 | 116 | ||
| 113 | 117 | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 114 | 118 | // Expect HTTP probe timeout (no server listening) | |
| @@ -70,6 +70,8 @@ func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabe | |||||
| 70 | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | 70 | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | ||
| 71 | // Cascade resolves container endpoint for TCP probe | 71 | // Cascade resolves container endpoint for TCP probe | ||
| 72 | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | 72 | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | ||
| 73 | + | // Host port binding resolution — return the same loopback addr so probe hits it | |||
| 74 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once() | |||
| 73 | 75 | ||||
| 74 | // TCP probe will try to connect — which will fail since the port is closed. | 76 | // TCP probe will try to connect — which will fail since the port is closed. | ||
| 75 | // Use a short health timeout so the test doesn't hang. | 77 | // Use a short health timeout so the test doesn't hang. | ||
| @@ -109,6 +111,8 @@ func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *test | |||||
| 109 | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | 111 | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | ||
| 110 | // Cascade resolves container endpoint for HTTP probe | 112 | // Cascade resolves container endpoint for HTTP probe | ||
| 111 | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | 113 | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | ||
| 114 | + | // Host port binding resolution — return the same loopback addr so probe hits it | |||
| 115 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once() | |||
| 112 | 116 | ||||
| 113 | err := svc.waitForReady(ctx, containerID, containerConfig) | 117 | err := svc.waitForReady(ctx, containerID, containerConfig) | ||
| 114 | // Expect HTTP probe timeout (no server listening) | 118 | // Expect HTTP probe timeout (no server listening) | ||