Skip to content

Commit 0ec755a

Browse files
h4x3rotabclaude
andcommitted
docs(consul-postgres-ha): design brief for declarative-services refactor
A DX review showed that adding a microservice currently takes edits in seven places — including hardcoded ports in `mesh-conn/main.go` (a Go rebuild + image republish + tag bump in tfvars) and copy-pasted Envoy supervise blocks in the platform sidecar entrypoint. None of those edits assert against each other; port-number mismatches surface as silent EDS-empty or TLS-handshake failures. The fix: declare the service mesh as data in `cluster.tf` and generate everything else from there. After this lands, adding a microservice is one HCL block edit, period — no mesh-conn rebuild, no entrypoint edits, no app-side Consul-registration code. This also extracts the only Patroni-specific code that lives in the platform sidecar (the role-watcher tag loop) back into `patroni/entrypoint.sh`, leaving `mesh-sidecar/` 100% workload- agnostic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f5c141 commit 0ec755a

2 files changed

Lines changed: 264 additions & 0 deletions

File tree

consul-postgres-ha/design/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ in their face.
1212

1313
| Doc | What |
1414
|---|---|
15+
| [`declarative-services.md`](declarative-services.md) | Adding a microservice should be one HCL block in `cluster.tf`. Today's `mesh-conn` allowlist is hardcoded Go; sidecar registration lives in app binaries; Envoy supervise loops are copy-pasted. This pass makes the service list declarative and generates the rest. |
1516
| [`attestation-admission.md`](attestation-admission.md) | Use dstack TEE attestation as the mesh-conn admission credential, replacing/augmenting the shared TURN HMAC. Phased plan: per-app-id first, Consul-KV-rooted policy later. |
1617

1718
Each doc includes:
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
# Design: declarative service mesh — adding a microservice is one HCL block
2+
3+
**Status**: design accepted, not started. Single rewrite, no migration
4+
scaffolding — old impl is committed, rollback is `git revert`, nothing
5+
in production. Branch off `dstack-consul-ha-db`, PR back into it.
6+
7+
**Dependencies**: builds on the service-VIP / peer-VIP architecture
8+
that landed at `808836c`. Attestation-rooted admission is still the
9+
last architectural stage; this work keeps the TF-broadcast secrets
10+
pattern unchanged.
11+
12+
## Why
13+
14+
A DX review showed that adding a microservice to the current cluster
15+
takes edits in **seven places**: `cluster.tf`, `mesh-conn/main.go`'s
16+
hardcoded port allowlist (requires a Go rebuild + image republish +
17+
tag bump in `tfvars`), `compose/worker.yaml`, the Envoy supervise
18+
block in `mesh-sidecar/entrypoint.sh` (copy-paste 50 lines, change
19+
`--base-id` and admin port), the app binary's Consul-registration
20+
JSON literal, the CI publish workflow, and the consumer's upstream
21+
list. None of those edits assert against each other; port-number
22+
mismatches surface as silent EDS-empty or TLS handshake failures.
23+
24+
Compare with the reference points the example is being measured
25+
against:
26+
27+
- **Istio bookinfo**: one `Service` + `Deployment` YAML per
28+
microservice; sidecar injector handles the rest. Zero port
29+
arithmetic.
30+
- **Consul HashiCups**: one tiny HCL `service { … connect {
31+
sidecar_service { upstreams = [...] } } }` per microservice;
32+
`consul services register` reads it. Zero platform edits.
33+
34+
The fix: **declare the service mesh in `cluster.tf`. Everything else
35+
generates from that declaration.**
36+
37+
In particular: **mesh-conn becomes invisible to developers.** Today's
38+
hardcoded `peerVIPAllowlist` in `mesh-conn/main.go:114-119` is a leak
39+
of app-level concerns (which sidecar ports cross peer boundaries) into
40+
platform-level code. After this work, adding a microservice does not
41+
involve mesh-conn at all — not even a config touch.
42+
43+
## Goal
44+
45+
After this work, adding a microservice "billing" on `:9090` that the
46+
existing `webdemo` consumes is **one HCL block edit in
47+
`cluster.tf`**:
48+
49+
```hcl
50+
local.services = [
51+
{ name = "webdemo", port = 8080 },
52+
{ name = "postgres-master", port = 5432, subset = "master" },
53+
{ name = "postgres-replica", port = 5432, subset = "replica" },
54+
{ name = "billing", port = 9090 }, # ← only addition
55+
]
56+
```
57+
58+
Plus `terraform apply`. Plus (if it's a new image) updating
59+
`webdemo`'s consumer list to include `billing` — one line.
60+
61+
No `mesh-conn/main.go` edit. No `mesh-sidecar/entrypoint.sh` edit. No
62+
Consul-registration code in the app binary. No port arithmetic. No
63+
CI workflow change unless the image is brand new.
64+
65+
## Non-goals
66+
67+
- **Replacing Consul Connect or Envoy.** Stock Connect remains the
68+
data plane.
69+
- **Workload identity beyond service names.** Attestation-rooted
70+
intentions still live in `design/attestation-admission.md`; this
71+
work uses plain service-name intentions.
72+
- **A service-mesh CLI or DSL.** The "declaration" is just HCL in
73+
`cluster.tf`. We're not building tooling on top.
74+
- **Maintaining backward compatibility with the current `mesh-conn`
75+
allowlist shape.** This is a fresh rewrite.
76+
77+
## Approach
78+
79+
Four coupled changes, all on the same feature branch:
80+
81+
### 1. `cluster.tf` declares the full service list
82+
83+
Replace today's `local.service_vips` with a single
84+
`local.services` list that captures everything the platform needs
85+
to know about each service:
86+
87+
```hcl
88+
local.services = [
89+
{
90+
name = "webdemo" # Consul service name, /etc/hosts alias
91+
port = 8080 # canonical port (binds 127.0.0.1:port locally)
92+
subset = null # optional: subset filter for service-resolver
93+
},
94+
{
95+
name = "postgres-master"
96+
port = 5432
97+
subset = "master" # → service-resolver redirects to stage4@master
98+
},
99+
100+
]
101+
```
102+
103+
VIP octets (`127.10.0.<n>`) and Envoy sidecar ports (`21000+n`) are
104+
**allocated automatically** by ordering — first service gets `vip=10
105+
sidecar_port=21000`, second `vip=11 sidecar_port=21001`, etc. The
106+
allocations are derived in HCL, not hand-managed.
107+
108+
For services where one canonical Patroni instance backs multiple
109+
logical names (the `postgres-master` / `postgres-replica` split is
110+
the only case today), they **share a single Envoy public listener +
111+
sidecar_port** — they're the same backend, just resolved through
112+
different subset filters. The HCL declares them as two entries with
113+
the same `port` and different `subset`; the generated config
114+
collapses them into one producer-side sidecar.
115+
116+
The resulting `SERVICES_JSON` env var is the **single source of
117+
truth** all other components read.
118+
119+
### 2. `mesh-conn` reads its allowlist from runtime config
120+
121+
Drop the hardcoded `peerVIPAllowlist` in `mesh-conn/main.go`.
122+
Replace with a `MESH_CONN_ALLOWLIST` env (JSON: `[{port, udp}, …]`)
123+
that the platform sidecar populates at startup.
124+
125+
The allowlist is always
126+
`{21000, 21001, …, 21000+N-1, 8300, 8301}` — the per-service
127+
sidecar ports for every producer-side sidecar, plus the two static
128+
Consul-infra ports. The platform sidecar generates this from
129+
`SERVICES_JSON` in `entrypoint.sh`; `mesh-conn` just reads it.
130+
131+
`validatePeers()` extends to validate the allowlist too: no
132+
duplicates, all ports in valid range.
133+
134+
### 3. Platform sidecar entrypoint generates Envoy supervise loops + Consul registration from `SERVICES_JSON`
135+
136+
Today: `mesh-sidecar/entrypoint.sh:231-269` has two copy-pasted
137+
50-line Envoy supervise blocks (one for webdemo, one for postgres),
138+
plus app code in `webdemo/main.go:76-129` does the Consul service
139+
registration via the Go SDK.
140+
141+
After: one `for service in SERVICES_JSON` loop in the entrypoint
142+
that, for each declared service:
143+
144+
- Allocates the loopback alias (`127.10.0.<vip>`).
145+
- Updates `/etc/hosts`.
146+
- Generates the Envoy bootstrap and starts a supervise loop on the
147+
appropriate `--base-id` and admin port.
148+
- **Registers the Consul service + sidecar via `consul services
149+
register` from a generated HCL/JSON spec** — apps stop calling the
150+
Consul API themselves.
151+
152+
The app binary's job collapses to: bind `127.0.0.1:<canonical-port>`
153+
and serve. No registration code, no platform awareness.
154+
155+
For services that need dynamic tag-mgmt (e.g. Patroni's role
156+
watcher), the platform sidecar registers the service with **no
157+
initial tags**, and the app's own entrypoint augments the
158+
registration with role tags via `consul services register -replace`.
159+
This keeps the platform-vs-app split clean: platform creates the
160+
service, app manages its dynamic state.
161+
162+
### 4. Patroni-specific code moves out of `mesh-sidecar/`
163+
164+
The role-watcher loop currently in `mesh-sidecar/entrypoint.sh` (it
165+
polls Patroni's REST every 5s and re-PUTs the postgres sidecar
166+
registration with `Tags=["master"]` / `Tags=["replica"]`) is the
167+
only Postgres-specific code in the platform sidecar. It moves
168+
verbatim into `patroni/entrypoint.sh`, where it belongs.
169+
170+
After this, `mesh-sidecar/` contains zero workload-specific code.
171+
172+
## Where the architecture invariant lands
173+
174+
The layering invariant from the service-discovery rewrite —
175+
**mesh-conn knows peers, not services** — gets strengthened:
176+
177+
> **The developer never touches mesh-conn.** mesh-conn's
178+
> configuration is platform plumbing: generated from the declared
179+
> service list by the sidecar's entrypoint, consumed by mesh-conn at
180+
> startup, never edited by hand. Adding or removing a service does
181+
> not require a mesh-conn rebuild.
182+
183+
Today's `peerVIPAllowlist` is a Go const; after this it's an env-var
184+
that the entrypoint emits. The substance is the same; the user-
185+
facing surface drops from "edit Go code" to "the platform handles
186+
it."
187+
188+
## Implementation — single rewrite, by file
189+
190+
- **`cluster-example/cluster.tf`**: replace `service_vips` with
191+
`services` list. Compute VIPs + sidecar_ports in HCL. Emit
192+
`SERVICES_JSON` env on workers.
193+
- **`mesh-conn/main.go`**: drop `peerVIPAllowlist` const; read
194+
`MESH_CONN_ALLOWLIST` env at startup. Extend `validatePeers()` to
195+
cover allowlist invariants.
196+
- **`mesh-sidecar/entrypoint.sh`**:
197+
- Read `SERVICES_JSON` at top.
198+
- Compute and export `MESH_CONN_ALLOWLIST` for mesh-conn.
199+
- Loop over services to provision aliases, render `/etc/hosts`,
200+
register Consul sidecar services, and launch Envoy supervise
201+
loops.
202+
- Remove the postgres-specific role-watcher loop.
203+
- Remove the hardcoded webdemo / postgres Envoy blocks.
204+
- **`compose/worker.yaml`**: replace per-app env-passing with
205+
`SERVICES_JSON`.
206+
- **`webdemo/main.go`**: remove all Consul registration code.
207+
Binary becomes ~20 LoC: bind `127.0.0.1:8080`, serve, exit.
208+
- **`patroni/entrypoint.sh`**: absorb the role-watcher loop from
209+
`mesh-sidecar/entrypoint.sh`. Patroni's entrypoint now drives the
210+
Patroni-specific tag dance against its own sidecar service
211+
registration.
212+
- **`validate_test.go`**: cover the allowlist validation cases.
213+
- **`README.md`**: update the "Adapting to your own workload"
214+
section to the new "add a service" flow.
215+
- **`ARCHITECTURE.md`**: minor — Layer 2 narrative mentions the
216+
allowlist is platform-generated.
217+
218+
## Risks + mitigations
219+
220+
| Risk | Mitigation |
221+
|---|---|
222+
| Apps that register their own services break because they're no longer the source of truth | webdemo + patroni are the only two in this repo; both are rewritten as part of this change. Document the new pattern in README so users adapting the template do the right thing. |
223+
| `consul services register` from the platform sidecar races with the app coming up | Platform registers the *sidecar* (which is its own concern, not app-state); app-driven tag updates use `-replace` which is idempotent. Verified by reading the Consul API docs; covered by the existing health-check pattern. |
224+
| `MESH_CONN_ALLOWLIST` env malformed → mesh-conn crashes silently | `validatePeers()` extends to fail-loud at startup with a specific error message. Same fail-fast philosophy as the existing PEERS_JSON validation. |
225+
| Two services with the same canonical port (e.g. both want `:5432`) | HCL precomputes the assignments; if a collision exists, `cluster.tf` errors out at plan time. Check is in HCL, not at runtime. |
226+
| Patroni role-watcher migrating breaks the existing failover behavior | Migrate verbatim — same loop, same poll interval, same `consul services register -replace`. Test against `FAILOVER.md` recipes. |
227+
228+
## Success criteria
229+
230+
- [ ] **Adding a new service to the cluster is one HCL block in
231+
`cluster.tf`** — verified by a worked example in the PR
232+
description.
233+
- [ ] **`mesh-conn/main.go` has no const-defined service list.** The
234+
allowlist comes from env.
235+
- [ ] **`mesh-sidecar/entrypoint.sh` has no Patroni-specific code.**
236+
`grep -i patroni mesh-sidecar/entrypoint.sh` returns nothing.
237+
- [ ] **`webdemo/main.go` has no Consul SDK calls.** The binary just
238+
binds and serves.
239+
- [ ] **All `FAILOVER.md` recipes still pass** with measured RTO
240+
within noise of the pre-rewrite baseline.
241+
- [ ] **`go test` in `mesh-conn` covers allowlist-malformed cases.**
242+
243+
## Hand-off
244+
245+
This is a mechanically clean refactor — no policy choices, no SDK
246+
uncertainty, no live-deploy verification required for the design to
247+
land (though a deploy is the right way to validate). The implementing
248+
agent should:
249+
250+
1. Read `ARCHITECTURE.md` for layering vocabulary.
251+
2. Read this doc.
252+
3. Implement on a feature branch off `dstack-consul-ha-db`.
253+
4. Verify locally: `go test ./...`, `terraform validate`, all shell
254+
entrypoints parse with `bash -n` / `sh -n`.
255+
5. Optional but recommended: a fresh live deploy via `terraform
256+
apply`, validate at least one `FAILOVER.md` recipe, then
257+
`terraform destroy`.
258+
6. Update README's "Adapting to your own workload" section to walk
259+
through the new flow on the worked example (add a fictional
260+
third service like `billing:9090`).
261+
262+
After this lands, this doc gets deleted. Surviving artifacts: the
263+
code + the updated README.

0 commit comments

Comments
 (0)