feat(deployment): probe stateless single-port TCP services without declared readiness
- commit
- a2f0e019a8ba6e0c9df0443aa353e41125ac7498
- author
- brice · · 2026-10-16 20:17 UTC
- parent
- 7a64a5a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +65 -5
M docs/config/apps.md +2 -0
| @@ -168,6 +168,8 @@ A container port declared by both an internal HTTP interface and an externally b | |||
| 168 | 168 | ||
| 169 | 169 | `type = "tcp"` on a published port is ready when a connection to the loopback backend is accepted and then either receives data or stays open for 300ms. A connection closed right after accept counts as not ready: the host port relay of rootless Docker accepts on behalf of a container that does not listen yet. A workload that accepts and closes immediately by design should use `log` readiness instead. | |
| 170 | 170 | ||
| 171 | + | When a service declares no readiness (or `none`), deploy and restart still probe it in two cases: a stateless service whose interfaces are all public HTTP gets an `http` probe on `/`, and a stateless service with exactly one TCP interface and no HTTP or UDP interface gets a `tcp` probe on that port. Other services without a declared probe are not checked. | |
| 172 | + | ||
| 171 | 173 | ## TLS | |
| 172 | 174 | ||
| 173 | 175 | TLS applies to public interfaces only; an internal interface never declares `tls`. | |
| @@ -168,6 +168,8 @@ A container port declared by both an internal HTTP interface and an externally b | |||||
| 168 | 168 | ||||
| 169 | `type = "tcp"` on a published port is ready when a connection to the loopback backend is accepted and then either receives data or stays open for 300ms. A connection closed right after accept counts as not ready: the host port relay of rootless Docker accepts on behalf of a container that does not listen yet. A workload that accepts and closes immediately by design should use `log` readiness instead. | 169 | `type = "tcp"` on a published port is ready when a connection to the loopback backend is accepted and then either receives data or stays open for 300ms. A connection closed right after accept counts as not ready: the host port relay of rootless Docker accepts on behalf of a container that does not listen yet. A workload that accepts and closes immediately by design should use `log` readiness instead. | ||
| 170 | 170 | ||||
| 171 | + | When a service declares no readiness (or `none`), deploy and restart still probe it in two cases: a stateless service whose interfaces are all public HTTP gets an `http` probe on `/`, and a stateless service with exactly one TCP interface and no HTTP or UDP interface gets a `tcp` probe on that port. Other services without a declared probe are not checked. | |||
| 172 | + | ||||
| 171 | ## TLS | 173 | ## TLS | ||
| 172 | 174 | ||||
| 173 | TLS applies to public interfaces only; an internal interface never declares `tls`. | 175 | TLS applies to public interfaces only; an internal interface never declares `tls`. | ||
M internal/usecase/deployment/bind_policy_internal_test.go +43 -2
| @@ -227,7 +227,47 @@ func TestSingleWriterRequiredAndImplicitHTTPReadiness(t *testing.T) { | |||
| 227 | 227 | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, | |
| 228 | 228 | } | |
| 229 | 229 | assert.False(t, implicitHTTPProbe(l4Only), "an HTTP GET proves nothing about an L4 workload") | |
| 230 | - | assert.Empty(t, deploymentReadiness(l4Only).Readiness.Type) | |
| 230 | + | }) | |
| 231 | + | ||
| 232 | + | t.Run("undeclared probe on a stateless single TCP service is the implicit TCP probe", func(t *testing.T) { | |
| 233 | + | for _, readiness := range []string{"", domain.AppReadinessNone} { | |
| 234 | + | spec := domain.AppService{ | |
| 235 | + | Name: "relay", | |
| 236 | + | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "9000"}}, | |
| 237 | + | Readiness: domain.AppReadiness{Type: readiness}, | |
| 238 | + | } | |
| 239 | + | assert.True(t, implicitTCPProbe(spec)) | |
| 240 | + | assert.Equal(t, domain.AppReadinessTCP, deploymentReadiness(spec).Readiness.Type) | |
| 241 | + | } | |
| 242 | + | }) | |
| 243 | + | ||
| 244 | + | t.Run("no implicit TCP probe on stateful, multi-port, mixed, or UDP services", func(t *testing.T) { | |
| 245 | + | tcp := func() domain.AppService { | |
| 246 | + | return domain.AppService{ | |
| 247 | + | Name: "db", | |
| 248 | + | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | withVolume := tcp() | |
| 252 | + | withVolume.Volumes = []domain.AppVolume{{Name: "data", Path: "/var/lib/postgresql/data"}} | |
| 253 | + | withBind := tcp() | |
| 254 | + | withBind.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}} | |
| 255 | + | twoPorts := tcp() | |
| 256 | + | twoPorts.TCP = append(twoPorts.TCP, domain.AppTCPInterface{Entrypoint: "tcp2", Port: 5433, Publish: "5433"}) | |
| 257 | + | withUDP := tcp() | |
| 258 | + | withUDP.UDP = []domain.AppUDPInterface{{Entrypoint: "udp", Port: 5432}} | |
| 259 | + | mixed := tcp() | |
| 260 | + | mixed.HTTP = []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}} | |
| 261 | + | declaredLog := tcp() | |
| 262 | + | declaredLog.Readiness = domain.AppReadiness{Type: domain.AppReadinessLog} | |
| 263 | + | ||
| 264 | + | for name, spec := range map[string]domain.AppService{ | |
| 265 | + | "volume": withVolume, "bind": withBind, "two TCP ports": twoPorts, | |
| 266 | + | "UDP": withUDP, "HTTP interface": mixed, "declared log": declaredLog, | |
| 267 | + | } { | |
| 268 | + | assert.False(t, implicitTCPProbe(spec), name) | |
| 269 | + | assert.Equal(t, spec.Readiness.Type, deploymentReadiness(spec).Readiness.Type, name) | |
| 270 | + | } | |
| 231 | 271 | }) | |
| 232 | 272 | ||
| 233 | 273 | t.Run("declared probes are never replaced by the implicit HTTP probe", func(t *testing.T) { | |
| @@ -245,10 +285,11 @@ func TestSingleWriterRequiredAndImplicitHTTPReadiness(t *testing.T) { | |||
| 245 | 285 | assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(spec).Readiness.Type) | |
| 246 | 286 | }) | |
| 247 | 287 | ||
| 248 | - | t.Run("an explicit none on an L4 service stays unchecked", func(t *testing.T) { | |
| 288 | + | t.Run("an explicit none on a stateful L4 service stays unchecked", func(t *testing.T) { | |
| 249 | 289 | spec := domain.AppService{ | |
| 250 | 290 | Name: "db", | |
| 251 | 291 | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, | |
| 292 | + | Volumes: []domain.AppVolume{{Name: "data", Path: "/var/lib/postgresql/data"}}, | |
| 252 | 293 | Readiness: domain.AppReadiness{Type: domain.AppReadinessNone}, | |
| 253 | 294 | } | |
| 254 | 295 | assert.Equal(t, domain.AppReadinessNone, deploymentReadiness(spec).Readiness.Type) | |
| @@ -227,7 +227,47 @@ func TestSingleWriterRequiredAndImplicitHTTPReadiness(t *testing.T) { | |||||
| 227 | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, | 227 | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, | ||
| 228 | } | 228 | } | ||
| 229 | assert.False(t, implicitHTTPProbe(l4Only), "an HTTP GET proves nothing about an L4 workload") | 229 | assert.False(t, implicitHTTPProbe(l4Only), "an HTTP GET proves nothing about an L4 workload") | ||
| 230 | - | assert.Empty(t, deploymentReadiness(l4Only).Readiness.Type) | 230 | + | }) |
| 231 | + | ||||
| 232 | + | t.Run("undeclared probe on a stateless single TCP service is the implicit TCP probe", func(t *testing.T) { | |||
| 233 | + | for _, readiness := range []string{"", domain.AppReadinessNone} { | |||
| 234 | + | spec := domain.AppService{ | |||
| 235 | + | Name: "relay", | |||
| 236 | + | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "9000"}}, | |||
| 237 | + | Readiness: domain.AppReadiness{Type: readiness}, | |||
| 238 | + | } | |||
| 239 | + | assert.True(t, implicitTCPProbe(spec)) | |||
| 240 | + | assert.Equal(t, domain.AppReadinessTCP, deploymentReadiness(spec).Readiness.Type) | |||
| 241 | + | } | |||
| 242 | + | }) | |||
| 243 | + | ||||
| 244 | + | t.Run("no implicit TCP probe on stateful, multi-port, mixed, or UDP services", func(t *testing.T) { | |||
| 245 | + | tcp := func() domain.AppService { | |||
| 246 | + | return domain.AppService{ | |||
| 247 | + | Name: "db", | |||
| 248 | + | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | withVolume := tcp() | |||
| 252 | + | withVolume.Volumes = []domain.AppVolume{{Name: "data", Path: "/var/lib/postgresql/data"}} | |||
| 253 | + | withBind := tcp() | |||
| 254 | + | withBind.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}} | |||
| 255 | + | twoPorts := tcp() | |||
| 256 | + | twoPorts.TCP = append(twoPorts.TCP, domain.AppTCPInterface{Entrypoint: "tcp2", Port: 5433, Publish: "5433"}) | |||
| 257 | + | withUDP := tcp() | |||
| 258 | + | withUDP.UDP = []domain.AppUDPInterface{{Entrypoint: "udp", Port: 5432}} | |||
| 259 | + | mixed := tcp() | |||
| 260 | + | mixed.HTTP = []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}} | |||
| 261 | + | declaredLog := tcp() | |||
| 262 | + | declaredLog.Readiness = domain.AppReadiness{Type: domain.AppReadinessLog} | |||
| 263 | + | ||||
| 264 | + | for name, spec := range map[string]domain.AppService{ | |||
| 265 | + | "volume": withVolume, "bind": withBind, "two TCP ports": twoPorts, | |||
| 266 | + | "UDP": withUDP, "HTTP interface": mixed, "declared log": declaredLog, | |||
| 267 | + | } { | |||
| 268 | + | assert.False(t, implicitTCPProbe(spec), name) | |||
| 269 | + | assert.Equal(t, spec.Readiness.Type, deploymentReadiness(spec).Readiness.Type, name) | |||
| 270 | + | } | |||
| 231 | }) | 271 | }) | ||
| 232 | 272 | ||||
| 233 | t.Run("declared probes are never replaced by the implicit HTTP probe", func(t *testing.T) { | 273 | t.Run("declared probes are never replaced by the implicit HTTP probe", func(t *testing.T) { | ||
| @@ -245,10 +285,11 @@ func TestSingleWriterRequiredAndImplicitHTTPReadiness(t *testing.T) { | |||||
| 245 | assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(spec).Readiness.Type) | 285 | assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(spec).Readiness.Type) | ||
| 246 | }) | 286 | }) | ||
| 247 | 287 | ||||
| 248 | - | t.Run("an explicit none on an L4 service stays unchecked", func(t *testing.T) { | 288 | + | t.Run("an explicit none on a stateful L4 service stays unchecked", func(t *testing.T) { |
| 249 | spec := domain.AppService{ | 289 | spec := domain.AppService{ | ||
| 250 | Name: "db", | 290 | Name: "db", | ||
| 251 | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, | 291 | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, | ||
| 292 | + | Volumes: []domain.AppVolume{{Name: "data", Path: "/var/lib/postgresql/data"}}, | |||
| 252 | Readiness: domain.AppReadiness{Type: domain.AppReadinessNone}, | 293 | Readiness: domain.AppReadiness{Type: domain.AppReadinessNone}, | ||
| 253 | } | 294 | } | ||
| 254 | assert.Equal(t, domain.AppReadinessNone, deploymentReadiness(spec).Readiness.Type) | 295 | assert.Equal(t, domain.AppReadinessNone, deploymentReadiness(spec).Readiness.Type) | ||
M internal/usecase/deployment/execute.go +20 -3
| @@ -1021,14 +1021,31 @@ func (s *Service) failResult(revision, before, after string, err error) ServiceR | |||
| 1021 | 1021 | // HTTP interface, no L4 interface, no volume, and no bind. An undeclared | |
| 1022 | 1022 | // probe on a stateful or L4 workload stays unchecked instead: an HTTP GET on | |
| 1023 | 1023 | // the published port proves nothing there, so Gordon never guesses one. | |
| 1024 | + | // | |
| 1025 | + | // A stateless raw TCP service (see implicitTCPProbe) that declares no check | |
| 1026 | + | // receives the implicit TCP probe, so the overlap switch never routes to a | |
| 1027 | + | // container whose port does not accept yet. | |
| 1024 | 1028 | func deploymentReadiness(spec domain.AppService) domain.AppService { | |
| 1025 | - | if !implicitHTTPProbe(spec) { | |
| 1026 | - | return spec | |
| 1029 | + | switch { | |
| 1030 | + | case implicitHTTPProbe(spec): | |
| 1031 | + | spec.Readiness.Type = domain.AppReadinessHTTP | |
| 1032 | + | case implicitTCPProbe(spec): | |
| 1033 | + | spec.Readiness.Type = domain.AppReadinessTCP | |
| 1027 | 1034 | } | |
| 1028 | - | spec.Readiness.Type = domain.AppReadinessHTTP | |
| 1029 | 1035 | return spec | |
| 1030 | 1036 | } | |
| 1031 | 1037 | ||
| 1038 | + | // implicitTCPProbe reports whether a service that declares no readiness check | |
| 1039 | + | // receives the implicit TCP probe: exactly one TCP interface, no HTTP or UDP | |
| 1040 | + | // interface, no volume, bind, or device. Such a service is replaced with an | |
| 1041 | + | // overlap, and its single published loopback bind is the probe target. | |
| 1042 | + | func implicitTCPProbe(spec domain.AppService) bool { | |
| 1043 | + | if spec.Readiness.Type != "" && spec.Readiness.Type != domain.AppReadinessNone { | |
| 1044 | + | return false | |
| 1045 | + | } | |
| 1046 | + | return len(spec.TCP) == 1 && len(spec.HTTP) == 0 && overlapEligible(spec) | |
| 1047 | + | } | |
| 1048 | + | ||
| 1032 | 1049 | // implicitHTTPProbe reports whether a service that declares no readiness | |
| 1033 | 1050 | // check receives the implicit HTTP probe when a deploy or restart starts it. | |
| 1034 | 1051 | // Recovery verification of an already running generation keeps the declared | |
| @@ -1021,14 +1021,31 @@ func (s *Service) failResult(revision, before, after string, err error) ServiceR | |||||
| 1021 | // HTTP interface, no L4 interface, no volume, and no bind. An undeclared | 1021 | // HTTP interface, no L4 interface, no volume, and no bind. An undeclared | ||
| 1022 | // probe on a stateful or L4 workload stays unchecked instead: an HTTP GET on | 1022 | // probe on a stateful or L4 workload stays unchecked instead: an HTTP GET on | ||
| 1023 | // the published port proves nothing there, so Gordon never guesses one. | 1023 | // the published port proves nothing there, so Gordon never guesses one. | ||
| 1024 | + | // | |||
| 1025 | + | // A stateless raw TCP service (see implicitTCPProbe) that declares no check | |||
| 1026 | + | // receives the implicit TCP probe, so the overlap switch never routes to a | |||
| 1027 | + | // container whose port does not accept yet. | |||
| 1024 | func deploymentReadiness(spec domain.AppService) domain.AppService { | 1028 | func deploymentReadiness(spec domain.AppService) domain.AppService { | ||
| 1025 | - | if !implicitHTTPProbe(spec) { | 1029 | + | switch { |
| 1026 | - | return spec | 1030 | + | case implicitHTTPProbe(spec): |
| 1031 | + | spec.Readiness.Type = domain.AppReadinessHTTP | |||
| 1032 | + | case implicitTCPProbe(spec): | |||
| 1033 | + | spec.Readiness.Type = domain.AppReadinessTCP | |||
| 1027 | } | 1034 | } | ||
| 1028 | - | spec.Readiness.Type = domain.AppReadinessHTTP | |||
| 1029 | return spec | 1035 | return spec | ||
| 1030 | } | 1036 | } | ||
| 1031 | 1037 | ||||
| 1038 | + | // implicitTCPProbe reports whether a service that declares no readiness check | |||
| 1039 | + | // receives the implicit TCP probe: exactly one TCP interface, no HTTP or UDP | |||
| 1040 | + | // interface, no volume, bind, or device. Such a service is replaced with an | |||
| 1041 | + | // overlap, and its single published loopback bind is the probe target. | |||
| 1042 | + | func implicitTCPProbe(spec domain.AppService) bool { | |||
| 1043 | + | if spec.Readiness.Type != "" && spec.Readiness.Type != domain.AppReadinessNone { | |||
| 1044 | + | return false | |||
| 1045 | + | } | |||
| 1046 | + | return len(spec.TCP) == 1 && len(spec.HTTP) == 0 && overlapEligible(spec) | |||
| 1047 | + | } | |||
| 1048 | + | ||||
| 1032 | // implicitHTTPProbe reports whether a service that declares no readiness | 1049 | // implicitHTTPProbe reports whether a service that declares no readiness | ||
| 1033 | // check receives the implicit HTTP probe when a deploy or restart starts it. | 1050 | // check receives the implicit HTTP probe when a deploy or restart starts it. | ||
| 1034 | // Recovery verification of an already running generation keeps the declared | 1051 | // Recovery verification of an already running generation keeps the declared | ||