Skip to content

Commit ea9d37c

Browse files
h4x3rotabclaude
andcommitted
feat(consul-postgres-ha): declarative local.services in cluster.tf
Replace the hand-managed local.service_vips list with a single local.services declaration. VIP octets (127.10.0.<n>) and per-backend sidecar_ports (21000+n) are computed in HCL from declaration order; entries sharing a canonical port collapse onto one backend (one Envoy, one Connect-mTLS endpoint). Workers now receive SERVICES_JSON — single source of truth for the platform sidecar, patroni's role-watcher, and the consumer-side /etc/hosts setup in compose/worker.yaml. Plan-time precondition catches duplicate (name, subset) tuples. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0ec755a commit ea9d37c

2 files changed

Lines changed: 119 additions & 31 deletions

File tree

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

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -153,18 +153,80 @@ locals {
153153
# COORDINATOR_VIPS — comma-separated for serf retry-join.
154154
coordinator_vips = join(",", [for p in local.peers : tostring(p.vip) if p.role == "coordinator"])
155155

156-
# Service VIPs: 127.10.0.<vip>/32 — one per Connect upstream a
157-
# worker consumes. Three services in this template (extend by
158-
# adding entries here + Connect upstreams in the sidecar config):
159-
# webdemo for the cross-peer fan-out demo
160-
# postgres-master the Patroni leader, leader-aware via subset filter
161-
# postgres-replica any Patroni replica
162-
service_vips = [
163-
{ name = "webdemo", vip = 10, port = 8080 },
164-
{ name = "postgres-master", vip = 20, port = 5432 },
165-
{ name = "postgres-replica", vip = 21, port = 5432 },
156+
# ---------- Service declarations ----------
157+
#
158+
# local.services is the single source of truth for every microservice
159+
# on the mesh. Adding a service is one entry here; the platform
160+
# sidecar reads SERVICES_JSON at startup and generates everything
161+
# downstream (loopback aliases, /etc/hosts, Consul registrations,
162+
# Envoy supervise loops, mesh-conn allowlist).
163+
#
164+
# name Consul service name (also the /etc/hosts alias).
165+
# port Canonical app port. App binds 127.0.0.1:port; the local
166+
# Envoy upstream listener binds 127.10.0.<vip>:port.
167+
# subset Optional. When set, this entry is a subset filter on a
168+
# shared backend (the postgres-master/-replica split is the
169+
# only case today). Entries sharing the same `port` collapse
170+
# into one producer-side sidecar — same Envoy public
171+
# listener, same Connect-mTLS endpoint, just different
172+
# service-resolver names with different subset filters.
173+
services_raw = [
174+
{ name = "webdemo", port = 8080, subset = null },
175+
{ name = "postgres-master", port = 5432, subset = "master" },
176+
{ name = "postgres-replica", port = 5432, subset = "replica" },
166177
]
167-
upstreams_json = jsonencode(local.service_vips)
178+
179+
# Backends = unique canonical ports in declaration order. Each
180+
# backend gets ONE producer-side sidecar at sidecar_port=21000+idx.
181+
# Logical names sharing a backend (e.g. postgres-master + -replica)
182+
# ride the same Envoy public listener; their distinct names live
183+
# purely in service-resolver redirects on the Consul side.
184+
service_ports = distinct([for s in local.services_raw : s.port])
185+
186+
port_sidecar = {
187+
for idx, p in local.service_ports : tostring(p) => 21000 + idx
188+
}
189+
190+
# Parent service name per backend. If at least one entry at this
191+
# port has subset=null, that entry's `name` is the parent (the
192+
# platform sidecar registers a Consul service with Connect.SidecarService
193+
# inline — pattern A, webdemo case). If every entry has a subset,
194+
# the parent is var.cluster_name (pattern B — Patroni-style: the
195+
# parent service is auto-registered by the workload itself under
196+
# its `scope`, so the platform only registers a standalone
197+
# connect-proxy pointing at it).
198+
port_parent = {
199+
for p in local.service_ports : tostring(p) => try(
200+
[for s in local.services_raw : s.name if s.port == p && s.subset == null][0],
201+
var.cluster_name,
202+
)
203+
}
204+
205+
# Whether the platform registers the parent service itself.
206+
# False = the workload (e.g. Patroni) auto-registers its parent.
207+
port_registers_parent = {
208+
for p in local.service_ports : tostring(p) =>
209+
length([for s in local.services_raw : s if s.port == p && s.subset == null]) > 0
210+
}
211+
212+
# Final per-service descriptors. VIP octets allocated 10+ordinal so
213+
# subset entries each get their own 127.10.0.<vip>/32 loopback alias
214+
# + /etc/hosts entry — that's what makes `postgres-master:5432` and
215+
# `postgres-replica:5432` resolve to different consumer-side Envoy
216+
# listeners even when they share a producer-side sidecar.
217+
services = [
218+
for idx, s in local.services_raw : {
219+
name = s.name
220+
port = s.port
221+
subset = s.subset
222+
vip = 10 + idx
223+
sidecar_port = local.port_sidecar[tostring(s.port)]
224+
parent = local.port_parent[tostring(s.port)]
225+
registers_parent = local.port_registers_parent[tostring(s.port)]
226+
}
227+
]
228+
229+
services_json = jsonencode(local.services)
168230
}
169231

170232
# ---------- Coordinator ----------
@@ -228,7 +290,7 @@ resource "phala_app" "worker" {
228290
env = {
229291
CLUSTER_NAME = var.cluster_name
230292
PEERS_JSON = local.peers_json
231-
UPSTREAMS_JSON = local.upstreams_json
293+
SERVICES_JSON = local.services_json
232294
WORKER_ORDINAL = tostring(each.value)
233295
EXPECTED_REPLICAS = var.worker_replicas + var.coordinator_replicas
234296
COORDINATOR_VIPS = local.coordinator_vips
@@ -253,6 +315,20 @@ resource "phala_app" "worker" {
253315
wait_for_ready = true
254316
wait_timeout_seconds = 600
255317

318+
# Plan-time check: every (name, subset) tuple in local.services must
319+
# be unique. Catches typos like declaring two `webdemo` entries, or
320+
# two `postgres-master` entries with the same subset. Backends
321+
# (services sharing a canonical port) intentionally collapse onto
322+
# one sidecar_port; that's not flagged.
323+
lifecycle {
324+
precondition {
325+
condition = length(local.services) == length(distinct([
326+
for s in local.services : "${s.name}/${s.subset == null ? "" : s.subset}"
327+
]))
328+
error_message = "local.services has duplicate (name, subset) entries — each logical service name (with its subset) must be unique."
329+
}
330+
}
331+
256332
depends_on = [phala_app.coordinator]
257333
}
258334

consul-postgres-ha/compose/worker.yaml

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
# A "worker" CVM ends up running exactly three containers:
44
#
55
# sidecar bundled platform plumbing — bootstrap-secrets, mesh-conn,
6-
# consul (client), and TWO envoys (one per Connect sidecar:
7-
# webdemo on 21000, postgres on 21001). Provisions per-peer
8-
# VIP loopback aliases on 127.50.0.0/24 and per-service VIP
9-
# aliases on 127.10.0.0/24 at startup, so needs NET_ADMIN.
10-
# patroni the workload — Postgres + Patroni leader/replica.
6+
# consul (client), and one Envoy per backend declared in
7+
# SERVICES_JSON (services sharing a canonical port share a
8+
# backend). Provisions per-peer VIP loopback aliases on
9+
# 127.50.0.0/24 and per-service VIP aliases on
10+
# 127.10.0.0/24 at startup, so needs NET_ADMIN.
11+
# patroni the workload — Postgres + Patroni leader/replica. Also
12+
# hosts the role-watcher that mirrors Patroni's current
13+
# role onto the postgres sidecar's Service.Tags.
1114
# webdemo tiny example app sitting on the mesh; swap for your
1215
# own service when adapting this template.
1316

@@ -28,12 +31,16 @@ services:
2831
- WORKER_ORDINAL=${WORKER_ORDINAL}
2932
- EXPECTED_REPLICAS=${EXPECTED_REPLICAS}
3033
# PEERS_JSON shape: [{id, vip}] — vip is the last octet in
31-
# 127.50.0.0/24. mesh-conn binds the static infra-port allowlist
32-
# on every other peer's VIP; entrypoint provisions the aliases.
34+
# 127.50.0.0/24. mesh-conn binds the runtime allowlist on every
35+
# other peer's VIP; entrypoint provisions the aliases.
3336
- PEERS_JSON=${PEERS_JSON}
34-
# UPSTREAMS_JSON shape: [{name, vip, port}] — service VIPs the
35-
# local Envoy will bind for its declared Connect upstreams.
36-
- UPSTREAMS_JSON=${UPSTREAMS_JSON}
37+
# SERVICES_JSON shape: [{name, port, subset, vip, sidecar_port,
38+
# parent, registers_parent}] — single source of truth for every
39+
# microservice on the mesh. The sidecar generates Consul
40+
# registrations, Envoy supervise loops, /etc/hosts aliases, and
41+
# mesh-conn's allowlist from this one env. Editing local.services
42+
# in cluster.tf is the only place adding a service touches.
43+
- SERVICES_JSON=${SERVICES_JSON}
3744
# COORDINATOR_VIPS: comma-separated list of coordinators' peer
3845
# VIPs. Workers retry-join 127.50.0.<vip>:8301 (serf) for each.
3946
- COORDINATOR_VIPS=${COORDINATOR_VIPS}
@@ -71,18 +78,25 @@ services:
7178
# CLUSTER_NAME drives Patroni's `scope` — every peer's patroni
7279
# must use the same value to land in the same cluster. Postgres
7380
# binds the canonical 127.0.0.1:5432; Patroni REST on 127.0.0.1:8008.
81+
# The Patroni entrypoint also hosts the role-watcher loop that
82+
# mirrors Patroni's current role into the postgres-sidecar
83+
# registration's Service.Tags (workload-specific, lives here
84+
# instead of in the platform sidecar).
7485
# WORKAROUND: superuser + replication passwords generated in
7586
# Terraform and broadcast identically to every worker. See
7687
# cluster.tf.
7788
environment:
7889
- CLUSTER_NAME=${CLUSTER_NAME}
7990
- PATRONI_SUPERUSER_PW=${PATRONI_SUPERUSER_PW}
8091
- PATRONI_REPLICATION_PW=${PATRONI_REPLICATION_PW}
81-
# Service VIPs for /etc/hosts — patroni needs to resolve
82-
# postgres-master to the local service VIP so its replication
83-
# source string ("primary_conninfo") routes through the local
84-
# Envoy → mesh → leader's Postgres.
85-
- UPSTREAMS_JSON=${UPSTREAMS_JSON}
92+
# SERVICES_JSON: service VIPs for /etc/hosts (patroni needs to
93+
# resolve postgres-master to its local Envoy upstream so
94+
# primary_conninfo routes through the mesh), AND the sidecar
95+
# registration shape the role-watcher re-PUTs with tags.
96+
- SERVICES_JSON=${SERVICES_JSON}
97+
# PEERS_JSON: role-watcher needs SELF_VIP to set Address on the
98+
# sidecar registration.
99+
- PEERS_JSON=${PEERS_JSON}
86100
volumes:
87101
- /tmp/dstack-runtime/instance:/run/instance:ro
88102
- /tmp/dstack-runtime/secrets:/run/secrets:ro
@@ -97,17 +111,15 @@ services:
97111
restart: unless-stopped
98112
environment:
99113
- PEERS_JSON=${PEERS_JSON}
100-
- UPSTREAMS_JSON=${UPSTREAMS_JSON}
114+
- SERVICES_JSON=${SERVICES_JSON}
101115
entrypoint: ["/bin/sh", "-c"]
102116
command:
103117
- |
104118
set -e
105119
export PEER_ID=$$(jq -r '.role + "-" + (.ordinal|tostring)' /run/instance/info.json)
106-
export SELF_VIP=$$(echo "$$PEERS_JSON" | jq -r --arg id "$$PEER_ID" '.[] | select(.id == $$id) | .vip')
107120
# Append service-VIP /etc/hosts entries — each container has
108121
# its own /etc/hosts (network_mode: host doesn't share it).
109-
echo "$$UPSTREAMS_JSON" | jq -r '.[] | "127.10.0.\(.vip) \(.name)"' >> /etc/hosts
110-
export CONSUL_HTTP_ADDR=127.0.0.1:8500
122+
echo "$$SERVICES_JSON" | jq -r '.[] | "127.10.0.\(.vip) \(.name)"' >> /etc/hosts
111123
exec webdemo
112124
volumes:
113125
- /tmp/dstack-runtime/instance:/run/instance:ro

0 commit comments

Comments
 (0)