detached a2f0e01

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
168168
169169 `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.
170170
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+
171173 ## TLS
172174
173175 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 ## TLS173 ## 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) {
227227 TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}},
228228 }
229229 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+ }
231271 })
232272
233273 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) {
245285 assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(spec).Readiness.Type)
246286 })
247287
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) {
249289 spec := domain.AppService{
250290 Name: "db",
251291 TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}},
292+ Volumes: []domain.AppVolume{{Name: "data", Path: "/var/lib/postgresql/data"}},
252293 Readiness: domain.AppReadiness{Type: domain.AppReadinessNone},
253294 }
254295 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
10211021 // HTTP interface, no L4 interface, no volume, and no bind. An undeclared
10221022 // probe on a stateful or L4 workload stays unchecked instead: an HTTP GET on
10231023 // 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.
10241028 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
10271034 }
1028- spec.Readiness.Type = domain.AppReadinessHTTP
10291035 return spec
10301036 }
10311037
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+
10321049 // implicitHTTPProbe reports whether a service that declares no readiness
10331050 // check receives the implicit HTTP probe when a deploy or restart starts it.
10341051 // 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 undeclared1021 // 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 on1022 // 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 spec1030+ 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 spec1035 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 readiness1049 // 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 declared1051 // Recovery verification of an already running generation keeps the declared

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close