1package deployment
2
3import (
4 "context"
5 "net"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/gordon/internal/domain"
13)
14
15// serveTCP accepts connections on loopback and hands each to handle.
16func serveTCP(t *testing.T, handle func(net.Conn)) string {
17 t.Helper()
18 ln, err := net.Listen("tcp", "127.0.0.1:0")
19 require.NoError(t, err)
20 t.Cleanup(func() { _ = ln.Close() })
21 go func() {
22 for {
23 conn, err := ln.Accept()
24 if err != nil {
25 return
26 }
27 go handle(conn)
28 }
29 }()
30 return ln.Addr().String()
31}
32
33// testSettle leaves a wide margin for the server goroutine to close first.
34const testSettle = 500 * time.Millisecond
35
36func TestTCPProbeOnce_RelayAcceptThenCloseIsNotReady(t *testing.T) {
37 // A host port relay with no listener behind it accepts and closes at once.
38 addr := serveTCP(t, func(c net.Conn) { _ = c.Close() })
39
40 err := tcpProbeOnce(context.Background(), addr, testSettle)
41
42 require.ErrorIs(t, err, errTCPProbeClosed)
43}
44
45func TestTCPProbeOnce_RelayResetIsNotReady(t *testing.T) {
46 addr := serveTCP(t, func(c net.Conn) {
47 if tc, ok := c.(*net.TCPConn); ok {
48 _ = tc.SetLinger(0) // close with RST
49 }
50 _ = c.Close()
51 })
52
53 err := tcpProbeOnce(context.Background(), addr, testSettle)
54
55 require.Error(t, err)
56}
57
58func TestTCPProbeOnce_SilentListenerIsReady(t *testing.T) {
59 // A workload that never speaks first (Postgres, Redis) keeps the connection open.
60 release := make(chan struct{})
61 t.Cleanup(func() { close(release) })
62 addr := serveTCP(t, func(c net.Conn) {
63 <-release
64 _ = c.Close()
65 })
66
67 start := time.Now()
68 err := tcpProbeOnce(context.Background(), addr, testSettle)
69
70 require.NoError(t, err)
71 assert.GreaterOrEqual(t, time.Since(start), testSettle)
72}
73
74func TestTCPProbeOnce_BannerIsReadyImmediately(t *testing.T) {
75 // A workload that speaks first (SSH, SMTP) is ready on its first byte.
76 addr := serveTCP(t, func(c net.Conn) {
77 _, _ = c.Write([]byte("SSH-2.0-test\r\n"))
78 _ = c.Close()
79 })
80
81 start := time.Now()
82 err := tcpProbeOnce(context.Background(), addr, 5*time.Second)
83
84 require.NoError(t, err)
85 assert.Less(t, time.Since(start), 2*time.Second)
86}
87
88func TestTCPProbeOnce_NoListenerFails(t *testing.T) {
89 ln, err := net.Listen("tcp", "127.0.0.1:0")
90 require.NoError(t, err)
91 addr := ln.Addr().String()
92 require.NoError(t, ln.Close())
93
94 require.Error(t, tcpProbeOnce(context.Background(), addr, testSettle))
95}
96
97func TestTCPProbeOnce_CanceledContextWinsOverData(t *testing.T) {
98 addr := serveTCP(t, func(c net.Conn) {
99 _, _ = c.Write([]byte("banner"))
100 _ = c.Close()
101 })
102 ctx, cancel := context.WithCancel(context.Background())
103 cancel()
104
105 require.Error(t, tcpProbeOnce(ctx, addr, testSettle))
106}
107
108func TestWaitTCPReady_SettleAfterBudgetIsNotReady(t *testing.T) {
109 // Each attempt settles only after the readiness budget expired: the wait
110 // must report a timeout instead of accepting the late success.
111 slowDial := func(ctx context.Context, _ string) error {
112 select {
113 case <-ctx.Done():
114 case <-time.After(400 * time.Millisecond):
115 }
116 return nil
117 }
118 deps := NewTestProbeDeps(nil, nil, slowDial)
119 spec := domain.AppService{
120 Name: "relay",
121 TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000}},
122 Readiness: domain.AppReadiness{Type: domain.AppReadinessTCP, Timeout: 200 * time.Millisecond},
123 }
124
125 start := time.Now()
126 err := waitTCPReadyWithDeps(context.Background(), deps, spec, map[int]int{9000: 40000})
127
128 require.ErrorContains(t, err, "TCP readiness timeout")
129 assert.Less(t, time.Since(start), 5*time.Second)
130}
131
132func TestWaitTCPReady_CallerCancelIsReturned(t *testing.T) {
133 deps := NewTestProbeDeps(nil, nil, func(context.Context, string) error { return errTCPProbeClosed })
134 spec := domain.AppService{
135 Name: "relay",
136 TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000}},
137 Readiness: domain.AppReadiness{Type: domain.AppReadinessTCP, Timeout: 10 * time.Second},
138 }
139 ctx, cancel := context.WithCancel(context.Background())
140 cancel()
141
142 err := waitTCPReadyWithDeps(ctx, deps, spec, map[int]int{9000: 40000})
143
144 require.ErrorIs(t, err, context.Canceled)
145}
146
147func TestTCPProbeOnce_ContextCancelStopsTheSettleWait(t *testing.T) {
148 release := make(chan struct{})
149 t.Cleanup(func() { close(release) })
150 addr := serveTCP(t, func(c net.Conn) {
151 <-release
152 _ = c.Close()
153 })
154 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
155 defer cancel()
156
157 start := time.Now()
158 err := tcpProbeOnce(ctx, addr, 10*time.Second)
159
160 require.ErrorIs(t, err, context.DeadlineExceeded)
161 assert.Less(t, time.Since(start), 5*time.Second)
162}