Skip to content

Commit 2a6926c

Browse files
h4x3rotabclaude
andcommitted
fix(consul-postgres-ha): live-test fallout — sidecar registration + self-VIP dispatch
Three issues surfaced from the first end-to-end run on a real 6-CVM dstack cluster: 1. **sidecar registration JSON format**: the brief's plan to use `consul services register` failed because that CLI takes HCL-style files with lowercase keys wrapped in `{"service": ...}`, not the flat capital-letter struct the agent HTTP API takes. Switched to `curl PUT /v1/agent/service/register` for consistency with how webdemo registers itself. 2. **service-resolver service-name mismatch**: Patroni auto-registers the parent service under `<scope>` (= `CLUSTER_NAME`), not under the literal string `postgres`. Service-resolver entries + intentions + the postgres-sidecar's `DestinationServiceName` now parameterize on `${CLUSTER_NAME}` so consumer-facing names (`postgres-master`/`postgres-replica`) redirect to the actual Patroni-tagged scope. 3. **self-VIP dispatch**: Consul's "server health" probes self-dial `127.50.0.<self-vip>:8300`, and mesh-conn dispatched inbound to `127.0.0.1:port` while Consul (per the brief) also listened on `127.0.0.1`. Self-dial then failed with connection-refused. Fixed by (a) `-bind=127.50.0.${SELF_VIP}` so Consul listens on the self-VIP (which is local-aliased on lo) and (b) mesh-conn's accept-loop dispatches to `self.vipAddr():port` on the receive side. Both directions now land on the same listener. Also adds `MESH_CONN_RELAY_ONLY` as a Terraform variable + plumbs it through to both coord and worker env so the documented ICE-flakiness workaround can be flipped without a code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1b7aa91 commit 2a6926c

3 files changed

Lines changed: 81 additions & 46 deletions

File tree

consul-postgres-ha/cluster-example/cluster.tf

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ variable "external_turn_secret" {
101101
sensitive = true
102102
}
103103

104+
# Force ICE to gather Relay candidates only — routes all peer traffic
105+
# through coturn instead of attempting NAT-hairpin direct paths. Set
106+
# this when worker↔worker direct-pair ICE handshakes are unstable
107+
# (the dstack provider NAT path is known-flaky for these pairs).
108+
variable "mesh_conn_relay_only" {
109+
type = string
110+
default = ""
111+
}
112+
104113
# ---------- Cluster topology + VIP allocation ----------
105114

106115
locals {
@@ -177,7 +186,8 @@ resource "phala_app" "coordinator" {
177186
TURN_SHARED_SECRET = var.external_turn_secret
178187
MESH_SIDECAR_IMAGE = var.mesh_sidecar_image
179188
# Stage-1 WORKAROUND — see `random_bytes` block at top of file.
180-
GOSSIP_KEY = random_bytes.gossip_key.base64
189+
GOSSIP_KEY = random_bytes.gossip_key.base64
190+
MESH_CONN_RELAY_ONLY = var.mesh_conn_relay_only
181191
}
182192

183193
listed = false
@@ -222,6 +232,7 @@ resource "phala_app" "worker" {
222232
GOSSIP_KEY = random_bytes.gossip_key.base64
223233
PATRONI_SUPERUSER_PW = random_bytes.patroni_superuser_pw.hex
224234
PATRONI_REPLICATION_PW = random_bytes.patroni_replication_pw.hex
235+
MESH_CONN_RELAY_ONLY = var.mesh_conn_relay_only
225236
}
226237

227238
listed = false
@@ -237,7 +248,8 @@ resource "phala_app" "worker" {
237248
output "coordinator_app_ids" { value = { for k, c in phala_app.coordinator : k => c.app_id } }
238249
output "worker_app_ids" { value = { for k, w in phala_app.worker : k => w.app_id } }
239250
output "consul_ui" {
240-
# Coordinator-0's Consul HTTP API on the canonical 8500. The dstack
241-
# gateway maps `<app_id>-<port>s.<gateway>` to that port on the CVM.
242-
value = "https://${phala_app.coordinator["0"].app_id}-8500s.${var.gateway_domain}/ui"
251+
# Coordinator-0's Consul HTTP API on the canonical 8500. Plain HTTP
252+
# backend → use the no-`s` gateway form (gateway terminates TLS).
253+
# See README "dstack gateway URL convention".
254+
value = "https://${phala_app.coordinator["0"].app_id}-8500.${var.gateway_domain}/ui"
243255
}

consul-postgres-ha/mesh-conn/main.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ func dialAndPump(cfg *Config, self, peer Peer) error {
497497

498498
udpPortCount := len(udpSocks)
499499
go func() {
500-
errCh <- runAcceptLoop(connCtx, qconn, udpStreams, udpPortCount, allUDPReady)
500+
errCh <- runAcceptLoop(connCtx, qconn, &self, udpStreams, udpPortCount, allUDPReady)
501501
}()
502502

503503
if isClient {
@@ -539,7 +539,14 @@ func dialAndPump(cfg *Config, self, peer Peer) error {
539539
st := udpStreams[port]
540540
go func() { errCh <- pumpUDPSockToStream(us, st) }()
541541
go func() {
542-
udpDst := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}
542+
// Dispatch to the SELF VIP, not 127.0.0.1, so the
543+
// inbound packet lands on whatever (Consul, Envoy, ...)
544+
// is listening on this peer's own loopback alias. This
545+
// also keeps the source-port-preservation invariant
546+
// intact: the local listener's bound addr is the
547+
// remote peer's VIP, so the receiving service sees
548+
// "from peer-N" as the source.
549+
udpDst := &net.UDPAddr{IP: self.vipAddr(), Port: port}
543550
errCh <- pumpUDPStreamToSock(st, us, udpDst)
544551
}()
545552
}
@@ -554,10 +561,11 @@ func dialAndPump(cfg *Config, self, peer Peer) error {
554561

555562
// runAcceptLoop handles every incoming QUIC stream from the peer.
556563
// streamUDP headers are matched into udpStreams keyed by port; streamTCP
557-
// triggers a Dial to 127.0.0.1:<port>. The receiver validates port
558-
// against the static allowlist; anything else is rejected — that's the
559-
// only port-knowledge the receiver needs.
560-
func runAcceptLoop(ctx context.Context, qconn *quic.Conn, udpStreams map[int]*quic.Stream, expectedUDP int, allUDPReady chan struct{}) error {
564+
// triggers a Dial to <self-vip>:<port> (so it lands on whatever the
565+
// local Consul / Envoy bound on the peer's own loopback alias). The
566+
// receiver validates port against the static allowlist; anything
567+
// else is rejected — that's the only port-knowledge the receiver needs.
568+
func runAcceptLoop(ctx context.Context, qconn *quic.Conn, self *Peer, udpStreams map[int]*quic.Stream, expectedUDP int, allUDPReady chan struct{}) error {
561569
udpRegistered := 0
562570
for {
563571
s, err := qconn.AcceptStream(ctx)
@@ -592,7 +600,7 @@ func runAcceptLoop(ctx context.Context, qconn *quic.Conn, udpStreams map[int]*qu
592600
close(allUDPReady)
593601
}
594602
case streamTCP:
595-
go handleIncomingTCP(s, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
603+
go handleIncomingTCP(s, &net.TCPAddr{IP: self.vipAddr(), Port: port})
596604
default:
597605
log.Printf("unknown stream tag 0x%x", tag)
598606
s.CancelRead(0)

consul-postgres-ha/mesh-sidecar/entrypoint.sh

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,17 @@ CONSUL_ARGS=(
9494
agent
9595
-node="$PEER_ID"
9696
-datacenter="${CLUSTER_NAME}"
97-
-bind=127.0.0.1
97+
# bind on the self-VIP so serf + RPC listen there. Consumers (other
98+
# peers AND self) reach this Consul at 127.50.0.<self-vip>:8301/8300:
99+
# - other-peer dials → that peer's mesh-conn forwards via QUIC →
100+
# this peer's mesh-conn dispatches to 127.50.0.<self-vip>:port →
101+
# Consul receives.
102+
# - self-dial (e.g. internal "server health" probe) → kernel
103+
# loopback → Consul receives directly. No mesh-conn hop.
104+
-bind="127.50.0.${SELF_VIP}"
98105
-advertise="127.50.0.${SELF_VIP}"
106+
# HTTP API + gRPC stay loopback-only — apps and Patroni use them
107+
# from inside the same network namespace, never via the mesh.
99108
-client=127.0.0.1
100109
-serf-lan-port=8301
101110
-server-port=8300
@@ -154,39 +163,44 @@ if [ "$ROLE" = "worker" ]; then
154163
# filtered by the role tag Patroni stamps on each registration.
155164
POSTGRES_MASTER_VIP=$(jq -r '.[] | select(.name=="postgres-master") | .vip' <<<"$UPSTREAMS_JSON")
156165
POSTGRES_REPLICA_VIP=$(jq -r '.[] | select(.name=="postgres-replica") | .vip' <<<"$UPSTREAMS_JSON")
166+
# Patroni's auto-registered parent service uses CLUSTER_NAME as the
167+
# service name (Patroni-internal "scope"). The Connect sidecar
168+
# terminates inbound mTLS for that same name; consumers reach it via
169+
# the postgres-master/-replica service-resolver redirects below.
157170
cat > /tmp/postgres-sidecar.json <<EOF
158171
{
159-
"kind": "connect-proxy",
160-
"id": "postgres-sidecar-${PEER_ID}",
161-
"name": "postgres-sidecar-proxy",
162-
"address": "127.50.0.${SELF_VIP}",
163-
"port": 21001,
164-
"proxy": {
165-
"destination_service_name": "postgres",
166-
"destination_service_id": "postgres-${PEER_ID}",
167-
"local_service_address": "127.0.0.1",
168-
"local_service_port": 5432,
169-
"upstreams": [
172+
"Kind": "connect-proxy",
173+
"ID": "postgres-sidecar-${PEER_ID}",
174+
"Name": "postgres-sidecar-proxy",
175+
"Address": "127.50.0.${SELF_VIP}",
176+
"Port": 21001,
177+
"Proxy": {
178+
"DestinationServiceName": "${CLUSTER_NAME}",
179+
"LocalServiceAddress": "127.0.0.1",
180+
"LocalServicePort": 5432,
181+
"Upstreams": [
170182
{
171-
"destination_name": "postgres-master",
172-
"local_bind_address": "127.10.0.${POSTGRES_MASTER_VIP}",
173-
"local_bind_port": 5432
183+
"DestinationName": "postgres-master",
184+
"LocalBindAddress": "127.10.0.${POSTGRES_MASTER_VIP}",
185+
"LocalBindPort": 5432
174186
},
175187
{
176-
"destination_name": "postgres-replica",
177-
"local_bind_address": "127.10.0.${POSTGRES_REPLICA_VIP}",
178-
"local_bind_port": 5432
188+
"DestinationName": "postgres-replica",
189+
"LocalBindAddress": "127.10.0.${POSTGRES_REPLICA_VIP}",
190+
"LocalBindPort": 5432
179191
}
180192
]
181193
}
182194
}
183195
EOF
184-
# Re-register on every boot; Consul's API is PUT-style so this is
185-
# idempotent. Patroni's auto-registration of the regular `postgres`
186-
# service may not have landed yet — that's fine, the sidecar can
187-
# register before its destination service exists.
196+
# Re-register on every boot via the agent HTTP API (PUT-idempotent).
197+
# The `consul services register` CLI uses a different file format
198+
# (HCL-style with a top-level `service` block); the HTTP API takes
199+
# the flat capital-letter JSON above and is consistent with how
200+
# webdemo registers itself.
188201
log "registering postgres-sidecar-proxy (port=21001)"
189-
until consul services register /tmp/postgres-sidecar.json; do
202+
until curl -fsS -X PUT --data-binary @/tmp/postgres-sidecar.json \
203+
http://127.0.0.1:8500/v1/agent/service/register; do
190204
log "postgres-sidecar-proxy register failed; retrying"
191205
sleep 2
192206
done
@@ -195,7 +209,7 @@ EOF
195209
# need Envoy to form quorum (Raft RPC rides peer VIPs as plain TCP),
196210
# but workers can't start their sidecars until a server can issue.
197211
log "waiting for connect CA to be ready..."
198-
until consul connect ca leaf -service=postgres >/dev/null 2>&1; do
212+
until consul connect ca leaf -service="${CLUSTER_NAME}" >/dev/null 2>&1; do
199213
sleep 2
200214
done
201215
log "connect CA ready"
@@ -249,36 +263,37 @@ Config { protocol = "tcp" }
249263
HCL
250264

251265
# Postgres subset definitions. Patroni auto-registers the parent
252-
# `postgres` service with role tags (master|replica); subsets pick
253-
# the right instances by tag, and the redirect resolvers expose
254-
# consumer-facing names that are independent of Patroni's tagging.
255-
consul config write - <<'HCL' || true
266+
# service under its `scope` (= CLUSTER_NAME) with role tags
267+
# (master|replica); subsets pick the right instances by tag, and
268+
# the redirect resolvers expose consumer-facing names that are
269+
# independent of Patroni's scope.
270+
consul config write - <<HCL || true
256271
Kind = "service-resolver"
257-
Name = "postgres"
272+
Name = "${CLUSTER_NAME}"
258273
Subsets = {
259274
master = { Filter = "Service.Tags contains \"master\"" }
260275
replica = { Filter = "Service.Tags contains \"replica\"" }
261276
}
262277
HCL
263278

264-
consul config write - <<'HCL' || true
279+
consul config write - <<HCL || true
265280
Kind = "service-resolver"
266281
Name = "postgres-master"
267-
Redirect { Service = "postgres", ServiceSubset = "master" }
282+
Redirect { Service = "${CLUSTER_NAME}", ServiceSubset = "master" }
268283
HCL
269284

270-
consul config write - <<'HCL' || true
285+
consul config write - <<HCL || true
271286
Kind = "service-resolver"
272287
Name = "postgres-replica"
273-
Redirect { Service = "postgres", ServiceSubset = "replica" }
288+
Redirect { Service = "${CLUSTER_NAME}", ServiceSubset = "replica" }
274289
HCL
275290

276291
# Default-allow intentions for now. Tightening this to per-pair
277292
# allow + default-deny is straightforward but out of scope here;
278293
# see design/attestation-admission.md for the longer-term shape.
279-
consul config write - <<'HCL' || true
294+
consul config write - <<HCL || true
280295
Kind = "service-intentions"
281-
Name = "postgres"
296+
Name = "${CLUSTER_NAME}"
282297
Sources = [{ Name = "*", Action = "allow" }]
283298
HCL
284299
consul config write - <<'HCL' || true

0 commit comments

Comments
 (0)