|
| 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