Skip to content

Commit 69a6b8e

Browse files
Merge pull request #109 from open-source-cloud/fix/cloud-stack-health-readiness
fix(cloud): shared-localstack health gate + db-create connect race
2 parents 2d9cec1 + 7984745 commit 69a6b8e

7 files changed

Lines changed: 284 additions & 4 deletions

File tree

internal/generate/cloud_engines_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,31 @@ func TestCloudEngineTemplatesLint(t *testing.T) {
5050
}
5151
}
5252

53+
// TestLocalStackHealthGatesOnAvailable guards the fix for the "shared-localstack
54+
// unhealthy after 1 attempt" bug. LocalStack 3.x reports each configured SERVICE
55+
// as "available" on startup — a service only flips to "running" after its first
56+
// request. A healthcheck that greps solely for "running" therefore NEVER passes
57+
// (nothing is running until traffic arrives), the container stays unhealthy, and
58+
// the up saga aborts. The gate must accept "available".
59+
func TestLocalStackHealthGatesOnAvailable(t *testing.T) {
60+
src := template.NewFSSource(templates.FS)
61+
res, err := template.Resolve(src, "localstack", nil)
62+
if err != nil {
63+
t.Fatal(err)
64+
}
65+
compose, err := LintResolved("localstack", res)
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
s := string(compose)
70+
if !strings.Contains(s, "available") {
71+
t.Errorf("localstack healthcheck must accept the \"available\" state, not gate solely on \"running\":\n%s", s)
72+
}
73+
if strings.Contains(s, "grep -q running") {
74+
t.Error("localstack healthcheck still greps solely for \"running\" — the deadlock bug")
75+
}
76+
}
77+
5378
// TestRabbitMQSecretIsValueless asserts RABBITMQ_DEFAULT_PASS is emitted as a
5479
// valueless env key (no plaintext) — the §7.5 secret coupling for broker creds.
5580
func TestRabbitMQSecretIsValueless(t *testing.T) {
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package orchestrate
2+
3+
import (
4+
"context"
5+
"strings"
6+
"time"
7+
8+
"github.com/open-source-cloud/devstack/internal/provision"
9+
)
10+
11+
// This file hardens the host-side Postgres admin connection against the
12+
// readiness race that surfaced as `db create` failing with "connect to shared
13+
// postgres on 127.0.0.1:<port>: read: connection reset by peer".
14+
//
15+
// Why the race exists: the imperative resource path (and the up provision phase)
16+
// publishes the shared engine's host port via an up-time compose overlay, then
17+
// `docker compose up -d <inst>` applies it. Adding a published port RECREATES the
18+
// container, so Postgres restarts; for a second or two afterwards Docker's
19+
// userland proxy accepts the TCP connection on 127.0.0.1:<port> but the backend
20+
// isn't listening yet, so it RSTs the handshake ("connection reset by peer",
21+
// "failed to receive message", EOF). A single immediate connect loses the race.
22+
//
23+
// The fix mirrors how any client should treat a just-(re)started server: retry
24+
// the connect with backoff for a bounded window. Idempotent and safe — a healthy
25+
// server connects on the first try, so this only ever adds latency on the race.
26+
27+
const (
28+
// connectRetryBudget bounds how long we retry a transient connect before
29+
// giving up and surfacing the real error (Postgres genuinely down / wrong
30+
// creds fail fast because those errors are not transient).
31+
connectRetryBudget = 30 * time.Second
32+
// connectRetryStart is the initial backoff; it doubles up to connectRetryMax.
33+
connectRetryStart = 200 * time.Millisecond
34+
connectRetryMax = 2 * time.Second
35+
)
36+
37+
// transientConnErr reports whether a Postgres connect error is the engine still
38+
// coming up after a port-overlay recreate (retry) rather than a permanent
39+
// failure like bad credentials or an unknown database (fail fast).
40+
func transientConnErr(err error) bool {
41+
if err == nil {
42+
return false
43+
}
44+
s := strings.ToLower(err.Error())
45+
for _, m := range []string{
46+
"connection reset by peer",
47+
"connection refused",
48+
"failed to receive message",
49+
"the database system is starting up",
50+
"broken pipe",
51+
"unexpected eof",
52+
"eof",
53+
"i/o timeout",
54+
"no route to host",
55+
"server closed the connection unexpectedly",
56+
} {
57+
if strings.Contains(s, m) {
58+
return true
59+
}
60+
}
61+
return false
62+
}
63+
64+
// sleepFn is indirected so tests can drive the backoff without real time.
65+
var sleepFn = func(ctx context.Context, d time.Duration) error {
66+
t := time.NewTimer(d)
67+
defer t.Stop()
68+
select {
69+
case <-ctx.Done():
70+
return ctx.Err()
71+
case <-t.C:
72+
return nil
73+
}
74+
}
75+
76+
// nowFn is indirected for tests.
77+
var nowFn = time.Now
78+
79+
// retryingPgConnect wraps a PgConnector so a transient connect error (the engine
80+
// just restarted to bind its host port) is retried with capped backoff for
81+
// connectRetryBudget. A nil connector passes through nil (the default connector
82+
// is substituted downstream). Non-transient errors and a cancelled context
83+
// return immediately.
84+
func retryingPgConnect(connect PgConnector) PgConnector {
85+
if connect == nil {
86+
return nil
87+
}
88+
return func(ctx context.Context, dsn string) (provision.Conn, func() error, error) {
89+
deadline := nowFn().Add(connectRetryBudget)
90+
backoff := connectRetryStart
91+
for {
92+
conn, closeFn, err := connect(ctx, dsn)
93+
if err == nil {
94+
return conn, closeFn, nil
95+
}
96+
if !transientConnErr(err) || !nowFn().Before(deadline) || ctx.Err() != nil {
97+
return nil, nil, err
98+
}
99+
if serr := sleepFn(ctx, backoff); serr != nil {
100+
return nil, nil, err
101+
}
102+
if backoff < connectRetryMax {
103+
backoff *= 2
104+
}
105+
}
106+
}
107+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package orchestrate
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
"github.com/open-source-cloud/devstack/internal/provision"
10+
)
11+
12+
func TestTransientConnErr(t *testing.T) {
13+
transient := []string{
14+
"connect to shared postgres: read tcp 127.0.0.1:46820->127.0.0.1:45432: read: connection reset by peer",
15+
"failed to receive message: EOF",
16+
"dial tcp 127.0.0.1:45432: connect: connection refused",
17+
"the database system is starting up",
18+
}
19+
for _, m := range transient {
20+
if !transientConnErr(errors.New(m)) {
21+
t.Errorf("expected transient: %q", m)
22+
}
23+
}
24+
permanent := []string{
25+
"password authentication failed for user \"devstack\"",
26+
"database \"nope\" does not exist",
27+
"",
28+
}
29+
for _, m := range permanent {
30+
if transientConnErr(errors.New(m)) {
31+
t.Errorf("expected permanent: %q", m)
32+
}
33+
}
34+
if transientConnErr(nil) {
35+
t.Error("nil is not transient")
36+
}
37+
}
38+
39+
// fakeConn is a throwaway provision.Conn for connector return values.
40+
type fakeConn struct{}
41+
42+
func (fakeConn) Exec(context.Context, string, ...any) error { return nil }
43+
func (fakeConn) Exists(context.Context, string, ...any) (bool, error) { return false, nil }
44+
45+
func withNoSleep(t *testing.T) {
46+
t.Helper()
47+
orig := sleepFn
48+
sleepFn = func(ctx context.Context, _ time.Duration) error {
49+
if ctx.Err() != nil {
50+
return ctx.Err()
51+
}
52+
return nil
53+
}
54+
t.Cleanup(func() { sleepFn = orig })
55+
}
56+
57+
func TestRetryingPgConnect_EventuallySucceeds(t *testing.T) {
58+
withNoSleep(t)
59+
calls := 0
60+
base := PgConnector(func(context.Context, string) (provision.Conn, func() error, error) {
61+
calls++
62+
if calls < 3 {
63+
return nil, nil, errors.New("read: connection reset by peer")
64+
}
65+
return fakeConn{}, func() error { return nil }, nil
66+
})
67+
conn, closeFn, err := retryingPgConnect(base)(context.Background(), "dsn")
68+
if err != nil {
69+
t.Fatalf("want success after retries, got %v", err)
70+
}
71+
if conn == nil || closeFn == nil {
72+
t.Fatal("want a live conn + close on success")
73+
}
74+
if calls != 3 {
75+
t.Errorf("calls = %d, want 3 (two transient failures then success)", calls)
76+
}
77+
}
78+
79+
func TestRetryingPgConnect_FailsFastOnPermanent(t *testing.T) {
80+
withNoSleep(t)
81+
calls := 0
82+
base := PgConnector(func(context.Context, string) (provision.Conn, func() error, error) {
83+
calls++
84+
return nil, nil, errors.New("password authentication failed")
85+
})
86+
_, _, err := retryingPgConnect(base)(context.Background(), "dsn")
87+
if err == nil {
88+
t.Fatal("want the permanent error surfaced")
89+
}
90+
if calls != 1 {
91+
t.Errorf("calls = %d, want 1 (no retry on a permanent error)", calls)
92+
}
93+
}
94+
95+
func TestRetryingPgConnect_BudgetBounded(t *testing.T) {
96+
withNoSleep(t)
97+
// Drive a synthetic clock so the 30s budget elapses without real waiting.
98+
origNow := nowFn
99+
tick := time.Unix(0, 0)
100+
nowFn = func() time.Time { tick = tick.Add(5 * time.Second); return tick }
101+
t.Cleanup(func() { nowFn = origNow })
102+
103+
calls := 0
104+
base := PgConnector(func(context.Context, string) (provision.Conn, func() error, error) {
105+
calls++
106+
return nil, nil, errors.New("connection refused")
107+
})
108+
_, _, err := retryingPgConnect(base)(context.Background(), "dsn")
109+
if err == nil {
110+
t.Fatal("want the transient error surfaced after the budget elapses")
111+
}
112+
if calls < 2 {
113+
t.Errorf("calls = %d, want ≥2 (retried before giving up)", calls)
114+
}
115+
}
116+
117+
func TestRetryingPgConnect_HonorsContextCancel(t *testing.T) {
118+
withNoSleep(t)
119+
ctx, cancel := context.WithCancel(context.Background())
120+
cancel()
121+
calls := 0
122+
base := PgConnector(func(context.Context, string) (provision.Conn, func() error, error) {
123+
calls++
124+
return nil, nil, errors.New("connection reset by peer")
125+
})
126+
_, _, err := retryingPgConnect(base)(ctx, "dsn")
127+
if err == nil {
128+
t.Fatal("want an error when ctx is already cancelled")
129+
}
130+
if calls != 1 {
131+
t.Errorf("calls = %d, want 1 (a cancelled ctx stops the retry loop)", calls)
132+
}
133+
}
134+
135+
func TestRetryingPgConnect_NilPassthrough(t *testing.T) {
136+
if retryingPgConnect(nil) != nil {
137+
t.Error("retryingPgConnect(nil) must be nil so the default connector is used downstream")
138+
}
139+
}

internal/orchestrate/provision.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ func provisionPhase(d UpDeps, targets []provTarget) Phase {
147147
if connect == nil {
148148
connect = defaultPgConnect
149149
}
150+
// Retry a transient connect: the host-port overlay may have just
151+
// recreated the engine, so the proxy RSTs the handshake until Postgres
152+
// relistens (the "connection reset by peer" race).
153+
connect = retryingPgConnect(connect)
150154
byInst := map[string][]string{}
151155
for _, t := range targets {
152156
byInst[t.instance] = append(byInst[t.instance], t.project)

internal/orchestrate/resources.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ func toResourceConnector(connect PgConnector) resource.PgConnector {
135135
// daemon/endpoint-free in tests. Postgres + MinIO are live in this milestone.
136136
func buildRegistry(d UpDeps) *resource.Registry {
137137
return resource.NewRegistry(
138-
resource.Postgres{Connect: toResourceConnector(d.PgConnect)},
138+
resource.Postgres{Connect: toResourceConnector(retryingPgConnect(d.PgConnect))},
139139
resource.MinIO{Factory: d.S3Factory},
140140
resource.NATS{Factory: d.NatsFactory},
141141
resource.Kafka{Factory: d.KafkaFactory},

templates/localstack/golden.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ services:
99
healthcheck:
1010
test:
1111
- CMD-SHELL
12-
- curl -sf http://localhost:4566/_localstack/health | grep -q running
12+
- curl -sf http://localhost:4566/_localstack/health | grep -Eq 'available|running'
1313
timeout: 5s
1414
interval: 10s
1515
retries: 8

templates/localstack/template.yaml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@ service:
2929
volumes:
3030
- "localstackdata:/var/lib/localstack"
3131
healthcheck:
32-
# /_localstack/health reports per-service readiness; gate on the edge being up.
33-
test: ["CMD-SHELL", "curl -sf http://localhost:4566/_localstack/health | grep -q running"]
32+
# /_localstack/health reports per-service readiness. On a fresh start the
33+
# configured SERVICES are "available" (a service only flips to "running" after
34+
# its first request), so gate on ANY service being available|running — the
35+
# honest "edge up + providers loaded" signal. Gating on "running" alone
36+
# deadlocks: nothing is "running" until traffic arrives, so the container
37+
# never goes healthy and the up saga aborts (verified on localstack 3.8.1).
38+
test: ["CMD-SHELL", "curl -sf http://localhost:4566/_localstack/health | grep -Eq 'available|running'"]
3439
interval: 10s
3540
timeout: 5s
3641
retries: 8

0 commit comments

Comments
 (0)