1- # Stage 4 — example cluster .
1+ # Example cluster — peer-VIP / service-VIP layout .
22#
3- # This whole HCL file IS the cluster definition. To bring up a 4-instance
4- # Consul + Connect mesh on dstack:
3+ # This whole HCL file IS the cluster definition. To bring up an
4+ # HA Postgres + webdemo cluster on dstack:
55#
6- # PHALA_CLOUD_API_KEY=$(your token) terraform apply
6+ # PHALA_CLOUD_API_KEY=$(your token) terraform apply -parallelism=1
77#
88# Adding a worker is a `replicas` bump on phala_app.worker; terraform
99# apply propagates the new PEERS_JSON to every CVM via in-place env
10- # update (no destroy/recreate; disks survive — verified in
11- # stage4-experiments/disk-persistence/).
10+ # update (no destroy/recreate; disks survive).
1211
1312terraform {
1413 required_version = " >= 1.5"
@@ -47,6 +46,10 @@ provider "phala" {}
4746# them in Terraform and hand the same bytes to every phala_app via
4847# env. Trade-off accepted: anyone with read access to terraform.tfstate
4948# (or the apply host's memory) sees plaintext keys. Stage 2 closes this.
49+ #
50+ # Connect CA root is NOT in this list — Consul's built-in CA provider
51+ # generates the root in Raft on first quorum, no external derivation
52+ # required and no per-CVM problem.
5053resource "random_bytes" "gossip_key" {
5154 length = 32
5255}
@@ -80,92 +83,70 @@ variable "gateway_domain" {
8083 description = " Phala dstack gateway domain (e.g. dstack-pha-prod5.phala.network)"
8184}
8285
83- # Image references. Gap 2 collapsed bootstrap-secrets, mesh-conn, the
84- # legacy keepalive placeholder, and the old envoy-only sidecar into
85- # one `mesh_sidecar_image` (consul-postgres-ha-mesh-sidecar) — workers
86- # and coordinators both reference it and the entrypoint dispatches on
87- # ROLE. The `signaling` image is still published by CI (used by the
88- # external Vultr coordinator), but no dstack CVM in this cluster
89- # references it, so it isn't a Terraform input here.
86+ # Image references. The mesh_sidecar image bundles
87+ # bootstrap-secrets, mesh-conn, consul agent, and envoy; workers and
88+ # coordinators both reference it and the entrypoint dispatches on ROLE.
9089variable "mesh_sidecar_image" { type = string }
9190variable "webdemo_image" { type = string }
9291variable "patroni_image" { type = string }
9392
9493# External coordinator (Vultr coturn + signaling box). Used until
9594# Phala admin enables UDP ingress on dstack apps; once that lands we
9695# can host coturn + signaling inside the dstack mesh and drop these
97- # external_* vars. The dstack-coordinator compose no longer carries
98- # unused local copies of those services.
96+ # external_* vars.
9997variable "external_coordinator_host" { type = string }
10098variable "external_signaling_url" { type = string }
10199variable "external_turn_secret" {
102100 type = string
103101 sensitive = true
104102}
105103
106- # ---------- Protocol port plan ----------
104+ # ---------- Cluster topology + VIP allocation ----------
107105
108106locals {
109- # Index i is the same protocol on every peer; the per-peer port for
110- # protocol `name` at ordinal `n` is base + n. mesh-conn reads
111- # /run/instance/info.json for this peer's actual ports (computed by
112- # bootstrap-secrets from PROTOCOL_BASES + the ordinal it claimed).
113- protocol_bases = {
114- serf_lan = 18000
115- server_rpc = 18100
116- http_api = 18200
117- grpc = 18300
118- webdemo = 18500
119- sidecar_public = 18600
120- postgres = 18700 # Patroni-managed PostgreSQL listen
121- patroni_rest = 18800 # Patroni REST API (peer health, leader query)
122- }
123-
124- # The full peer list, identical on every CVM. Coordinators occupy
125- # ordinals 0..C-1 (where C = coordinator_replicas), workers fill
126- # ordinals C..C+W-1. PEERS_JSON is what mesh-conn consumes; the
127- # role-ordinal pair is what each peer self-identifies as in its
128- # bootstrap-secrets-derived /run/instance/info.json (mesh-conn then
129- # reads "<role>-<ordinal>" as its self ID).
107+ # Peer VIPs: 127.50.0.<vip>/32. Allocated as ordinal+1 (so vip=1
108+ # for the first coordinator, never 0 — the validate_test in
109+ # mesh-conn rejects vip=0). PEERS_JSON shape: [{id, vip}], same
110+ # on every CVM. mesh-conn binds the static infra-port allowlist
111+ # ({21000, 21001, 8300, 8301}) on every other peer's VIP.
130112 peers = concat (
131113 [
132114 for i in range (var. coordinator_replicas ) : {
133115 id = " coordinator-${ i } "
134116 ordinal = i
135117 role = " coordinator"
118+ vip = i + 1
136119 }
137120 ],
138121 [
139122 for i in range (var. worker_replicas ) : {
140- # ID must match mesh-conn's self_id, which is `role-ordinal`,
141- # NOT slot. Workers occupy ordinals C..C+W-1.
142123 id = " worker-${ i + var . coordinator_replicas } "
143124 ordinal = i + var . coordinator_replicas
144125 role = " worker"
126+ vip = i + var . coordinator_replicas + 1
145127 }
146128 ],
147129 )
148130
149131 peers_json = jsonencode ([
150- for p in local . peers : {
151- id = p . id
152- ports = [for proto , base in local . protocol_bases : base + p . ordinal ]
153- }
132+ for p in local . peers : { id = p.id, vip = p.vip }
154133 ])
155134
156- protocol_bases_json = jsonencode (local. protocol_bases )
157-
158- # Comma-separated lists of coordinator-ordinal-shifted ports. Workers
159- # use COORDINATOR_SERF_PORTS to retry-join EVERY coordinator, and
160- # COORDINATOR_HTTP_PORTS to pick ANY coordinator's HTTP API for
161- # KV-CAS bootstrapping. Coordinators use COORDINATOR_SERF_PORTS to
162- # gossip-join their server peers (consul -bootstrap-expect=N).
163- coordinator_serf_ports = join (" ," , [for i in range (var. coordinator_replicas ) : tostring (local. protocol_bases . serf_lan + i)])
164- coordinator_http_ports = join (" ," , [for i in range (var. coordinator_replicas ) : tostring (local. protocol_bases . http_api + i)])
165-
166- # First coordinator's HTTP port — used as a single endpoint for the
167- # consul-ui output and for legacy single-coord callers.
168- coordinator_http_port_first = local. protocol_bases . http_api + 0
135+ # COORDINATOR_VIPS — comma-separated for serf retry-join.
136+ coordinator_vips = join (" ," , [for p in local . peers : tostring (p. vip ) if p . role == " coordinator" ])
137+
138+ # Service VIPs: 127.10.0.<vip>/32 — one per Connect upstream a
139+ # worker consumes. Three services in this template (extend by
140+ # adding entries here + Connect upstreams in the sidecar config):
141+ # webdemo for the cross-peer fan-out demo
142+ # postgres-master the Patroni leader, leader-aware via subset filter
143+ # postgres-replica any Patroni replica
144+ service_vips = [
145+ { name = " webdemo" , vip = 10 , port = 8080 },
146+ { name = " postgres-master" , vip = 20 , port = 5432 },
147+ { name = " postgres-replica" , vip = 21 , port = 5432 },
148+ ]
149+ upstreams_json = jsonencode (local. service_vips )
169150}
170151
171152# ---------- Coordinator ----------
@@ -186,16 +167,15 @@ resource "phala_app" "coordinator" {
186167 docker_compose = file (" ${ path . module } /../compose/coordinator.yaml" )
187168
188169 env = {
189- CLUSTER_NAME = var.cluster_name
190- PROTOCOL_BASES = local.protocol_bases_json
191- PEERS_JSON = local.peers_json
192- COORDINATOR_ORDINAL = tostring (each. value )
193- BOOTSTRAP_EXPECT = tostring (var. coordinator_replicas )
194- COORDINATOR_SERF_PORTS = local.coordinator_serf_ports
195- SIGNALING_URL = var.external_signaling_url
196- TURN_HOST = var.external_coordinator_host
197- TURN_SHARED_SECRET = var.external_turn_secret
198- MESH_SIDECAR_IMAGE = var.mesh_sidecar_image
170+ CLUSTER_NAME = var.cluster_name
171+ PEERS_JSON = local.peers_json
172+ COORDINATOR_ORDINAL = tostring (each. value )
173+ BOOTSTRAP_EXPECT = tostring (var. coordinator_replicas )
174+ COORDINATOR_VIPS = local.coordinator_vips
175+ SIGNALING_URL = var.external_signaling_url
176+ TURN_HOST = var.external_coordinator_host
177+ TURN_SHARED_SECRET = var.external_turn_secret
178+ MESH_SIDECAR_IMAGE = var.mesh_sidecar_image
199179 # Stage-1 WORKAROUND — see `random_bytes` block at top of file.
200180 GOSSIP_KEY = random_bytes.gossip_key.base64
201181 }
@@ -215,17 +195,6 @@ resource "phala_app" "worker" {
215195 # app with replicas:N. Reason: each worker needs its OWN ordinal
216196 # passed in via env so bootstrap-secrets can write the correct
217197 # /run/instance/info.json without a Consul KV CAS round-trip.
218- # The CAS path has a chicken-and-egg: workers need Consul to
219- # claim an ordinal, but Consul (on the coordinator) is reached
220- # via mesh-conn, which depends on bootstrap-secrets having
221- # finished. Per-worker resources sidestep this entirely.
222- #
223- # Once phala-cloud#243 lands phala_app_instance + per-instance
224- # env, this reverts to one resource with replicas:N + per-instance
225- # env block.
226- # Key is the worker's 1-based slot (used in the CVM name); value is
227- # the cluster-wide ordinal (= slot + coordinator_replicas, since
228- # coordinators occupy ordinals 0..C-1).
229198 for_each = { for i in range (var. worker_replicas ) : tostring (i + 1 ) => i + var . coordinator_replicas }
230199
231200 name = " ${ var . cluster_name } -worker-${ each . key } "
@@ -237,19 +206,18 @@ resource "phala_app" "worker" {
237206 docker_compose = file (" ${ path . module } /../compose/worker.yaml" )
238207
239208 env = {
240- CLUSTER_NAME = var.cluster_name
241- PROTOCOL_BASES = local.protocol_bases_json
242- PEERS_JSON = local.peers_json
243- WORKER_ORDINAL = tostring (each. value )
244- EXPECTED_REPLICAS = var.worker_replicas + var.coordinator_replicas
245- COORDINATOR_SERF_PORTS = local.coordinator_serf_ports
246- COORDINATOR_HTTP_PORTS = local.coordinator_http_ports
247- SIGNALING_URL = var.external_signaling_url
248- TURN_HOST = var.external_coordinator_host
249- TURN_SHARED_SECRET = var.external_turn_secret
250- MESH_SIDECAR_IMAGE = var.mesh_sidecar_image
251- WEBDEMO_IMAGE = var.webdemo_image
252- PATRONI_IMAGE = var.patroni_image
209+ CLUSTER_NAME = var.cluster_name
210+ PEERS_JSON = local.peers_json
211+ UPSTREAMS_JSON = local.upstreams_json
212+ WORKER_ORDINAL = tostring (each. value )
213+ EXPECTED_REPLICAS = var.worker_replicas + var.coordinator_replicas
214+ COORDINATOR_VIPS = local.coordinator_vips
215+ SIGNALING_URL = var.external_signaling_url
216+ TURN_HOST = var.external_coordinator_host
217+ TURN_SHARED_SECRET = var.external_turn_secret
218+ MESH_SIDECAR_IMAGE = var.mesh_sidecar_image
219+ WEBDEMO_IMAGE = var.webdemo_image
220+ PATRONI_IMAGE = var.patroni_image
253221 # Stage-1 WORKAROUND — see `random_bytes` block at top of file.
254222 GOSSIP_KEY = random_bytes.gossip_key.base64
255223 PATRONI_SUPERUSER_PW = random_bytes.patroni_superuser_pw.hex
@@ -269,11 +237,7 @@ resource "phala_app" "worker" {
269237output "coordinator_app_ids" { value = { for k , c in phala_app . coordinator : k => c . app_id } }
270238output "worker_app_ids" { value = { for k , w in phala_app . worker : k => w . app_id } }
271239output "consul_ui" {
272- # Any coordinator's HTTP port serves the UI. Pick coord-0 by convention.
273- # `<port>` (no trailing 's') = gateway terminates TLS using the wildcard
274- # cert and forwards plain HTTP to the backend. Consul HTTP binds plain
275- # HTTP on 127.0.0.1, so this is the right convention. The `<port>s`
276- # form is for TLS pass-through where the backend speaks TLS itself
277- # (e.g. Envoy public mTLS on :21000).
278- value = " https://${ phala_app . coordinator [" 0" ]. app_id } -${ local . coordinator_http_port_first } .${ var . gateway_domain } /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"
279243}
0 commit comments