d5d2c29f
feat(deploy): a release is one pipeline run twice, at stg then prod
a73x 2026-08-08 14:02
Commit message
.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -31,3 +31,8 @@ deploy.env | |||
| 31 | 31 | ||
| 32 | /dist/ | 32 | /dist/ |
| 33 | site/dist/ | 33 | site/dist/ |
| 34 | |||
| 35 | # Transient probe dir scripts/ship.sh builds to ask internal/server/release | ||
| 36 | # whether a tag is parsable (removed on exit; ignored so a crash can't leave | ||
| 37 | # the tree dirty for the next run's clean-tree check). | ||
| 38 | /.shipcheck.*/ | ||
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -13,7 +13,7 @@ LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret | |||
| 13 | .PHONY: build build-go build-darwin web test vet proto clean \ | 13 | .PHONY: build build-go build-darwin web test vet proto clean \ |
| 14 | lint lint-extra arch cover fmt fmt-check tidy-check proto-check shape shape-check api api-check \ | 14 | lint lint-extra arch cover fmt fmt-check tidy-check proto-check shape shape-check api api-check \ |
| 15 | site site-check ci deadcode \ | 15 | site site-check ci deadcode \ |
| 16 | deploy release hooks site-image server-image | 16 | deploy release ship hooks site-image server-image |
| 17 | 17 | ||
| 18 | # Enable the repo's client-side merge gate: point git at .githooks, whose | 18 | # Enable the repo's client-side merge gate: point git at .githooks, whose |
| 19 | # pre-push hook runs `make ci` before any push that updates main. Run once per | 19 | # pre-push hook runs `make ci` before any push that updates main. Run once per |
| @@ -68,6 +68,14 @@ deploy: | |||
| 68 | release: web | 68 | release: web |
| 69 | ./scripts/release.sh | 69 | ./scripts/release.sh |
| 70 | 70 | ||
| 71 | # The release pipeline for one plane: build the tagged tree, publish it, roll the | ||
| 72 | # plane and prove it (see scripts/ship.sh). Run it twice — TARGET=stg with a | ||
| 73 | # pre-release tag, then TARGET=prod. FROM=<n> resumes at a stage after a partial | ||
| 74 | # failure; the script's other flags are for direct use. | ||
| 75 | ship: | ||
| 76 | @$(if $(and $(TARGET),$(TAG)),,$(error ship: TARGET and TAG are required, e.g. make ship TARGET=stg TAG=v0.0.4-pre.1)) | ||
| 77 | ./scripts/ship.sh --target $(TARGET) --tag $(TAG)$(if $(FROM), --from $(FROM)) | ||
| 78 | |||
| 71 | # --- quality gates ----------------------------------------------------------- | 79 | # --- quality gates ----------------------------------------------------------- |
| 72 | 80 | ||
| 73 | # Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests | 81 | # Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests |
deploy/server/Dockerfile
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,13 @@ | |||
| 1 | # eitri-server production image: one static Go binary (pure-Go sqlite, no | 1 | # eitri-server production image: static Go binaries (pure-Go sqlite, no CGO) |
| 2 | # CGO) on distroless/static — CA roots included for OIDC discovery, runs as | 2 | # on distroless/static — CA roots included for OIDC discovery, runs as nonroot |
| 3 | # nonroot (uid 65532). The console SPA is embedded in the binary; state | 3 | # (uid 65532). The console SPA is embedded in the binary; state lives on the |
| 4 | # lives on the mounted PVC and config in the mounted server.json Secret. | 4 | # mounted PVC and config in the mounted server.json Secret. |
| 5 | # | ||
| 6 | # eitri-oidc rides along as a second entrypoint. A plane with no external | ||
| 7 | # identity provider runs it as its own Deployment off this same image, so the | ||
| 8 | # issuer can never be a different version than the server it authenticates for | ||
| 9 | # — and a plane fronted by a real IdP simply never starts it. | ||
| 5 | FROM gcr.io/distroless/static-debian12:nonroot | 10 | FROM gcr.io/distroless/static-debian12:nonroot |
| 6 | COPY eitri-server /eitri-server | 11 | COPY eitri-server /eitri-server |
| 12 | COPY eitri-oidc /eitri-oidc | ||
| 7 | ENTRYPOINT ["/eitri-server"] | 13 | ENTRYPOINT ["/eitri-server"] |
deploy/server/README.md
| Old | New | ||
|---|---|---|---|
| @@ -1,82 +1,454 @@ | |||
| 1 | # eitri-server on the eitri.sh cluster | 1 | # The hosted planes and the release pipeline |
| 2 | 2 | ||
| 3 | One container runs the whole control plane (embedded console, API, OIDC | 3 | Two planes run on one cluster from one set of manifests: **stg** |
| 4 | auth, QUIC sync, SSH gate), pinned to vnic-1 — the cluster's public-IP | 4 | (`stg.eitri.sh`) and **prod** (`console.eitri.sh`). A release is |
| 5 | node — with state on a local-path PVC and config from a Secret. | 5 | `scripts/ship.sh` run twice — first at stg with a pre-release tag, then at prod |
| 6 | with the release tag, same script, same stages, artifacts rebuilt from the same | ||
| 7 | tree. Nothing reaches prod that a machine has not already done to stg. | ||
| 6 | 8 | ||
| 7 | ## One-time bring-up | 9 | Each plane is one container carrying the whole control plane (console, API, |
| 10 | OIDC relying party, QUIC sync, SSH gate) pinned to the cluster's public-IP | ||
| 11 | node, with state on a local-path PVC and config in a Secret, plus an nginx | ||
| 12 | deployment serving the static site and its `/dl` artifacts. | ||
| 8 | 13 | ||
| 9 | 1. **Google OAuth client** (GCP console → Credentials): Web application, | 14 | ## The two planes |
| 10 | authorized redirect `https://console.eitri.sh/auth/callback`. Note the | 15 | |
| 11 | client id + secret. | 16 | | | prod | stg | |
| 12 | 2. **server.json** (never committed; lives only in the Secret): | 17 | |---|---|---| |
| 18 | | namespace | `eitri` | `eitri-stg` | | ||
| 19 | | console | `console.eitri.sh` | `stg.eitri.sh` | | ||
| 20 | | MCP host | `api.eitri.sh` | `api.stg.eitri.sh` | | ||
| 21 | | gate | `gate.eitri.sh:2222` | `gate.stg.eitri.sh:2223` | | ||
| 22 | | sync | `sync.eitri.sh:8443` | `sync.stg.eitri.sh:8444` | | ||
| 23 | | site / `/dl` | `eitri.sh` | `dl.stg.eitri.sh` | | ||
| 24 | | http listener | `:8081` | `:8082` | | ||
| 25 | | guest CIDR pool | `10.78.0.0/16` | `10.79.0.0/16` | | ||
| 26 | | sign-in | Google | bundled `eitri-oidc` at `oidc.stg.eitri.sh` | | ||
| 27 | | backups | nightly | none — the database is disposable | | ||
| 28 | | CDN purge | yes | no — served cache-bypassed | | ||
| 29 | |||
| 30 | The pod runs `hostNetwork`, so those ports are the **node's**: the two planes | ||
| 31 | share a node and cannot share a port triple. Deployment, Service, PVC and | ||
| 32 | IngressRoute keep the same names in both namespaces — they are namespace-scoped, | ||
| 33 | so per-plane naming would be noise, and identical names mean `kubectl -n eitri` | ||
| 34 | and `kubectl -n eitri-stg` take the same commands. Only what is node-scoped or | ||
| 35 | cluster-visible (host ports, hostnames, TLS secrets) differs. | ||
| 36 | |||
| 37 | The guest CIDR pools differ so a host flicked between planes never carries a | ||
| 38 | colliding guest subnet. The local dev plane is `10.77.0.0/16`. | ||
| 39 | |||
| 40 | ### Reading a manifest | ||
| 41 | |||
| 42 | The manifests are templates. Rendering is how you read them: | ||
| 43 | |||
| 44 | scripts/ship.sh --target stg --tag v0.0.4-pre.1 --render-only | ||
| 45 | |||
| 46 | That touches nothing — no cluster, no build — and prints exactly what an apply | ||
| 47 | would send. Values come from `deploy/server/plane.<target>.env` (committed) and | ||
| 48 | `~/eitri-deploy/<target>/ship.env` (not). `envsubst` is called with an explicit | ||
| 49 | variable list and every name on it is checked non-empty first, because | ||
| 50 | `envsubst`'s own answer to an unset variable is to substitute nothing at all. | ||
| 51 | |||
| 52 | One template set, two planes, so the planes cannot differ in *shape* — only in | ||
| 53 | *values*. That is the whole premise: the shape under test at stg is the real one. | ||
| 54 | |||
| 55 | Two components are *present or absent* rather than shaped differently, each | ||
| 56 | gated on a plane value: the backup CronJob (`BACKUPS`) and the bundled issuer | ||
| 57 | (`LOCAL_OIDC`). Their values join the template variable list only on a plane | ||
| 58 | that has them, so a plane without one can never render a half-filled manifest | ||
| 59 | for it. | ||
| 60 | |||
| 61 | ### Sign-in, and the one place the planes genuinely differ | ||
| 62 | |||
| 63 | prod signs in against Google. stg runs `eitri-oidc`, the issuer that already | ||
| 64 | serves the local plane, so stg is reproducible from nothing but this repo and a | ||
| 65 | Secret, its identities are ours to create and delete, and the smoke can drive a | ||
| 66 | real sign-in headlessly — which is what lets the credential chain be *proven* at | ||
| 67 | stg rather than skipped. | ||
| 68 | |||
| 69 | It runs as its own Deployment from the **server's image at the server's tag**, | ||
| 70 | so the two can never be different builds, and it is an ordinary pod behind a | ||
| 71 | Service — nothing about it needs a socket on the node, so it costs the shared | ||
| 72 | node no port. It gets **its own hostname**, `oidc.stg.eitri.sh`, rather than a | ||
| 73 | path on the console: the login form posts back to the absolute path | ||
| 74 | `/authorize`, so mounted under a prefix the browser's POST would land on the | ||
| 75 | console host's catch-all rule instead of the issuer. An issuer only works at the | ||
| 76 | root of its own origin. | ||
| 77 | |||
| 78 | **Its state is not disposable.** `users.json` holds each identity's subject and | ||
| 79 | the server derives a tenant from that subject, so losing the file signs the | ||
| 80 | operator into a brand-new tenant while the old one still owns the plane's hosts | ||
| 81 | — with no error anywhere to say so. Hence its own PVC, pinned to the same node | ||
| 82 | as the server's. | ||
| 83 | |||
| 84 | What this costs: **stg does not exercise prod's relying-party wiring against a | ||
| 85 | third-party identity provider.** Everything after sign-in is identical; the | ||
| 86 | Google-specific half of prod's login is proven only at prod. | ||
| 87 | |||
| 88 | ### Two facts about routing that will surprise someone | ||
| 89 | |||
| 90 | **The console host carries every path, so `console.eitri.sh/mcp` is a working | ||
| 91 | MCP endpoint** — and it is the one a proxy fronts. `api.eitri.sh` is not the | ||
| 92 | only way in; it is the way in that does not also route the console (routing the | ||
| 93 | whole API host would make it a second console origin, and OIDC sign-in works at | ||
| 94 | exactly `oidc.public_url`). The smoke exercises both. | ||
| 95 | |||
| 96 | **stg's console is proxied and prod's is not.** stg is *more* fronted than prod, | ||
| 97 | which inverts the usual staging relationship and has one practical consequence: | ||
| 98 | the console's SSE streams traverse Cloudflare at stg and go direct at prod. If a | ||
| 99 | stg run shows a dead live-updating console or a stalled event stream, the first | ||
| 100 | hypothesis is proxy buffering, not a code defect — and the confirming test is | ||
| 101 | the same page against `api.stg.eitri.sh`, which is grey-cloud. | ||
| 102 | |||
| 103 | ## The pipeline | ||
| 104 | |||
| 105 | scripts/ship.sh --target <stg|prod> --tag <vX.Y.Z[-pre.N]> | ||
| 106 | [--from <stage>] [--skip-smoke] [--render-only] | ||
| 107 | |||
| 108 | Every stage announces itself and is idempotent; re-running from the top is | ||
| 109 | always safe and is the documented default. `--from <n>` resumes after a partial | ||
| 110 | failure. | ||
| 111 | |||
| 112 | 1. **Verify the tag** — clean tree, HEAD is exactly this tag, and the version is | ||
| 113 | parsable by `internal/server/release`, asked by running that code rather than | ||
| 114 | re-deriving its rule. An unparsable version silently disables the upgrade | ||
| 115 | button fleet-wide. Releases are `vX.Y.Z`, pre-releases `vX.Y.Z-pre.N`, and | ||
| 116 | the fleet orders the whole chain: | ||
| 117 | `v0.0.4-pre.1 < v0.0.4-pre.2 < v0.0.4 < v0.0.4-2-g<hex>`. | ||
| 118 | 2. **Build the artifacts** into `dist/<tag>`, with the manifest base pointed at | ||
| 119 | the target's own `/dl`. This is the one build input the planes legitimately | ||
| 120 | differ on, and it is why the stg run *proves* the download and upgrade paths | ||
| 121 | rather than rehearsing them. | ||
| 122 | 3. **Build and push the images**, server and site, tagged with the version. | ||
| 123 | 4. **Check the config** (see below). | ||
| 124 | 5. **Apply the plane's shape** — namespace, middleware, storage, service, | ||
| 125 | certificates, routes. Nothing here restarts anything. | ||
| 126 | 6. **Roll the site, then purge.** Order is load-bearing: the server fetches the | ||
| 127 | release manifest at boot and pins the answer for 24 hours, so a server rolled | ||
| 128 | ahead of its site serves the previous release to the whole fleet for a day. | ||
| 129 | 7. **Roll the control plane.** Recreate strategy — a brief gap; agents redial. | ||
| 130 | 8. **Hosted smoke**: the same `eitri-smoke` the branch gate runs, against this | ||
| 131 | plane's public names. | ||
| 132 | 9. **Report** what is actually running. | ||
| 133 | |||
| 134 | ### Stage 4, the one that justifies the script | ||
| 135 | |||
| 136 | Every incident on v0.0.3 release day was a hosted-shape failure the local gate | ||
| 137 | cannot see. Stage 4 is where that class dies. It reads the plane's Secret, never | ||
| 138 | writes it, and never prints its contents. | ||
| 139 | |||
| 140 | *Schema drift, both directions.* The keys the **tagged tree** declares are | ||
| 141 | extracted from `internal/server/config/config.go` and compared against | ||
| 142 | `deploy/server/config.required`, which classifies each as `required`, `optional` | ||
| 143 | or `retired`. A key the tree grew that nobody classified is a hard failure | ||
| 144 | naming the key; a required key missing from the Secret is a hard failure naming | ||
| 145 | the key and the patch that fixes it; a retired key still set is a warning. Had | ||
| 146 | this existed, `default_images` would have failed a pre-deploy check the moment | ||
| 147 | the struct grew the field, instead of becoming a live secret patch. | ||
| 148 | |||
| 149 | *Plane agreement.* The Secret's `http_listen`, `quic_listen`, `ssh_listen`, | ||
| 150 | `ssh_gate_domain`, `advertise_http`, `advertise_quic` and `oidc.public_url` must | ||
| 151 | match the plane's rendered manifests. Each of these disagreeing is a failure | ||
| 152 | that presents as something else entirely: a wrong gate domain reads as "pubkey | ||
| 153 | denied", a wrong `advertise_quic` as a host that enrolls and then never syncs. | ||
| 154 | |||
| 155 | ### What stays operator-manual | ||
| 156 | |||
| 157 | Ordered by when they bite. | ||
| 158 | |||
| 159 | 1. **The Google OAuth client — prod only.** No API, and a mistake is invisible | ||
| 160 | until sign-in fails. stg runs the bundled issuer and needs nothing from | ||
| 161 | Google. | ||
| 162 | 2. **First-time Secret creation.** The pipeline validates config secrets and | ||
| 163 | never writes them: a script that can write one is a script that can overwrite | ||
| 164 | prod's. That is two secrets on a plane with a bundled issuer, one on prod. | ||
| 165 | 3. **`regcred` copied into the namespace.** | ||
| 166 | 4. **Registering the plane's identity — stg only**, one `kubectl exec` at | ||
| 167 | bring-up. The pipeline stays plane-agnostic: it forwards whatever credential | ||
| 168 | `ship.env` names and never creates one. | ||
| 169 | 5. **Firewall openings** for the plane's sync and gate ports, in the cloud | ||
| 170 | security list *and* the node's host firewall. | ||
| 171 | 6. **The Cloudflare purge token**, once, into | ||
| 172 | `~/eitri-deploy/pipeline/cf-purge.env` (`CF_ZONE_ID`, `CF_API_TOKEN`). Until | ||
| 173 | it exists a prod run refuses to finish without `ALLOW_MANUAL_PURGE=1`, which | ||
| 174 | is how you say out loud that you will purge by hand. | ||
| 175 | 7. **DNS.** Verify rather than create: the `*.eitri.sh` wildcard covers the HTTP | ||
| 176 | names. `gate` and `sync` are the exception and need explicit A records — see | ||
| 177 | "Known gaps". | ||
| 178 | 8. **Minting the operator PAT — prod only.** Where the plane's issuer is one of | ||
| 179 | ours, the smoke signs in and mints its own, so there is no token to paste and | ||
| 180 | none to rotate. | ||
| 181 | 9. **Tagging.** The pipeline verifies tags; it never creates or pushes them. | ||
| 182 | |||
| 183 | ## One-time bring-up of a plane | ||
| 184 | |||
| 185 | Shown for stg. prod is the same minus steps 5 and 7, plus a Google OAuth client | ||
| 186 | (Web application, authorized redirect `https://console.eitri.sh/auth/callback`) | ||
| 187 | whose id and secret go into `oidc` below, and a hand-minted operator PAT at the | ||
| 188 | end. | ||
| 189 | |||
| 190 | 1. **`eitri-oidc.json`** — 0600, in `~/eitri-deploy/stg/`, the bundled issuer's | ||
| 191 | own config. `issuer` must be the URL browsers and the server reach it at, and | ||
| 192 | `redirect_url` must equal the server's callback exactly; stage 4 asserts both | ||
| 193 | against the plane rather than letting them drift into a 400 at the end of an | ||
| 194 | otherwise working login. | ||
| 195 | |||
| 196 | ```json | ||
| 197 | { | ||
| 198 | "listen": ":9111", | ||
| 199 | "issuer": "https://oidc.stg.eitri.sh", | ||
| 200 | "users_file": "/var/lib/eitri-oidc/users.json", | ||
| 201 | "signing_key": "/var/lib/eitri-oidc/signing.key", | ||
| 202 | "clients": [ | ||
| 203 | {"id": "eitri-console", "redirect_url": "https://stg.eitri.sh/auth/callback"} | ||
| 204 | ] | ||
| 205 | } | ||
| 206 | ``` | ||
| 207 | |||
| 208 | 2. **`server.json`** — 0600, in `~/eitri-deploy/<target>/`, never committed and | ||
| 209 | never written by a script: | ||
| 13 | 210 | ||
| 14 | ```json | 211 | ```json |
| 15 | { | 212 | { |
| 16 | "db_path": "/var/lib/eitri/eitri.db", | 213 | "db_path": "/var/lib/eitri/eitri.db", |
| 17 | "http_listen": ":8081", | 214 | "http_listen": ":8082", |
| 18 | "quic_listen": ":8443", | 215 | "quic_listen": ":8444", |
| 19 | "default_image_url": "https://cloud-images.ubuntu.com/resolute/20260720/resolute-server-cloudimg-amd64.img", | 216 | "ssh_listen": ":2223", |
| 20 | "default_image_sha256": "117816726abbdefc5ef3e38902e81a76f1c76c3610e709999d0885f9d5d9b477", | 217 | "ssh_gate_domain": "gate.stg.eitri.sh", |
| 21 | "advertise_http": "https://console.eitri.sh", | ||
| 22 | "advertise_quic": "sync.eitri.sh:8443", | ||
| 23 | "cidr_pool": "10.78.0.0/16", | ||
| 24 | "ssh_listen": ":2222", | ||
| 25 | "ssh_gate_domain": "gate.eitri.sh", | ||
| 26 | "ssh_ca_key": "/var/lib/eitri/ssh_ca", | 218 | "ssh_ca_key": "/var/lib/eitri/ssh_ca", |
| 27 | "ssh_host_key": "/var/lib/eitri/ssh_host_key", | 219 | "ssh_host_key": "/var/lib/eitri/ssh_host_key", |
| 220 | "advertise_http": "https://stg.eitri.sh", | ||
| 221 | "advertise_quic": "sync.stg.eitri.sh:8444", | ||
| 222 | "cidr_pool": "10.79.0.0/16", | ||
| 28 | "host_secret": "<openssl rand -hex 32>", | 223 | "host_secret": "<openssl rand -hex 32>", |
| 224 | "release_manifest_url": "https://dl.stg.eitri.sh/dl/latest/manifest.json", | ||
| 225 | "default_images": { | ||
| 226 | "amd64": {"url": "...", "sha256": "..."}, | ||
| 227 | "arm64": {"url": "...", "sha256": "..."} | ||
| 228 | }, | ||
| 29 | "oidc": { | 229 | "oidc": { |
| 30 | "issuer": "https://accounts.google.com", | 230 | "issuer": "https://oidc.stg.eitri.sh", |
| 31 | "client_id": "<google client id>", | 231 | "client_id": "eitri-console", |
| 32 | "client_secret": "<google client secret>", | 232 | "public_url": "https://stg.eitri.sh", |
| 33 | "public_url": "https://console.eitri.sh" | 233 | "allowed_identities": ["ship@eitri.local"] |
| 34 | } | 234 | } |
| 35 | } | 235 | } |
| 36 | ``` | 236 | ``` |
| 37 | 237 | ||
| 38 | ``` | 238 | No `client_secret`: the bundled issuer registers public PKCE clients, and the |
| 39 | kubectl -n eitri create secret generic eitri-server-config \ | 239 | server pins the token request's client-auth style accordingly. prod's Google |
| 40 | --from-file=server.json=./server.json | 240 | client is confidential and does set one. |
| 41 | ``` | 241 | |
| 242 | `allowed_identities` closes the signup gate. Both architectures get a | ||
| 243 | `default_images` entry: a host handed an image it cannot execute fails at | ||
| 244 | boot, which is the mistake that map exists to prevent — and stage 4 enforces | ||
| 245 | it for every entry, not just the first. | ||
| 246 | |||
| 247 | 3. **Namespace and pull secret**: | ||
| 248 | |||
| 249 | kubectl create ns eitri-stg | ||
| 250 | kubectl -n eitri get secret regcred -o yaml \ | ||
| 251 | | sed 's/namespace: eitri/namespace: eitri-stg/' | kubectl apply -f - | ||
| 252 | |||
| 253 | 4. **The config Secrets** — the server's, and the issuer's where there is one: | ||
| 254 | |||
| 255 | kubectl -n eitri-stg create secret generic eitri-stg-server-config \ | ||
| 256 | --from-file=server.json=$HOME/eitri-deploy/stg/server.json | ||
| 257 | kubectl -n eitri-stg create secret generic eitri-stg-oidc-config \ | ||
| 258 | --from-file=eitri-oidc.json=$HOME/eitri-deploy/stg/eitri-oidc.json | ||
| 259 | |||
| 260 | 5. **`ship.env`**: `cp scripts/ship.env.example ~/eitri-deploy/stg/ship.env` | ||
| 261 | and fill it in. For stg that means `CI_USER` and `CI_PASSWORD_FILE`; generate | ||
| 262 | the password once and lock it down: | ||
| 263 | |||
| 264 | umask 077; openssl rand -hex 16 > ~/eitri-deploy/stg/ship-password | ||
| 265 | |||
| 266 | 6. **First run**: `scripts/ship.sh --target stg --tag <pre-tag> --skip-smoke` | ||
| 267 | applies everything else and starts both deployments. The server creates its | ||
| 268 | own SSH CA and gate host key on the PVC at first boot, so the gate comes up | ||
| 269 | on its own. Skip the smoke on this one run: the identity it signs in as does | ||
| 270 | not exist yet. | ||
| 271 | |||
| 272 | 7. **Register the plane's identity**, once, in the running issuer. It writes to | ||
| 273 | the issuer's PVC, so it survives every later roll: | ||
| 274 | |||
| 275 | kubectl -n eitri-stg exec deploy/eitri-oidc -i -- \ | ||
| 276 | /eitri-oidc user add --config /etc/eitri/eitri-oidc.json \ | ||
| 277 | --password-file /dev/stdin ship@eitri.local \ | ||
| 278 | < ~/eitri-deploy/stg/ship-password | ||
| 279 | |||
| 280 | The password arrives on stdin rather than in an argument, which would put it | ||
| 281 | in the pod's process list. Re-running this changes the password and **keeps | ||
| 282 | the identity's subject**, so the tenant it owns survives a rotation. | ||
| 283 | |||
| 284 | 8. **Sign in at exactly `https://stg.eitri.sh`** — anything else gives "invalid | ||
| 285 | oauth state" — which JIT-provisions the operator tenant that will own the | ||
| 286 | plane's hosts. Then `scripts/ship.sh --target stg --tag <pre-tag> --from 8` | ||
| 287 | proves the whole thing. | ||
| 288 | |||
| 289 | There is no PAT to mint by hand here. The smoke signs in as that same | ||
| 290 | identity and mints its own short-lived token for the run, deriving the tenant | ||
| 291 | from it rather than being told. prod, which cannot sign in headlessly against | ||
| 292 | Google, still needs a non-expiring PAT named `ship` saved to | ||
| 293 | `~/eitri-deploy/prod/deploy-pat` (0600) and named in its `ship.env`. | ||
| 294 | |||
| 295 | ### Adopting prod's site objects | ||
| 296 | |||
| 297 | The `site-*.yaml` files describe the objects that have served `eitri.sh` since | ||
| 298 | before they were in the tree, and they carry those objects' own names — | ||
| 299 | Deployment and Service `web`, IngressRoutes `web-http` and `web-https`, | ||
| 300 | Certificate `web-tls`. They adopt the live site; they do not stand a second one | ||
| 301 | beside it. Confirm that before the first prod run: | ||
| 42 | 302 | ||
| 43 | The pool is deliberately 10.78/16 — the dev fleet is 10.77/16, so a | 303 | scripts/ship.sh --target prod --tag <tag> --render-only > /tmp/prod.yaml |
| 44 | host flicked between fleets never carries colliding guest subnets. | 304 | kubectl diff -f /tmp/prod.yaml |
| 45 | 3. **Image**: `make server-image` (needs SERVER_IMAGE in deploy.env), then | ||
| 46 | `kubectl -n eitri set image deployment/eitri-server eitri-server=<SERVER_IMAGE>:<version>`. | ||
| 47 | 4. **Apply**: `kubectl apply -f pvc.yaml -f deployment.yaml -f service.yaml -f certificate.yaml -f ingressroute.yaml -f backup-cronjob.yaml`. The server creates its own SSH CA and gate host key on the PVC on first boot, so the gate comes up on its own — no key step is required. | ||
| 48 | 5. **Network** (operator): | ||
| 49 | - Oracle security list AND vnic-1 host firewall: open 8443/udp, 2222/tcp. | ||
| 50 | - Cloudflare DNS: `console.eitri.sh` proxied to the eitri.sh origin (repoint off the old tailnet record — confirm nothing dev-side still resolves it); `sync.eitri.sh` and `gate.eitri.sh` grey-cloud A records to vnic-1's public IP. | ||
| 51 | - `api.eitri.sh` (the MCP endpoint): A record to the same origin as console. Proxied works only when the MCP client sends progress tokens — a `vm_create` that stays silent past Cloudflare's ~100s origin timeout gets a 524; if the client under test doesn't, use a grey-cloud record. | ||
| 52 | 6. **Backups**: nightly CronJob writes dated sqlite backups on the PVC; run `backup-pull.sh` from cron on an off-cluster machine — local-path storage does not survive the node, the off-node copy is the DR story. | ||
| 53 | 305 | ||
| 54 | ## Rollout | 306 | Every object should read as an update, never a create. One intentional |
| 307 | difference is expected: `web-http` gains the `redirect-https` middleware the | ||
| 308 | console's HTTP route already uses, so plain-HTTP `eitri.sh` starts redirecting. | ||
| 309 | The pipeline never deletes, so anything the live namespace carries that these | ||
| 310 | manifests do not describe — an unused `web-data` PVC, say — is left alone. | ||
| 55 | 311 | ||
| 56 | make server-image | 312 | ## Runbook: the nested dev fleet |
| 57 | kubectl -n eitri set image deployment/eitri-server eitri-server=<SERVER_IMAGE>:<version> | ||
| 58 | 313 | ||
| 59 | Single replica + Recreate: a rollout is a short outage; agents reconnect | 314 | The branch gate's fleet lives on the workstation, defined by |
| 60 | and re-sync on their own (the sync registry is rebuilt from Hellos). | 315 | `scripts/devhost.sh` rather than hand-built. Bring it up **before** mewtwo |
| 316 | moves to stg — until it is green, the project has no branch gate. | ||
| 61 | 317 | ||
| 62 | The pod runs hostNetwork: the QUIC sync socket (8443/udp) and SSH gate | 318 | scripts/devhost.sh create # from nothing |
| 63 | (2222/tcp) bind vnic-1's interfaces directly — both flows carry pinned | 319 | scripts/devhost.sh recycle # destroy + create; a wedged host is 3 minutes |
| 64 | end-to-end cryptography and tolerate no middlebox, including the CNI's | 320 | scripts/devhost.sh destroy |
| 65 | hostPort NAT, which conntrack-drops long-lived single-tuple UDP flows. | ||
| 66 | The console listens on :8081 (svclb claims host 8080); the Service maps | ||
| 67 | 8080 → 8081 so the IngressRoute is unaffected. | ||
| 68 | 321 | ||
| 69 | ## Re-homing a host from another fleet | 322 | `create` prints the `AGENT_HOSTS` and `AGENT_EXTRA_FLAGS` lines for |
| 323 | `~/eitri-deploy/deploy.env`. **`make deploy` itself does not change** — only | ||
| 324 | which host it points at. | ||
| 325 | |||
| 326 | All four of these must pass before mewtwo is allowed to move: | ||
| 327 | |||
| 328 | 1. `devhost.sh create` from nothing, then `make deploy` → boot gate **PASS** | ||
| 329 | including `gate SSH: ok`, `exposed port: ok`, `remote MCP: ok`. A first pass | ||
| 330 | on a cold host also proves the agent bootstrap — cloud-hypervisor and | ||
| 331 | `CLOUDHV.fd` downloaded and sha-verified — works nested. | ||
| 332 | 2. A second `make deploy` on the warm host → PASS. Proves the agent-swap path | ||
| 333 | and VM re-adoption. | ||
| 334 | 3. `devhost.sh recycle`, then `make deploy` → PASS. Proves the recycle story is | ||
| 335 | real and not a one-time hand-built machine. | ||
| 336 | 4. `coverage/integration` is written, so the gate's by-product survives the move. | ||
| 337 | |||
| 338 | If a nested guest cannot boot at all, everything downstream stops. That is why | ||
| 339 | this goes first. | ||
| 340 | |||
| 341 | ## Runbook: moving mewtwo from the local plane to stg | ||
| 342 | |||
| 343 | Preconditions: the dev fleet's four proofs are green, and stg is up — console | ||
| 344 | reachable, sign-in works, `/mcp` refuses an unauthenticated caller, certificate | ||
| 345 | valid. The local plane stays running throughout, so rollback is always a join | ||
| 346 | away. | ||
| 347 | |||
| 348 | 1. **Drain.** List mewtwo's VMs through the local plane and delete every one. | ||
| 349 | Guests survive an agent swap, not a re-enrollment. Confirm nothing on mewtwo | ||
| 350 | is precious before starting. | ||
| 351 | 2. **Remove mewtwo from `AGENT_HOSTS` in `~/eitri-deploy/deploy.env`, in the | ||
| 352 | same sitting.** This is the step most likely to be forgotten and the most | ||
| 353 | damaging to forget: the next `make deploy` would scp a coverage-instrumented | ||
| 354 | branch binary over mewtwo's release binary, restart it, and quietly drag it | ||
| 355 | back toward the dev fleet. Do it before the join, not after. | ||
| 356 | 3. **Decommission from the local plane**: `DELETE /api/v1/hosts/{id}` with the | ||
| 357 | local operator PAT. | ||
| 358 | 4. **Wipe the plane identity.** On mewtwo: `sudo systemctl stop eitri-agent`, | ||
| 359 | then remove `/var/lib/eitri-agent/{identity.json,epoch,vms}`. Keep `images/` | ||
| 360 | — the image cache is plane-agnostic and saves a large download. Removing | ||
| 361 | `epoch` is what avoids the epoch fence violation described below. | ||
| 362 | 5. **Install the release binary, not a build.** Fetch | ||
| 363 | `eitri-server_<tag>_linux_amd64.tar.gz` from `https://dl.stg.eitri.sh/dl/<tag>/`, | ||
| 364 | verify it against `SHA256SUMS`, install. From here mewtwo runs release | ||
| 365 | artifacts — which is the coverage stg exists to provide. | ||
| 366 | 6. **Join stg.** Mint a join token in the stg console as the stg operator, run | ||
| 367 | `eitri-agent join <blob>` on mewtwo, `systemctl start eitri-agent`. Confirm | ||
| 368 | the host appears with the right architecture, uplink and guest subnet. | ||
| 369 | 7. **Prove it**: `scripts/ship.sh --target stg --tag <tag> --from 8`. A full | ||
| 370 | PASS including gate SSH, exposed port and remote MCP is the acceptance | ||
| 371 | criterion. | ||
| 372 | |||
| 373 | **Rollback**, at any point: re-mint a join token on the local plane, repeat step | ||
| 374 | 4's wipe, re-join, restore `AGENT_HOSTS`. The cost is one more wipe and whatever | ||
| 375 | guests exist at the time. | ||
| 376 | |||
| 377 | ## Runbook: the first pre-release run | ||
| 378 | |||
| 379 | 1. Land the pipeline on the version branch and get `make deploy` green on the | ||
| 380 | nested dev host — that is the gate for the pipeline code itself. | ||
| 381 | 2. Tag `v0.0.4-pre.1` **on the version branch** and push the tag. Pre-tags are | ||
| 382 | branch tags; main stays untouched until release. | ||
| 383 | 3. `scripts/ship.sh --target stg --tag v0.0.4-pre.1`. Expect stage 4 to fail the | ||
| 384 | first time — that is the mechanism working. The fix is a Secret patch plus a | ||
| 385 | `config.required` classification, both recorded. | ||
| 386 | 4. Migrate mewtwo (above) and re-run stage 8. | ||
| 387 | 5. **The proof this run exists to produce**: drive `vm_create` over | ||
| 388 | `https://stg.eitri.sh/mcp` — the proxied origin — and observe whether progress | ||
| 389 | tokens keep Cloudflare from 524-ing a ten-minute call. Record the answer in | ||
| 390 | `docs/assumptions.md` either way. A negative result is exactly as valuable | ||
| 391 | and changes what the release can claim. | ||
| 392 | 6. Iterate with `-pre.2`, `-pre.3` as findings land. **The second pre-tag | ||
| 393 | rehearses the agent upgrade**, which is the failure that defined the v0.0.3 | ||
| 394 | release day and the reason the version ordering understands pre-releases: | ||
| 395 | after `--target stg --tag v0.0.4-pre.2` has rolled, mewtwo is still on | ||
| 396 | `-pre.1` and the console must offer it the upgrade. Take it from the console, | ||
| 397 | confirm the agent comes back reporting `v0.0.4-pre.2` with its guests intact, | ||
| 398 | and that the artifact it fetched came from `dl.stg.eitri.sh`. An upgrade that | ||
| 399 | is not offered means the manifest, not the ordering — check | ||
| 400 | `release_manifest_url` and that the site rolled before the server. | ||
| 401 | 7. When stg is clean, tag the release on main and run | ||
| 402 | `scripts/ship.sh --target prod --tag v0.0.4` — same script, same stages, | ||
| 403 | nothing new attempted. The last pre-release upgrades to it like any other | ||
| 404 | version, because it orders below it. | ||
| 405 | |||
| 406 | ## Known gaps | ||
| 407 | |||
| 408 | **The CDN purge's first real execution is at promote time.** stg is served | ||
| 409 | cache-bypassed, so a purge there would invalidate nothing and make the prod step | ||
| 410 | look practiced when it is not. The mitigation is to keep the step small enough | ||
| 411 | that an unrehearsed run is safe: one `curl` with an explicit file list built | ||
| 412 | from `dist/<tag>`, no cache tags, no zone-wide purge. A zone-wide purge is the | ||
| 413 | tempting simplification and the wrong one — it would evict the whole site's | ||
| 414 | cache on every release. | ||
| 415 | |||
| 416 | **`gate` and `sync` do not inherit the wildcard usefully.** Every `*.eitri.sh` | ||
| 417 | name resolves to the home origin, where Traefik runs, but the control-plane pod | ||
| 418 | is `hostNetwork` on the cloud node — so its SSH gate and QUIC sync bind *that* | ||
| 419 | node's interfaces. The HTTP path works through the Service; the raw TCP and UDP | ||
| 420 | listeners do not. Both planes want explicit A records for `gate.*` and `sync.*` | ||
| 421 | pointing at the pod's node, and the matching ports opened there. Verify with | ||
| 422 | `nc -vz gate.eitri.sh 2222` from outside the LAN before trusting a gate leg. | ||
| 423 | |||
| 424 | **Nested cannot prove bridged networking or real-hardware quirks.** That | ||
| 425 | coverage moved from per-branch to per-pre-tag when mewtwo moved to stg — | ||
| 426 | deliberate, and the reason stg gates releases on real metal. | ||
| 427 | |||
| 428 | **stg's sign-in is not prod's.** stg runs the bundled issuer, so what a stg run | ||
| 429 | proves is the credential chain end to end against an issuer of ours — and what | ||
| 430 | it does not touch is prod's relying-party wiring against Google. Everything | ||
| 431 | after sign-in is identical. See "Sign-in, and the one place the planes genuinely | ||
| 432 | differ". | ||
| 433 | |||
| 434 | ## Re-homing a host between planes | ||
| 70 | 435 | ||
| 71 | A host that previously belonged to a different control plane carries that | 436 | A host that previously belonged to a different control plane carries that |
| 72 | fleet's snapshot epoch in its state dir and will refuse the new server's | 437 | plane's snapshot epoch in its state dir and will refuse the new server's |
| 73 | lower-numbered snapshots ("epoch fence violation" in the server log). | 438 | lower-numbered snapshots ("epoch fence violation" in the server log). After |
| 74 | After `eitri-agent join` against the new fleet: | 439 | `eitri-agent join` against the new plane: |
| 75 | 440 | ||
| 76 | sudo systemctl stop eitri-agent | 441 | sudo systemctl stop eitri-agent |
| 77 | sudo rm /var/lib/eitri-agent/epoch | 442 | sudo rm /var/lib/eitri-agent/epoch |
| 78 | sudo systemctl start eitri-agent | 443 | sudo systemctl start eitri-agent |
| 79 | 444 | ||
| 80 | VMs from the old fleet are absent from the new fleet's desired state and | 445 | VMs from the old plane are absent from the new plane's desired state and are |
| 81 | are reaped through the normal quarantine grace — re-home a host only when | 446 | reaped through the normal quarantine grace — re-home a host only when its |
| 82 | its existing guests are disposable. | 447 | existing guests are disposable. |
| 448 | |||
| 449 | ## Backups | ||
| 450 | |||
| 451 | The nightly CronJob writes dated sqlite backups onto prod's PVC; run | ||
| 452 | `backup-pull.sh` from cron on an off-cluster machine. local-path storage does | ||
| 453 | not survive the node, so the off-node copy is the DR story. stg has no CronJob: | ||
| 454 | its database is disposable by design. | ||
deploy/server/backup-cronjob.yaml
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,11 @@ | |||
| 1 | # Nightly dated sqlite backups onto the PVC. Applied only where BACKUPS=1: | ||
| 2 | # local-path storage does not survive the node, so the off-node copy pulled by | ||
| 3 | # backup-pull.sh is the real DR story and this is what it pulls. | ||
| 1 | apiVersion: batch/v1 | 4 | apiVersion: batch/v1 |
| 2 | kind: CronJob | 5 | kind: CronJob |
| 3 | metadata: | 6 | metadata: |
| 4 | name: eitri-server-backup | 7 | name: eitri-server-backup |
| 5 | namespace: eitri | 8 | namespace: ${NAMESPACE} |
| 6 | spec: | 9 | spec: |
| 7 | schedule: "20 3 * * *" | 10 | schedule: "20 3 * * *" |
| 8 | concurrencyPolicy: Forbid | 11 | concurrencyPolicy: Forbid |
| @@ -12,7 +15,7 @@ spec: | |||
| 12 | spec: | 15 | spec: |
| 13 | restartPolicy: Never | 16 | restartPolicy: Never |
| 14 | nodeSelector: | 17 | nodeSelector: |
| 15 | kubernetes.io/hostname: vnic-1 | 18 | kubernetes.io/hostname: ${NODE_NAME} |
| 16 | containers: | 19 | containers: |
| 17 | - name: backup | 20 | - name: backup |
| 18 | image: alpine:3.20 | 21 | image: alpine:3.20 |
| @@ -28,4 +31,4 @@ spec: | |||
| 28 | - {name: data, mountPath: /data} | 31 | - {name: data, mountPath: /data} |
| 29 | volumes: | 32 | volumes: |
| 30 | - name: data | 33 | - name: data |
| 31 | persistentVolumeClaim: {claimName: eitri-server-data} | 34 | persistentVolumeClaim: {claimName: ${PVC_NAME}} |
deploy/server/certificate.yaml
| Old | New | ||
|---|---|---|---|
| @@ -1,11 +1,18 @@ | |||
| 1 | # The console and the MCP host share one certificate. The gate and sync names | ||
| 2 | # are deliberately absent: SSH and QUIC carry their own pinned credentials and | ||
| 3 | # never present a web certificate. | ||
| 4 | # | ||
| 5 | # The issuer solves DNS-01 through Cloudflare, so this issues before any route | ||
| 6 | # answers and regardless of whether the origin is reachable — which is why the | ||
| 7 | # certificate applies in the same pass as everything else rather than after it. | ||
| 1 | apiVersion: cert-manager.io/v1 | 8 | apiVersion: cert-manager.io/v1 |
| 2 | kind: Certificate | 9 | kind: Certificate |
| 3 | metadata: | 10 | metadata: |
| 4 | name: console-tls | 11 | name: ${TLS_SECRET} |
| 5 | namespace: eitri | 12 | namespace: ${NAMESPACE} |
| 6 | spec: | 13 | spec: |
| 7 | secretName: console-tls | 14 | secretName: ${TLS_SECRET} |
| 8 | dnsNames: [console.eitri.sh, api.eitri.sh] | 15 | dnsNames: [${CONSOLE_HOST}, ${API_HOST}] |
| 9 | issuerRef: | 16 | issuerRef: |
| 10 | name: letsencrypt-prod | 17 | name: letsencrypt-prod |
| 11 | kind: ClusterIssuer | 18 | kind: ClusterIssuer |
deploy/server/config.required
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,70 @@ | |||
| 1 | # The server config contract: every key the tagged tree's config schema declares, | ||
| 2 | # classified. scripts/ship.sh reads the schema out of | ||
| 3 | # internal/server/config/config.go on the tree being shipped, compares it against | ||
| 4 | # this file, and refuses to deploy a plane whose Secret and whose binary disagree. | ||
| 5 | # | ||
| 6 | # required the plane's server.json must set it to a non-empty value | ||
| 7 | # optional the server has a working default, or the key is a deliberate opt-in | ||
| 8 | # retired the field survives only so the server can spot it in an old config | ||
| 9 | # and say what to write instead; a plane still setting it gets a warning | ||
| 10 | # | ||
| 11 | # A key the tree declares and this file does not classify is a hard failure, on | ||
| 12 | # purpose: that is exactly the shape of the v0.0.3 release-day incident, where the | ||
| 13 | # struct grew default_images and every plane's Secret silently lacked it. The | ||
| 14 | # schema and its contract now move together or not at all. | ||
| 15 | # | ||
| 16 | # Nested structs reach this file through their JSON path, declared here so a new | ||
| 17 | # one cannot slip in unclassified — the extractor fails on a struct with no | ||
| 18 | # prefix. A "*" segment stands for a map key: the rule holds for every entry. | ||
| 19 | # | ||
| 20 | # struct-prefix: Config= | ||
| 21 | # struct-prefix: OIDC=oidc. | ||
| 22 | # struct-prefix: DefaultImage=default_images.*. | ||
| 23 | |||
| 24 | db_path required | ||
| 25 | http_listen required | ||
| 26 | quic_listen required | ||
| 27 | host_secret required | ||
| 28 | # Seals every piece of key material the plane holds — the host CA and gate host | ||
| 29 | # key on the PVC, and each tenant's managed SSH CA in the database — so the | ||
| 30 | # volume, the database and its backups hold ciphertext. 64 hex chars, minted with | ||
| 31 | # `openssl rand -hex 32`, and never rotated in place: a plane that loses it loses | ||
| 32 | # its host CA, and with it the identity every client pins and every VM's host | ||
| 33 | # certificate names. This Secret is that key's only home; keep the operator's | ||
| 34 | # copy of server.json. | ||
| 35 | key_encryption_key required | ||
| 36 | cidr_pool required | ||
| 37 | advertise_http required | ||
| 38 | advertise_quic required | ||
| 39 | ssh_listen required | ||
| 40 | ssh_gate_domain required | ||
| 41 | ssh_ca_key required | ||
| 42 | ssh_host_key required | ||
| 43 | |||
| 44 | # One image per host architecture. A plane serving both arm64 and amd64 hosts | ||
| 45 | # needs both entries: a host handed an image it cannot execute fails at boot, | ||
| 46 | # which is the mistake this map exists to prevent. | ||
| 47 | default_images required | ||
| 48 | default_images.*.url required | ||
| 49 | default_images.*.sha256 required | ||
| 50 | |||
| 51 | oidc required | ||
| 52 | oidc.issuer required | ||
| 53 | oidc.client_id required | ||
| 54 | oidc.public_url required | ||
| 55 | # Confidential clients only — the bundled loopback issuer is public. | ||
| 56 | oidc.client_secret optional | ||
| 57 | # The signup gate. Empty means anyone the issuer authenticates gets a tenant, so | ||
| 58 | # a hosted plane that is not open for signups sets one of these. | ||
| 59 | oidc.allowed_domains optional | ||
| 60 | oidc.allowed_identities optional | ||
| 61 | |||
| 62 | # Absent means the built-in default (eitri.sh); an explicit empty string | ||
| 63 | # disables release discovery and every upgrade surface with it. | ||
| 64 | release_manifest_url optional | ||
| 65 | credential_max_age optional | ||
| 66 | audit_retention optional | ||
| 67 | |||
| 68 | admin_token retired | ||
| 69 | default_image_url retired | ||
| 70 | default_image_sha256 retired | ||
deploy/server/deployment.yaml
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,7 @@ apiVersion: apps/v1 | |||
| 2 | kind: Deployment | 2 | kind: Deployment |
| 3 | metadata: | 3 | metadata: |
| 4 | name: eitri-server | 4 | name: eitri-server |
| 5 | namespace: eitri | 5 | namespace: ${NAMESPACE} |
| 6 | spec: | 6 | spec: |
| 7 | replicas: 1 | 7 | replicas: 1 |
| 8 | strategy: | 8 | strategy: |
| @@ -13,17 +13,17 @@ spec: | |||
| 13 | metadata: | 13 | metadata: |
| 14 | labels: {app: eitri-server} | 14 | labels: {app: eitri-server} |
| 15 | spec: | 15 | spec: |
| 16 | # vnic-1 is load-bearing twice over: the only node with a public IP | 16 | # The node is load-bearing twice over: the only one with a public IP |
| 17 | # (sync/gate listeners below), and local-path binds the PVC to the node | 17 | # (sync/gate listeners below), and local-path binds the PVC to the node |
| 18 | # the pod first lands on. | 18 | # the pod first lands on. |
| 19 | nodeSelector: | 19 | nodeSelector: |
| 20 | kubernetes.io/hostname: vnic-1 | 20 | kubernetes.io/hostname: ${NODE_NAME} |
| 21 | # hostNetwork: the QUIC sync socket (8443/udp) and SSH gate (2222/tcp) | 21 | # hostNetwork: the QUIC sync socket and SSH gate bind the node's |
| 22 | # bind the node's interfaces directly. Both flows carry their own pinned | 22 | # interfaces directly. Both flows carry their own pinned cryptography |
| 23 | # cryptography end-to-end and tolerate no middlebox — that includes the | 23 | # end-to-end and tolerate no middlebox — that includes the CNI's own |
| 24 | # CNI's own hostPort NAT, which conntrack-drops long-lived single-tuple | 24 | # hostPort NAT, which conntrack-drops long-lived single-tuple UDP flows. |
| 25 | # UDP flows. The console listens on :8081 because svclb already claims | 25 | # It also means these ports are the NODE's: two planes on one node need |
| 26 | # host 8080. | 26 | # two port triples, which is what the plane env files carry. |
| 27 | hostNetwork: true | 27 | hostNetwork: true |
| 28 | dnsPolicy: ClusterFirstWithHostNet | 28 | dnsPolicy: ClusterFirstWithHostNet |
| 29 | imagePullSecrets: | 29 | imagePullSecrets: |
| @@ -32,28 +32,29 @@ spec: | |||
| 32 | fsGroup: 65532 # distroless nonroot; PVC files must be writable | 32 | fsGroup: 65532 # distroless nonroot; PVC files must be writable |
| 33 | containers: | 33 | containers: |
| 34 | - name: eitri-server | 34 | - name: eitri-server |
| 35 | image: REGISTRY/eitri-server:VERSION # set at rollout: kubectl -n eitri set image ... | 35 | # By TAG, never by digest: a podman-pushed digest differs from the |
| 36 | # Always: a re-cut release reuses its version tag, and podman-pushed | 36 | # registry's, and rolling by one has taken the fleet down overnight. |
| 37 | # digests differ from the registry's — the tag plus a forced pull is | 37 | image: ${SERVER_IMAGE}:${TAG} |
| 38 | # the one rollout shape that never serves a stale image. | 38 | # Always: a re-cut release reuses its version tag, and the tag plus a |
| 39 | # forced pull is the one rollout shape that never serves a stale image. | ||
| 39 | imagePullPolicy: Always | 40 | imagePullPolicy: Always |
| 40 | ports: | 41 | ports: |
| 41 | - {name: http, containerPort: 8081} | 42 | - {name: http, containerPort: ${HTTP_PORT}} |
| 42 | - {name: sync, containerPort: 8443, protocol: UDP} | 43 | - {name: sync, containerPort: ${SYNC_PORT}, protocol: UDP} |
| 43 | - {name: gate, containerPort: 2222, protocol: TCP} | 44 | - {name: gate, containerPort: ${GATE_PORT}, protocol: TCP} |
| 44 | volumeMounts: | 45 | volumeMounts: |
| 45 | - {name: data, mountPath: /var/lib/eitri} | 46 | - {name: data, mountPath: /var/lib/eitri} |
| 46 | - {name: config, mountPath: /etc/eitri, readOnly: true} | 47 | - {name: config, mountPath: /etc/eitri, readOnly: true} |
| 47 | livenessProbe: | 48 | livenessProbe: |
| 48 | httpGet: {path: /livez, port: 8081} | 49 | httpGet: {path: /livez, port: ${HTTP_PORT}} |
| 49 | periodSeconds: 10 | 50 | periodSeconds: 10 |
| 50 | readinessProbe: | 51 | readinessProbe: |
| 51 | httpGet: {path: /readyz, port: 8081} | 52 | httpGet: {path: /readyz, port: ${HTTP_PORT}} |
| 52 | periodSeconds: 10 | 53 | periodSeconds: 10 |
| 53 | volumes: | 54 | volumes: |
| 54 | - name: data | 55 | - name: data |
| 55 | persistentVolumeClaim: {claimName: eitri-server-data} | 56 | persistentVolumeClaim: {claimName: ${PVC_NAME}} |
| 56 | - name: config | 57 | - name: config |
| 57 | secret: | 58 | secret: |
| 58 | secretName: eitri-server-config | 59 | secretName: ${CONFIG_SECRET} |
| 59 | defaultMode: 0400 | 60 | defaultMode: 0400 |
deploy/server/ingressroute.yaml
| Old | New | ||
|---|---|---|---|
| @@ -2,38 +2,41 @@ apiVersion: traefik.io/v1alpha1 | |||
| 2 | kind: IngressRoute | 2 | kind: IngressRoute |
| 3 | metadata: | 3 | metadata: |
| 4 | name: console-https | 4 | name: console-https |
| 5 | namespace: eitri | 5 | namespace: ${NAMESPACE} |
| 6 | spec: | 6 | spec: |
| 7 | entryPoints: [websecure] | 7 | entryPoints: [websecure] |
| 8 | routes: | 8 | routes: |
| 9 | - match: Host(`console.eitri.sh`) | 9 | # The console host rule carries every path, so /mcp answers here too — and |
| 10 | # this is the origin a proxy fronts. The API host below is not the only way | ||
| 11 | # in; it is the way in that doesn't also route the console. | ||
| 12 | - match: Host(`${CONSOLE_HOST}`) | ||
| 10 | kind: Rule | 13 | kind: Rule |
| 11 | services: | 14 | services: |
| 12 | - {name: eitri-server, port: 8080} | 15 | - {name: eitri-server, port: 8080} |
| 13 | # api.eitri.sh carries ONLY /mcp: routing the whole host would make it a | 16 | # The API host carries ONLY /mcp: routing the whole host would make it a |
| 14 | # second console origin, and OIDC sign-in works at exactly oidc.public_url. | 17 | # second console origin, and OIDC sign-in works at exactly oidc.public_url. |
| 15 | - match: Host(`api.eitri.sh`) && PathPrefix(`/mcp`) | 18 | - match: Host(`${API_HOST}`) && PathPrefix(`/mcp`) |
| 16 | kind: Rule | 19 | kind: Rule |
| 17 | services: | 20 | services: |
| 18 | - {name: eitri-server, port: 8080} | 21 | - {name: eitri-server, port: 8080} |
| 19 | tls: | 22 | tls: |
| 20 | secretName: console-tls | 23 | secretName: ${TLS_SECRET} |
| 21 | --- | 24 | --- |
| 22 | apiVersion: traefik.io/v1alpha1 | 25 | apiVersion: traefik.io/v1alpha1 |
| 23 | kind: IngressRoute | 26 | kind: IngressRoute |
| 24 | metadata: | 27 | metadata: |
| 25 | name: console-http | 28 | name: console-http |
| 26 | namespace: eitri | 29 | namespace: ${NAMESPACE} |
| 27 | spec: | 30 | spec: |
| 28 | entryPoints: [web] | 31 | entryPoints: [web] |
| 29 | routes: | 32 | routes: |
| 30 | - match: Host(`console.eitri.sh`) | 33 | - match: Host(`${CONSOLE_HOST}`) |
| 31 | kind: Rule | 34 | kind: Rule |
| 32 | middlewares: | 35 | middlewares: |
| 33 | - name: redirect-https | 36 | - name: redirect-https |
| 34 | services: | 37 | services: |
| 35 | - {name: eitri-server, port: 8080} | 38 | - {name: eitri-server, port: 8080} |
| 36 | - match: Host(`api.eitri.sh`) && PathPrefix(`/mcp`) | 39 | - match: Host(`${API_HOST}`) && PathPrefix(`/mcp`) |
| 37 | kind: Rule | 40 | kind: Rule |
| 38 | middlewares: | 41 | middlewares: |
| 39 | - name: redirect-https | 42 | - name: redirect-https |
deploy/server/middleware.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,12 @@ | |||
| 1 | # Every plain-HTTP route sends the caller back over TLS. Namespace-scoped, so | ||
| 2 | # each plane carries its own copy under the same name and the IngressRoutes | ||
| 3 | # reference it without qualification. | ||
| 4 | apiVersion: traefik.io/v1alpha1 | ||
| 5 | kind: Middleware | ||
| 6 | metadata: | ||
| 7 | name: redirect-https | ||
| 8 | namespace: ${NAMESPACE} | ||
| 9 | spec: | ||
| 10 | redirectScheme: | ||
| 11 | scheme: https | ||
| 12 | permanent: true | ||
deploy/server/namespace.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,4 @@ | |||
| 1 | apiVersion: v1 | ||
| 2 | kind: Namespace | ||
| 3 | metadata: | ||
| 4 | name: ${NAMESPACE} | ||
deploy/server/oidc-deployment.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,50 @@ | |||
| 1 | # The bundled issuer runs from the SERVER's image, at the server's tag, as its | ||
| 2 | # own Deployment — so it cannot be a different version than the server it | ||
| 3 | # authenticates for, and it rolls in the same stage. | ||
| 4 | # | ||
| 5 | # Not hostNetwork, unlike the control plane: nothing here needs a raw socket on | ||
| 6 | # the node, so it reaches the world through the Service and leaves the node's | ||
| 7 | # port space to the three listeners that genuinely cannot use it. | ||
| 8 | apiVersion: apps/v1 | ||
| 9 | kind: Deployment | ||
| 10 | metadata: | ||
| 11 | name: eitri-oidc | ||
| 12 | namespace: ${NAMESPACE} | ||
| 13 | spec: | ||
| 14 | replicas: 1 | ||
| 15 | strategy: | ||
| 16 | type: Recreate # single writer for the flat user file | ||
| 17 | selector: | ||
| 18 | matchLabels: {app: eitri-oidc} | ||
| 19 | template: | ||
| 20 | metadata: | ||
| 21 | labels: {app: eitri-oidc} | ||
| 22 | spec: | ||
| 23 | # local-path binds the PVC to the node the pod first lands on, and the | ||
| 24 | # issuer's state must land on the same node as the rest of the plane's. | ||
| 25 | nodeSelector: | ||
| 26 | kubernetes.io/hostname: ${NODE_NAME} | ||
| 27 | imagePullSecrets: | ||
| 28 | - name: regcred | ||
| 29 | securityContext: | ||
| 30 | fsGroup: 65532 # distroless nonroot; the user file must be writable | ||
| 31 | containers: | ||
| 32 | - name: eitri-oidc | ||
| 33 | image: ${SERVER_IMAGE}:${TAG} | ||
| 34 | imagePullPolicy: Always | ||
| 35 | command: ["/eitri-oidc", "-config", "/etc/eitri/eitri-oidc.json"] | ||
| 36 | ports: | ||
| 37 | - {name: http, containerPort: ${OIDC_PORT}} | ||
| 38 | volumeMounts: | ||
| 39 | - {name: data, mountPath: /var/lib/eitri-oidc} | ||
| 40 | - {name: config, mountPath: /etc/eitri, readOnly: true} | ||
| 41 | readinessProbe: | ||
| 42 | httpGet: {path: /.well-known/openid-configuration, port: ${OIDC_PORT}} | ||
| 43 | periodSeconds: 10 | ||
| 44 | volumes: | ||
| 45 | - name: data | ||
| 46 | persistentVolumeClaim: {claimName: ${OIDC_PVC_NAME}} | ||
| 47 | - name: config | ||
| 48 | secret: | ||
| 49 | secretName: ${OIDC_CONFIG_SECRET} | ||
| 50 | defaultMode: 0400 | ||
deploy/server/oidc.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,75 @@ | |||
| 1 | # The bundled issuer, for a plane with no external identity provider. Applied | ||
| 2 | # only where LOCAL_OIDC=1 — prod signs in against Google and never starts it. | ||
| 3 | # | ||
| 4 | # It gets its own hostname rather than a path on the console's, because the | ||
| 5 | # login form posts back to the absolute path /authorize: mounted under a prefix, | ||
| 6 | # the browser's POST would land on the console host's catch-all rule instead of | ||
| 7 | # the issuer. An issuer only works at the root of its own origin. | ||
| 8 | # | ||
| 9 | # Its state is NOT disposable. users.json holds each identity's subject, and the | ||
| 10 | # server derives a tenant from that subject — lose the file and the operator | ||
| 11 | # signs in to a brand-new tenant while the old one still owns the plane's hosts, | ||
| 12 | # with no error anywhere to say so. Hence a PVC, pinned like the server's. | ||
| 13 | apiVersion: v1 | ||
| 14 | kind: PersistentVolumeClaim | ||
| 15 | metadata: | ||
| 16 | name: ${OIDC_PVC_NAME} | ||
| 17 | namespace: ${NAMESPACE} | ||
| 18 | spec: | ||
| 19 | accessModes: [ReadWriteOnce] | ||
| 20 | storageClassName: local-path | ||
| 21 | resources: | ||
| 22 | requests: | ||
| 23 | storage: ${OIDC_PVC_SIZE} | ||
| 24 | --- | ||
| 25 | apiVersion: v1 | ||
| 26 | kind: Service | ||
| 27 | metadata: | ||
| 28 | name: eitri-oidc | ||
| 29 | namespace: ${NAMESPACE} | ||
| 30 | spec: | ||
| 31 | selector: {app: eitri-oidc} | ||
| 32 | ports: | ||
| 33 | - {name: http, port: ${OIDC_PORT}, targetPort: ${OIDC_PORT}} | ||
| 34 | --- | ||
| 35 | apiVersion: cert-manager.io/v1 | ||
| 36 | kind: Certificate | ||
| 37 | metadata: | ||
| 38 | name: ${OIDC_TLS_SECRET} | ||
| 39 | namespace: ${NAMESPACE} | ||
| 40 | spec: | ||
| 41 | secretName: ${OIDC_TLS_SECRET} | ||
| 42 | dnsNames: [${OIDC_HOST}] | ||
| 43 | issuerRef: | ||
| 44 | name: letsencrypt-prod | ||
| 45 | kind: ClusterIssuer | ||
| 46 | --- | ||
| 47 | apiVersion: traefik.io/v1alpha1 | ||
| 48 | kind: IngressRoute | ||
| 49 | metadata: | ||
| 50 | name: oidc-https | ||
| 51 | namespace: ${NAMESPACE} | ||
| 52 | spec: | ||
| 53 | entryPoints: [websecure] | ||
| 54 | routes: | ||
| 55 | - match: Host(`${OIDC_HOST}`) | ||
| 56 | kind: Rule | ||
| 57 | services: | ||
| 58 | - {name: eitri-oidc, port: ${OIDC_PORT}} | ||
| 59 | tls: | ||
| 60 | secretName: ${OIDC_TLS_SECRET} | ||
| 61 | --- | ||
| 62 | apiVersion: traefik.io/v1alpha1 | ||
| 63 | kind: IngressRoute | ||
| 64 | metadata: | ||
| 65 | name: oidc-http | ||
| 66 | namespace: ${NAMESPACE} | ||
| 67 | spec: | ||
| 68 | entryPoints: [web] | ||
| 69 | routes: | ||
| 70 | - match: Host(`${OIDC_HOST}`) | ||
| 71 | kind: Rule | ||
| 72 | middlewares: | ||
| 73 | - name: redirect-https | ||
| 74 | services: | ||
| 75 | - {name: eitri-oidc, port: ${OIDC_PORT}} | ||
deploy/server/plane.prod.env
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,54 @@ | |||
| 1 | # prod plane values — eitri.sh. | ||
| 2 | # | ||
| 3 | # One template set renders both planes (see README.md), so stg and prod cannot | ||
| 4 | # have different SHAPES, only different values. Every key here is required: | ||
| 5 | # scripts/ship.sh refuses to render with any of them empty rather than letting | ||
| 6 | # envsubst quietly substitute nothing. | ||
| 7 | # | ||
| 8 | # Nothing here is a secret. Config values that are (client secrets, the host | ||
| 9 | # secret, the OIDC credentials) live only in the plane's Secret, which the | ||
| 10 | # pipeline reads and validates but never writes. | ||
| 11 | |||
| 12 | NAMESPACE=eitri | ||
| 13 | |||
| 14 | # The node the control plane is pinned to. Load-bearing twice over: the only | ||
| 15 | # node with a public IP (the sync and gate listeners bind its interfaces | ||
| 16 | # directly), and local-path storage binds the PVC to whichever node the pod | ||
| 17 | # first lands on. | ||
| 18 | NODE_NAME=vnic-1 | ||
| 19 | # The architecture both images are built for (scripts/server-image.sh is arm64 | ||
| 20 | # by construction; the site image follows SITE_PLATFORM). | ||
| 21 | IMAGE_ARCH=arm64 | ||
| 22 | |||
| 23 | CONSOLE_HOST=console.eitri.sh | ||
| 24 | API_HOST=api.eitri.sh | ||
| 25 | GATE_HOST=gate.eitri.sh | ||
| 26 | SYNC_HOST=sync.eitri.sh | ||
| 27 | SITE_HOST=eitri.sh | ||
| 28 | |||
| 29 | # Host ports. hostNetwork means these are the NODE's ports, so the two planes | ||
| 30 | # cannot share them; every value here must equal the matching listener in the | ||
| 31 | # plane's server.json, which ship.sh asserts rather than trusts. | ||
| 32 | HTTP_PORT=8081 | ||
| 33 | SYNC_PORT=8443 | ||
| 34 | GATE_PORT=2222 | ||
| 35 | |||
| 36 | # prod signs in against Google, so the bundled issuer is not deployed and none | ||
| 37 | # of its values apply. The manifests that describe it are skipped wholesale. | ||
| 38 | LOCAL_OIDC=0 | ||
| 39 | |||
| 40 | CONFIG_SECRET=eitri-server-config | ||
| 41 | PVC_NAME=eitri-server-data | ||
| 42 | PVC_SIZE=5Gi | ||
| 43 | TLS_SECRET=console-tls | ||
| 44 | # The certificate already serving eitri.sh. These manifests adopt the live site | ||
| 45 | # objects (Deployment/Service `web`, IngressRoutes `web-http`/`web-https`, | ||
| 46 | # Certificate `web-tls`), so naming it anything else would mint a second | ||
| 47 | # certificate and swap the one in use. | ||
| 48 | SITE_TLS_SECRET=web-tls | ||
| 49 | |||
| 50 | # Nightly sqlite backup CronJob: prod's database is the one nobody can rebuild. | ||
| 51 | BACKUPS=1 | ||
| 52 | # The CDN purge runs at prod and only at prod — stg is served cache-bypassed, | ||
| 53 | # so there is nothing there to invalidate. | ||
| 54 | CDN_PURGE=1 | ||
deploy/server/plane.stg.env
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,55 @@ | |||
| 1 | # stg plane values — stg.eitri.sh. | ||
| 2 | # | ||
| 3 | # The same keys as plane.prod.env, because the same templates render both. Where | ||
| 4 | # a value differs, it differs because the two planes share a cluster and a node | ||
| 5 | # — not because stg is a smaller product. | ||
| 6 | |||
| 7 | NAMESPACE=eitri-stg | ||
| 8 | |||
| 9 | NODE_NAME=vnic-1 | ||
| 10 | IMAGE_ARCH=arm64 | ||
| 11 | |||
| 12 | CONSOLE_HOST=stg.eitri.sh | ||
| 13 | API_HOST=api.stg.eitri.sh | ||
| 14 | GATE_HOST=gate.stg.eitri.sh | ||
| 15 | SYNC_HOST=sync.stg.eitri.sh | ||
| 16 | # stg publishes its own /dl rather than borrowing prod's: scripts/site-image.sh | ||
| 17 | # stages exactly one version into site/dist/dl, so pushing a pre-release to | ||
| 18 | # eitri.sh would 404 every released download URL in the wild. Its own origin | ||
| 19 | # also means the release artifacts, the manifest shape and the cache headers | ||
| 20 | # are rehearsed by the same script that will later do it to prod. | ||
| 21 | SITE_HOST=dl.stg.eitri.sh | ||
| 22 | |||
| 23 | # stg shares vnic-1 with prod, so it cannot share prod's host ports: a second | ||
| 24 | # hostNetwork pod claiming 8081/8443/2222 would fail to schedule or silently | ||
| 25 | # lose its listeners. | ||
| 26 | HTTP_PORT=8082 | ||
| 27 | SYNC_PORT=8444 | ||
| 28 | GATE_PORT=2223 | ||
| 29 | |||
| 30 | # stg signs in against the bundled eitri-oidc rather than Google: the plane is | ||
| 31 | # then reproducible from nothing but this repo and a Secret, its identities are | ||
| 32 | # ours to create and delete, and the smoke can drive a real sign-in headlessly. | ||
| 33 | # What it gives up is stated in the README — prod's relying-party wiring against | ||
| 34 | # a third-party IdP is the one part of sign-in stg does not exercise. | ||
| 35 | LOCAL_OIDC=1 | ||
| 36 | OIDC_HOST=oidc.stg.eitri.sh | ||
| 37 | # Not a host port: the issuer is an ordinary pod behind a Service. | ||
| 38 | OIDC_PORT=9111 | ||
| 39 | OIDC_TLS_SECRET=stg-oidc-tls | ||
| 40 | OIDC_CONFIG_SECRET=eitri-stg-oidc-config | ||
| 41 | OIDC_PVC_NAME=eitri-stg-oidc-data | ||
| 42 | OIDC_PVC_SIZE=1Gi | ||
| 43 | |||
| 44 | CONFIG_SECRET=eitri-stg-server-config | ||
| 45 | PVC_NAME=eitri-stg-server-data | ||
| 46 | PVC_SIZE=2Gi | ||
| 47 | TLS_SECRET=stg-console-tls | ||
| 48 | SITE_TLS_SECRET=stg-web-tls | ||
| 49 | |||
| 50 | # No backups: stg's database is disposable by design, and a second nightly | ||
| 51 | # sqlite job against local-path storage on the same node buys nothing. | ||
| 52 | BACKUPS=0 | ||
| 53 | # No purge: stg is served cache-bypassed, so a purge here would be theatre that | ||
| 54 | # made the prod step look rehearsed when it is not. See README, "Known gaps". | ||
| 55 | CDN_PURGE=0 | ||
deploy/server/pvc.yaml
| Old | New | ||
|---|---|---|---|
| @@ -1,11 +1,11 @@ | |||
| 1 | apiVersion: v1 | 1 | apiVersion: v1 |
| 2 | kind: PersistentVolumeClaim | 2 | kind: PersistentVolumeClaim |
| 3 | metadata: | 3 | metadata: |
| 4 | name: eitri-server-data | 4 | name: ${PVC_NAME} |
| 5 | namespace: eitri | 5 | namespace: ${NAMESPACE} |
| 6 | spec: | 6 | spec: |
| 7 | accessModes: [ReadWriteOnce] | 7 | accessModes: [ReadWriteOnce] |
| 8 | storageClassName: local-path | 8 | storageClassName: local-path |
| 9 | resources: | 9 | resources: |
| 10 | requests: | 10 | requests: |
| 11 | storage: 5Gi | 11 | storage: ${PVC_SIZE} |
deploy/server/service.yaml
| Old | New | ||
|---|---|---|---|
| @@ -1,9 +1,11 @@ | |||
| 1 | # Cluster-internal, so the published port is the same on both planes and only | ||
| 2 | # the target follows the plane's HTTP listener. | ||
| 1 | apiVersion: v1 | 3 | apiVersion: v1 |
| 2 | kind: Service | 4 | kind: Service |
| 3 | metadata: | 5 | metadata: |
| 4 | name: eitri-server | 6 | name: eitri-server |
| 5 | namespace: eitri | 7 | namespace: ${NAMESPACE} |
| 6 | spec: | 8 | spec: |
| 7 | selector: {app: eitri-server} | 9 | selector: {app: eitri-server} |
| 8 | ports: | 10 | ports: |
| 9 | - {name: http, port: 8080, targetPort: 8081} | 11 | - {name: http, port: 8080, targetPort: ${HTTP_PORT}} |
deploy/server/site-certificate.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,11 @@ | |||
| 1 | apiVersion: cert-manager.io/v1 | ||
| 2 | kind: Certificate | ||
| 3 | metadata: | ||
| 4 | name: ${SITE_TLS_SECRET} | ||
| 5 | namespace: ${NAMESPACE} | ||
| 6 | spec: | ||
| 7 | secretName: ${SITE_TLS_SECRET} | ||
| 8 | dnsNames: [${SITE_HOST}] | ||
| 9 | issuerRef: | ||
| 10 | name: letsencrypt-prod | ||
| 11 | kind: ClusterIssuer | ||
deploy/server/site-deployment.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,36 @@ | |||
| 1 | # The static site: nginx over the built webroot, with the release artifacts | ||
| 2 | # baked in under /dl (scripts/site-image.sh stages dist/<version> into the image | ||
| 3 | # — there is no volume and nothing to upload afterwards). | ||
| 4 | # | ||
| 5 | # The site rolls BEFORE the server on every run. A server started ahead of its | ||
| 6 | # site fetches the boot manifest, finds the previous release, and pins that | ||
| 7 | # answer for 24 hours. | ||
| 8 | apiVersion: apps/v1 | ||
| 9 | kind: Deployment | ||
| 10 | metadata: | ||
| 11 | name: web | ||
| 12 | namespace: ${NAMESPACE} | ||
| 13 | spec: | ||
| 14 | replicas: 1 | ||
| 15 | selector: | ||
| 16 | matchLabels: {app: web} | ||
| 17 | template: | ||
| 18 | metadata: | ||
| 19 | labels: {app: web} | ||
| 20 | spec: | ||
| 21 | # The image is single-arch, so say which arch rather than discovering it | ||
| 22 | # as a scheduling failure on a mixed cluster. | ||
| 23 | nodeSelector: | ||
| 24 | kubernetes.io/arch: ${IMAGE_ARCH} | ||
| 25 | imagePullSecrets: | ||
| 26 | - name: regcred | ||
| 27 | containers: | ||
| 28 | - name: web | ||
| 29 | # By tag, like the server: see the note in deployment.yaml. | ||
| 30 | image: ${SITE_IMAGE}:${TAG} | ||
| 31 | imagePullPolicy: Always | ||
| 32 | ports: | ||
| 33 | - {name: http, containerPort: 8080} | ||
| 34 | readinessProbe: | ||
| 35 | httpGet: {path: /, port: 8080} | ||
| 36 | periodSeconds: 10 | ||
deploy/server/site-ingressroute.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,32 @@ | |||
| 1 | # Named for the deployment they route to, matching the objects already serving | ||
| 2 | # eitri.sh — these manifests adopt the live site rather than standing a second | ||
| 3 | # one beside it. | ||
| 4 | apiVersion: traefik.io/v1alpha1 | ||
| 5 | kind: IngressRoute | ||
| 6 | metadata: | ||
| 7 | name: web-https | ||
| 8 | namespace: ${NAMESPACE} | ||
| 9 | spec: | ||
| 10 | entryPoints: [websecure] | ||
| 11 | routes: | ||
| 12 | - match: Host(`${SITE_HOST}`) | ||
| 13 | kind: Rule | ||
| 14 | services: | ||
| 15 | - {name: web, port: 8080} | ||
| 16 | tls: | ||
| 17 | secretName: ${SITE_TLS_SECRET} | ||
| 18 | --- | ||
| 19 | apiVersion: traefik.io/v1alpha1 | ||
| 20 | kind: IngressRoute | ||
| 21 | metadata: | ||
| 22 | name: web-http | ||
| 23 | namespace: ${NAMESPACE} | ||
| 24 | spec: | ||
| 25 | entryPoints: [web] | ||
| 26 | routes: | ||
| 27 | - match: Host(`${SITE_HOST}`) | ||
| 28 | kind: Rule | ||
| 29 | middlewares: | ||
| 30 | - name: redirect-https | ||
| 31 | services: | ||
| 32 | - {name: web, port: 8080} | ||
deploy/server/site-service.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,9 @@ | |||
| 1 | apiVersion: v1 | ||
| 2 | kind: Service | ||
| 3 | metadata: | ||
| 4 | name: web | ||
| 5 | namespace: ${NAMESPACE} | ||
| 6 | spec: | ||
| 7 | selector: {app: web} | ||
| 8 | ports: | ||
| 9 | - {name: http, port: 8080, targetPort: 8080} | ||
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -419,6 +419,36 @@ Secret is the key's only home. | |||
| 419 | that will not open stops the server rather than being regenerated—a fresh host | 419 | that will not open stops the server rather than being regenerated—a fresh host |
| 420 | CA would invalidate every client's pin and every VM's host certificate at once. | 420 | CA would invalidate every client's pin and every VM's host certificate at once. |
| 421 | 421 | ||
| 422 | ### A staging plane on the same cluster catches hosted-shape failures | ||
| 423 | |||
| 424 | stg runs from the same manifests as prod, differing only in values, so a defect | ||
| 425 | in the deployed shape—a config key the Secret lacks, a listener that disagrees | ||
| 426 | with its advertisement, a route that does not carry /mcp—fails at stg first. | ||
| 427 | Underpins promoting to prod by re-running one script rather than by inspection. | ||
| 428 | **Unproven**: no pre-release has been shipped through it yet. One difference is | ||
| 429 | deliberate and known: stg signs in against the bundled issuer, so prod's | ||
| 430 | relying-party wiring against a third-party provider is proven only at prod. | ||
| 431 | |||
| 432 | ### A pre-release orders below the release it leads to | ||
| 433 | |||
| 434 | v0.0.4-pre.1 < v0.0.4-pre.2 < v0.0.4, and builds derived from either sit | ||
| 435 | between them, so an agent walks a release cycle's tags the way it reads. | ||
| 436 | Underpins a staging plane rehearsing the agent-upgrade path on the very tags | ||
| 437 | that cycle produces—the failure that defined the v0.0.3 release day. | ||
| 438 | **Proven** in code, by the ordering the fleet itself uses: the full chain is | ||
| 439 | pinned in a test, and everything else—dev, a dirty tree, rc.1, a malformed | ||
| 440 | tag—stays unordered and is never offered an upgrade. | ||
| 441 | |||
| 442 | ### Nested KVM is enough to gate a branch | ||
| 443 | |||
| 444 | A guest of a workstation VM boots under cloud-hypervisor and exercises the same | ||
| 445 | agent, bootstrap, gate and exposure paths bare metal does. Underpins moving the | ||
| 446 | branch gate off real hardware so a hosted outage cannot fail it. | ||
| 447 | **Unproven**: the four proofs in deploy/server/README.md—cold deploy, warm | ||
| 448 | deploy, deploy after a recycle, coverage merged—have not been run. Bridged | ||
| 449 | networking and real-hardware quirks are known to be out of its reach, which is | ||
| 450 | why real metal gates pre-release tags instead. | ||
| 451 | |||
| 422 | ### A proxied MCP origin carries long calls | 452 | ### A proxied MCP origin carries long calls |
| 423 | 453 | ||
| 424 | The public-MCP story rests on Cloudflare not cutting a `vm_create` that runs | 454 | The public-MCP story rests on Cloudflare not cutting a `vm_create` that runs |
docs/quickstart.md
| Old | New | ||
|---|---|---|---|
| @@ -21,10 +21,10 @@ host bundle: | |||
| 21 | 21 | ||
| 22 | ```sh | 22 | ```sh |
| 23 | V=v0.0.1 | 23 | V=v0.0.1 |
| 24 | curl -fsSLO "https://eitri.sh/dl/$V/eitri_${V}_linux_amd64.tar.gz" | 24 | curl -fsSLO "https://eitri.sh/dl/$V/eitri-server_${V}_linux_amd64.tar.gz" |
| 25 | curl -fsSLO "https://eitri.sh/dl/$V/SHA256SUMS" | 25 | curl -fsSLO "https://eitri.sh/dl/$V/SHA256SUMS" |
| 26 | sha256sum -c SHA256SUMS --ignore-missing | 26 | sha256sum -c SHA256SUMS --ignore-missing |
| 27 | tar xzf "eitri_${V}_linux_amd64.tar.gz" && cd "eitri_${V}_linux_amd64" | 27 | tar xzf "eitri-server_${V}_linux_amd64.tar.gz" && cd "eitri-server_${V}_linux_amd64" |
| 28 | ``` | 28 | ``` |
| 29 | 29 | ||
| 30 | Then click **+ Add host** in the console and run the command it prints | 30 | Then click **+ Add host** in the console and run the command it prints |
| @@ -175,7 +175,7 @@ manage them by hand instead, disable it in `/etc/default/eitri-agent`: | |||
| 175 | `EITRI_AGENT_FLAGS="--bootstrap-url="`. | 175 | `EITRI_AGENT_FLAGS="--bootstrap-url="`. |
| 176 | 176 | ||
| 177 | Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle | 177 | Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle |
| 178 | (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and | 178 | (`eitri-server_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and |
| 179 | their systemd units. The issuer bundle | 179 | their systemd units. The issuer bundle |
| 180 | (`eitri-oidc_<version>_linux_amd64.tar.gz`) has `eitri-oidc`—the bundled | 180 | (`eitri-oidc_<version>_linux_amd64.tar.gz`) has `eitri-oidc`—the bundled |
| 181 | sign-in provider—and its unit. The client bundle | 181 | sign-in provider—and its unit. The client bundle |
| @@ -187,7 +187,7 @@ Download and verify—set `V` to the current release (shown at | |||
| 187 | 187 | ||
| 188 | ```sh | 188 | ```sh |
| 189 | V=v0.0.1 | 189 | V=v0.0.1 |
| 190 | curl -fsSLO "https://eitri.sh/dl/$V/eitri_${V}_linux_amd64.tar.gz" | 190 | curl -fsSLO "https://eitri.sh/dl/$V/eitri-server_${V}_linux_amd64.tar.gz" |
| 191 | curl -fsSLO "https://eitri.sh/dl/$V/eitri-oidc_${V}_linux_amd64.tar.gz" | 191 | curl -fsSLO "https://eitri.sh/dl/$V/eitri-oidc_${V}_linux_amd64.tar.gz" |
| 192 | curl -fsSLO "https://eitri.sh/dl/$V/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd64.tar.gz" | 192 | curl -fsSLO "https://eitri.sh/dl/$V/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd64.tar.gz" |
| 193 | curl -fsSLO "https://eitri.sh/dl/$V/SHA256SUMS" | 193 | curl -fsSLO "https://eitri.sh/dl/$V/SHA256SUMS" |
| @@ -200,7 +200,7 @@ for every tarball before you unpack anything. | |||
| 200 | ### The server | 200 | ### The server |
| 201 | 201 | ||
| 202 | ```sh | 202 | ```sh |
| 203 | tar xzf eitri_*_linux_amd64.tar.gz && cd eitri_*_linux_amd64 | 203 | tar xzf eitri-server_*_linux_amd64.tar.gz && cd eitri-server_*_linux_amd64 |
| 204 | sudo install -m 0755 eitri-server /usr/local/bin/eitri-server | 204 | sudo install -m 0755 eitri-server /usr/local/bin/eitri-server |
| 205 | sudo install -m 0644 eitri-server.service /etc/systemd/system/eitri-server.service | 205 | sudo install -m 0644 eitri-server.service /etc/systemd/system/eitri-server.service |
| 206 | sudo useradd --system --home-dir /var/lib/eitri --shell /usr/sbin/nologin eitri | 206 | sudo useradd --system --home-dir /var/lib/eitri --shell /usr/sbin/nologin eitri |
docs/upgrade.md
| Old | New | ||
|---|---|---|---|
| @@ -23,7 +23,8 @@ hand, stop the agent, move `eitri-agent.prev` back over the binary, and start | |||
| 23 | it again. | 23 | it again. |
| 24 | 24 | ||
| 25 | **Requirements.** The button lights up only when the running agent reports a | 25 | **Requirements.** The button lights up only when the running agent reports a |
| 26 | release version (`vX.Y.Z`)—agents built from an untagged or dirty tree | 26 | release version (`vX.Y.Z`) or a pre-release of one (`vX.Y.Z-pre.N`, which orders |
| 27 | below the release it leads to)—agents built from an untagged or dirty tree | ||
| 27 | report a git hash instead and are never offered upgrades. The server needs | 28 | report a git hash instead and are never offered upgrades. The server needs |
| 28 | `release_manifest_url` reachable; set it to `""` in the server config to | 29 | `release_manifest_url` reachable; set it to `""` in the server config to |
| 29 | disable upgrade checks entirely. | 30 | disable upgrade checks entirely. |
internal/agent/selfupdate/selfupdate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -59,9 +59,9 @@ func tarball(t *testing.T, members ...[2]string) []byte { | |||
| 59 | func hostBundle(t *testing.T, agent string) []byte { | 59 | func hostBundle(t *testing.T, agent string) []byte { |
| 60 | t.Helper() | 60 | t.Helper() |
| 61 | return tarball(t, | 61 | return tarball(t, |
| 62 | [2]string{"eitri_v9_linux_amd64/eitri-server", "server-binary"}, | 62 | [2]string{"eitri-server_v9_linux_amd64/eitri-server", "server-binary"}, |
| 63 | [2]string{"eitri_v9_linux_amd64/eitri-agent", agent}, | 63 | [2]string{"eitri-server_v9_linux_amd64/eitri-agent", agent}, |
| 64 | [2]string{"eitri_v9_linux_amd64/eitri-agent.service", "[Unit]"}, | 64 | [2]string{"eitri-server_v9_linux_amd64/eitri-agent.service", "[Unit]"}, |
| 65 | ) | 65 | ) |
| 66 | } | 66 | } |
| 67 | 67 | ||
| @@ -133,8 +133,8 @@ func TestApplyTarballWithoutAgentMember(t *testing.T) { | |||
| 133 | exe := filepath.Join(dir, "eitri-agent") | 133 | exe := filepath.Join(dir, "eitri-agent") |
| 134 | os.WriteFile(exe, []byte("old"), 0o755) | 134 | os.WriteFile(exe, []byte("old"), 0o755) |
| 135 | body := tarball(t, | 135 | body := tarball(t, |
| 136 | [2]string{"eitri_v9_linux_amd64/eitri-server", "server-binary"}, | 136 | [2]string{"eitri-server_v9_linux_amd64/eitri-server", "server-binary"}, |
| 137 | [2]string{"eitri_v9_linux_amd64/eitri-agent.service", "[Unit]"}, | 137 | [2]string{"eitri-server_v9_linux_amd64/eitri-agent.service", "[Unit]"}, |
| 138 | ) | 138 | ) |
| 139 | url, sha := serveBinary(t, body) | 139 | url, sha := serveBinary(t, body) |
| 140 | 140 | ||
internal/server/release/release.go
| Old | New | ||
|---|---|---|---|
| @@ -117,14 +117,21 @@ func (c *Client) Poll(ctx context.Context, every time.Duration, onErr func(error | |||
| 117 | } | 117 | } |
| 118 | 118 | ||
| 119 | // Less reports whether version a orders strictly before b. Versions are | 119 | // Less reports whether version a orders strictly before b. Versions are |
| 120 | // eitri's own tags ("vX.Y.Z") and the git-describe builds derived from them | 120 | // eitri's own tags — releases ("v0.0.3"), pre-releases of them ("v0.0.4-pre.1") |
| 121 | // ("vX.Y.Z-N-g<hex>", N commits past the tag), ordered by (X, Y, Z, N) with a | 121 | // — and the git-describe builds derived from either ("v0.0.3-5-gabc1234", |
| 122 | // plain tag at N=0. So a hand-deployed "v0.0.2-2-g68f804d" still orders before | 122 | // "v0.0.4-pre.1-3-gabc1234", N commits past that tag). The whole chain orders |
| 123 | // the "v0.0.3" release and takes the upgrade, while a build ahead of the | 123 | // the way it reads: |
| 124 | // latest release ("v0.0.3-5-gabc1234") never orders before it and is never | 124 | // |
| 125 | // offered a downgrade. Anything unparsable — "dev", a "-dirty" working tree, | 125 | // v0.0.4-pre.1 < v0.0.4-pre.1-3-gabc1234 < v0.0.4-pre.2 < v0.0.4 < v0.0.4-2-gabc1234 |
| 126 | // a malformed tag — never orders before anything, so an unstamped build never | 126 | // |
| 127 | // sees an upgrade. | 127 | // So a hand-deployed "v0.0.2-2-g68f804d" orders before the "v0.0.3" release and |
| 128 | // takes the upgrade; a build ahead of the latest release never orders before it | ||
| 129 | // and is never offered a downgrade; and an agent on a pre-release takes the next | ||
| 130 | // pre-release and then the release itself, which is what lets a staging plane | ||
| 131 | // rehearse the upgrade path on the same tags a release cycle produces. | ||
| 132 | // | ||
| 133 | // Anything unparsable — "dev", a "-dirty" working tree, a malformed tag — never | ||
| 134 | // orders before anything, so an unstamped build never sees an upgrade. | ||
| 128 | func Less(a, b string) bool { | 135 | func Less(a, b string) bool { |
| 129 | pa, oka := parse(a) | 136 | pa, oka := parse(a) |
| 130 | pb, okb := parse(b) | 137 | pb, okb := parse(b) |
| @@ -139,35 +146,56 @@ func Less(a, b string) bool { | |||
| 139 | return false | 146 | return false |
| 140 | } | 147 | } |
| 141 | 148 | ||
| 142 | // parse reads "vX.Y.Z" or "vX.Y.Z-N-g<hex>" into the ordering tuple | 149 | // parse reads a version into its ordering tuple. Four shapes are accepted: |
| 143 | // (X, Y, Z, N); a plain tag carries N=0. The describe suffix must be exactly a | 150 | // |
| 144 | // non-negative decimal commit count and a "g"-prefixed non-empty hex abbrev — | 151 | // vX.Y.Z a release |
| 145 | // a "-dirty" marker or any other trailing text makes the version unparsable. | 152 | // vX.Y.Z-pre.N the Nth pre-release leading up to it |
| 146 | func parse(v string) ([4]int, bool) { | 153 | // vX.Y.Z-C-g<hex> C commits past the release (git describe) |
| 147 | var out [4]int | 154 | // vX.Y.Z-pre.N-C-g<hex> C commits past the pre-release |
| 148 | core := strings.TrimPrefix(v, "v") | 155 | // |
| 149 | if rel, desc, found := strings.Cut(core, "-"); found { | 156 | // The tuple is (X, Y, Z, final, pre, count). `final` is 1 for a release and 0 |
| 150 | core = rel | 157 | // for a pre-release of it, which is the whole trick: it sinks every |
| 151 | count, abbrev, ok := strings.Cut(desc, "-") | 158 | // vX.Y.Z-pre.N below vX.Y.Z without disturbing how anything else sorts, and |
| 152 | if !ok || !isDecimal(count) || !isGitAbbrev(abbrev) { | 159 | // leaves builds derived from either in their own place. Both counters must be |
| 160 | // exactly a non-negative decimal, and a describe suffix must carry a | ||
| 161 | // "g"-prefixed non-empty hex abbrev — a "-dirty" marker or any other trailing | ||
| 162 | // text makes the version unparsable. | ||
| 163 | func parse(v string) ([6]int, bool) { | ||
| 164 | var out [6]int | ||
| 165 | core, suffix, hasSuffix := strings.Cut(strings.TrimPrefix(v, "v"), "-") | ||
| 166 | |||
| 167 | out[3] = 1 // a release outranks every pre-release of itself | ||
| 168 | if hasSuffix { | ||
| 169 | if pre, isPre := strings.CutPrefix(suffix, "pre."); isPre { | ||
| 170 | out[3] = 0 | ||
| 171 | num, rest, hasRest := strings.Cut(pre, "-") | ||
| 172 | n, ok := decimal(num) | ||
| 173 | if !ok { | ||
| 174 | return out, false | ||
| 175 | } | ||
| 176 | out[4] = n | ||
| 177 | suffix, hasSuffix = rest, hasRest | ||
| 178 | } | ||
| 179 | } | ||
| 180 | if hasSuffix { | ||
| 181 | count, abbrev, found := strings.Cut(suffix, "-") | ||
| 182 | if !found || !isGitAbbrev(abbrev) { | ||
| 153 | return out, false | 183 | return out, false |
| 154 | } | 184 | } |
| 155 | n, err := strconv.Atoi(count) | 185 | n, ok := decimal(count) |
| 156 | if err != nil { | 186 | if !ok { |
| 157 | return out, false | 187 | return out, false |
| 158 | } | 188 | } |
| 159 | out[3] = n | 189 | out[5] = n |
| 160 | } | 190 | } |
| 191 | |||
| 161 | parts := strings.SplitN(core, ".", 3) | 192 | parts := strings.SplitN(core, ".", 3) |
| 162 | if len(parts) != 3 { | 193 | if len(parts) != 3 { |
| 163 | return out, false | 194 | return out, false |
| 164 | } | 195 | } |
| 165 | for i, p := range parts { | 196 | for i, p := range parts { |
| 166 | if !isDecimal(p) { | 197 | n, ok := decimal(p) |
| 167 | return out, false | 198 | if !ok { |
| 168 | } | ||
| 169 | n, err := strconv.Atoi(p) | ||
| 170 | if err != nil { | ||
| 171 | return out, false | 199 | return out, false |
| 172 | } | 200 | } |
| 173 | out[i] = n | 201 | out[i] = n |
| @@ -175,6 +203,19 @@ func parse(v string) ([4]int, bool) { | |||
| 175 | return out, true | 203 | return out, true |
| 176 | } | 204 | } |
| 177 | 205 | ||
| 206 | // decimal parses one non-negative decimal component, rejecting the sign Atoi | ||
| 207 | // would otherwise accept and any value too large to hold. | ||
| 208 | func decimal(s string) (int, bool) { | ||
| 209 | if !isDecimal(s) { | ||
| 210 | return 0, false | ||
| 211 | } | ||
| 212 | n, err := strconv.Atoi(s) | ||
| 213 | if err != nil { | ||
| 214 | return 0, false | ||
| 215 | } | ||
| 216 | return n, true | ||
| 217 | } | ||
| 218 | |||
| 178 | // isDecimal reports whether s is a non-empty run of decimal digits — the sign | 219 | // isDecimal reports whether s is a non-empty run of decimal digits — the sign |
| 179 | // Atoi would otherwise accept is not part of a version component. | 220 | // Atoi would otherwise accept is not part of a version component. |
| 180 | func isDecimal(s string) bool { | 221 | func isDecimal(s string) bool { |
internal/server/release/release_test.go
| Old | New | ||
|---|---|---|---|
| @@ -66,6 +66,33 @@ func TestLessOrdersGitDescribeBuilds(t *testing.T) { | |||
| 66 | } | 66 | } |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | // TestLessOrdersPreReleases pins the chain a release cycle walks: each | ||
| 70 | // pre-release, the builds derived from it, the next pre-release, the release | ||
| 71 | // itself, and builds past that. An agent on a pre-release takes the next one | ||
| 72 | // and eventually the release, which is what lets a staging plane rehearse the | ||
| 73 | // upgrade path on the same tags the cycle produces. | ||
| 74 | func TestLessOrdersPreReleases(t *testing.T) { | ||
| 75 | // Every entry orders strictly before every entry after it. | ||
| 76 | chain := []string{ | ||
| 77 | "v0.0.3", | ||
| 78 | "v0.0.4-pre.1", | ||
| 79 | "v0.0.4-pre.1-3-gabc1234", | ||
| 80 | "v0.0.4-pre.2", | ||
| 81 | "v0.0.4-pre.10", // numeric, not lexicographic | ||
| 82 | "v0.0.4", | ||
| 83 | "v0.0.4-2-gabc1234", | ||
| 84 | "v0.0.5-pre.1", | ||
| 85 | } | ||
| 86 | for i, a := range chain { | ||
| 87 | for j, b := range chain { | ||
| 88 | want := i < j | ||
| 89 | if got := Less(a, b); got != want { | ||
| 90 | t.Errorf("Less(%q,%q) = %v, want %v", a, b, got, want) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 69 | // TestLessLeavesUnstampedBuildsUnordered pins the shapes that stay | 96 | // TestLessLeavesUnstampedBuildsUnordered pins the shapes that stay |
| 70 | // unparsable: a dirty working tree or a malformed version never orders before | 97 | // unparsable: a dirty working tree or a malformed version never orders before |
| 71 | // anything, in either direction, so it is never offered an upgrade. | 98 | // anything, in either direction, so it is never offered an upgrade. |
| @@ -75,6 +102,17 @@ func TestLessLeavesUnstampedBuildsUnordered(t *testing.T) { | |||
| 75 | "dev", | 102 | "dev", |
| 76 | "v0.0.3-dirty", | 103 | "v0.0.3-dirty", |
| 77 | "v0.0.2-2-g68f804d-dirty", | 104 | "v0.0.2-2-g68f804d-dirty", |
| 105 | "v0.0.4-pre", // no pre number | ||
| 106 | "v0.0.4-pre.", // empty pre number | ||
| 107 | "v0.0.4-pre.x", // pre number is not a number | ||
| 108 | "v0.0.4-pre.1.2", // pre number is not one component | ||
| 109 | "v0.0.4-pre.-1", // negative pre number | ||
| 110 | "v0.0.4-pre.1-dirty", // dirty past a pre-release | ||
| 111 | "v0.0.4-pre.1-3", // no g-abbrev past a pre-release | ||
| 112 | "v0.0.4-pre.1-g abc", // no commit count past a pre-release | ||
| 113 | "v0.0.4-prerelease.1", // not the pre. prefix | ||
| 114 | "v0.0.4-rc.1", // only "pre" is spelled this way | ||
| 115 | "v0.0.4-pre.99999999999999999999", // pre number out of range | ||
| 78 | "v0.0.2-x-g123", // commit count is not a number | 116 | "v0.0.2-x-g123", // commit count is not a number |
| 79 | "v0.0.2--2-g68f804d", // negative commit count | 117 | "v0.0.2--2-g68f804d", // negative commit count |
| 80 | "v0.0.2-2", // no g-abbrev | 118 | "v0.0.2-2", // no g-abbrev |
internal/site/dl_test.go
| Old | New | ||
|---|---|---|---|
| @@ -14,9 +14,9 @@ func fixtureDist(t *testing.T) string { | |||
| 14 | t.Fatal(err) | 14 | t.Fatal(err) |
| 15 | } | 15 | } |
| 16 | files := map[string]string{ | 16 | files := map[string]string{ |
| 17 | "eitri_v0.0.1_linux_amd64.tar.gz": strings.Repeat("x", 2048), | 17 | "eitri-server_v0.0.1_linux_amd64.tar.gz": strings.Repeat("x", 2048), |
| 18 | "eitri-agent_linux_amd64": "binary", | 18 | "eitri-agent_linux_amd64": "binary", |
| 19 | "SHA256SUMS": "abc123 eitri_v0.0.1_linux_amd64.tar.gz\ndef456 eitri-agent_linux_amd64\n", | 19 | "SHA256SUMS": "abc123 eitri-server_v0.0.1_linux_amd64.tar.gz\ndef456 eitri-agent_linux_amd64\n", |
| 20 | } | 20 | } |
| 21 | for name, body := range files { | 21 | for name, body := range files { |
| 22 | if err := os.WriteFile(filepath.Join(dist, name), []byte(body), 0o644); err != nil { | 22 | if err := os.WriteFile(filepath.Join(dist, name), []byte(body), 0o644); err != nil { |
| @@ -32,7 +32,7 @@ func TestDownloadsPageListsArtifacts(t *testing.T) { | |||
| 32 | t.Fatal(err) | 32 | t.Fatal(err) |
| 33 | } | 33 | } |
| 34 | for _, want := range []string{ | 34 | for _, want := range []string{ |
| 35 | "[eitri_v0.0.1_linux_amd64.tar.gz](/dl/v0.0.1/eitri_v0.0.1_linux_amd64.tar.gz)", | 35 | "[eitri-server_v0.0.1_linux_amd64.tar.gz](/dl/v0.0.1/eitri-server_v0.0.1_linux_amd64.tar.gz)", |
| 36 | "abc123", | 36 | "abc123", |
| 37 | "sha256sum -c SHA256SUMS", | 37 | "sha256sum -c SHA256SUMS", |
| 38 | "[SHA256SUMS](/dl/v0.0.1/SHA256SUMS)", | 38 | "[SHA256SUMS](/dl/v0.0.1/SHA256SUMS)", |
internal/site/manifest_test.go
| Old | New | ||
|---|---|---|---|
| @@ -20,7 +20,7 @@ func TestBuildManifestFromBareBinaries(t *testing.T) { | |||
| 20 | } | 20 | } |
| 21 | } | 21 | } |
| 22 | // Non-bare files must be ignored. | 22 | // Non-bare files must be ignored. |
| 23 | if err := os.WriteFile(filepath.Join(dist, "eitri_v0.0.1_linux_amd64.tar.gz"), body, 0o644); err != nil { | 23 | if err := os.WriteFile(filepath.Join(dist, "eitri-server_v0.0.1_linux_amd64.tar.gz"), body, 0o644); err != nil { |
| 24 | t.Fatal(err) | 24 | t.Fatal(err) |
| 25 | } | 25 | } |
| 26 | 26 | ||
internal/smoke/config.go
| Old | New | ||
|---|---|---|---|
| @@ -9,13 +9,30 @@ import ( | |||
| 9 | // Config holds the environment-sourced settings for one smoke run. It mirrors | 9 | // Config holds the environment-sourced settings for one smoke run. It mirrors |
| 10 | // the variables the deploy boot-gate reads from $EITRI_DEPLOY_ENV. | 10 | // the variables the deploy boot-gate reads from $EITRI_DEPLOY_ENV. |
| 11 | type Config struct { | 11 | type Config struct { |
| 12 | ServerURL string | 12 | ServerURL string |
| 13 | // CIUser and CIPasswordFile drive the credential-chain proof: a headless | ||
| 14 | // sign-in that posts a password to the issuer's login form. Both empty ⇒ | ||
| 15 | // that proof is skipped, which is what a plane fronted by a real identity | ||
| 16 | // provider needs — a Google-backed console has no password to post, and the | ||
| 17 | // operator PAT below carries the run instead. | ||
| 13 | CIUser string | 18 | CIUser string |
| 14 | CIPasswordFile string | 19 | CIPasswordFile string |
| 15 | CIPATFile string | 20 | // CIPATFile is an operator-minted token for the VM lifecycle. Empty ⇒ the |
| 16 | AgentUserHost string | 21 | // PAT minted by the sign-in above is used instead, which is right whenever |
| 17 | AgentPort int | 22 | // the identity that signs in is itself the operator. |
| 18 | AgentStateDir string | 23 | CIPATFile string |
| 24 | AgentUserHost string | ||
| 25 | AgentPort int | ||
| 26 | AgentStateDir string | ||
| 27 | |||
| 28 | // MCPURLs are the origins the remote-MCP leg exercises (SMOKE_MCP_URL, a | ||
| 29 | // space-separated list; a single ServerURL when unset). The FIRST entry gets | ||
| 30 | // the full leg — register, create, exec, expose, banner, destroy — and every | ||
| 31 | // later entry gets the cheap ones: an unauthenticated POST must be refused, | ||
| 32 | // and the advertised toolset must be complete. One long call is enough to | ||
| 33 | // observe a proxy's silent-origin timeout; a second would only double the VM | ||
| 34 | // churn for coverage already held. | ||
| 35 | MCPURLs []string | ||
| 19 | 36 | ||
| 20 | // Carried for a later coverage-collection task; optional here. | 37 | // Carried for a later coverage-collection task; optional here. |
| 21 | ServerGocoverdir string | 38 | ServerGocoverdir string |
| @@ -44,14 +61,20 @@ func loadConfig(getenv func(string) string) (Config, error) { | |||
| 44 | if serverURL == "" { | 61 | if serverURL == "" { |
| 45 | missing = append(missing, "SERVER_URL") | 62 | missing = append(missing, "SERVER_URL") |
| 46 | } | 63 | } |
| 47 | if ciUser == "" { | 64 | // CI_USER/CI_PASSWORD_FILE are deliberately absent from this list: a plane |
| 48 | missing = append(missing, "CI_USER") | 65 | // whose issuer is a real IdP cannot offer a headless password sign-in, so |
| 49 | } | 66 | // the credential-chain proof is opt-in. Setting exactly one of the pair is |
| 50 | if ciPasswordFile == "" { | 67 | // a typo rather than a choice, and is rejected below. |
| 51 | missing = append(missing, "CI_PASSWORD_FILE") | 68 | if (ciUser == "") != (ciPasswordFile == "") { |
| 69 | return Config{}, fmt.Errorf("CI_USER and CI_PASSWORD_FILE must be set together (set neither to skip the credential-chain proof)") | ||
| 52 | } | 70 | } |
| 53 | if ciPATFile == "" { | 71 | // CI_PAT_FILE carries the VM lifecycle as an operator whose tenant owns the |
| 54 | missing = append(missing, "CI_PAT_FILE") | 72 | // fleet's hosts. It is required only when nothing else can produce such a |
| 73 | // token: where a password issuer exists, the sign-in above mints one, and | ||
| 74 | // on a plane whose signed-in identity IS the operator that is the whole | ||
| 75 | // credential story — no PAT to paste, nothing to rotate by hand. | ||
| 76 | if ciPATFile == "" && ciUser == "" { | ||
| 77 | missing = append(missing, "CI_PAT_FILE (or CI_USER, to mint one by signing in)") | ||
| 55 | } | 78 | } |
| 56 | if agentHosts == "" { | 79 | if agentHosts == "" { |
| 57 | missing = append(missing, "AGENT_HOSTS") | 80 | missing = append(missing, "AGENT_HOSTS") |
| @@ -70,8 +93,14 @@ func loadConfig(getenv func(string) string) (Config, error) { | |||
| 70 | smokeVMUser = "ubuntu" | 93 | smokeVMUser = "ubuntu" |
| 71 | } | 94 | } |
| 72 | 95 | ||
| 96 | mcpURLs := strings.Fields(getenv("SMOKE_MCP_URL")) | ||
| 97 | if len(mcpURLs) == 0 { | ||
| 98 | mcpURLs = []string{serverURL} | ||
| 99 | } | ||
| 100 | |||
| 73 | return Config{ | 101 | return Config{ |
| 74 | ServerURL: serverURL, | 102 | ServerURL: serverURL, |
| 103 | MCPURLs: mcpURLs, | ||
| 75 | CIUser: ciUser, | 104 | CIUser: ciUser, |
| 76 | CIPasswordFile: ciPasswordFile, | 105 | CIPasswordFile: ciPasswordFile, |
| 77 | CIPATFile: ciPATFile, | 106 | CIPATFile: ciPATFile, |
internal/smoke/config_test.go
| Old | New | ||
|---|---|---|---|
| @@ -69,9 +69,6 @@ func TestLoadConfigMissingRequiredVars(t *testing.T) { | |||
| 69 | wantErr string | 69 | wantErr string |
| 70 | }{ | 70 | }{ |
| 71 | {"missing server url", "SERVER_URL", "SERVER_URL"}, | 71 | {"missing server url", "SERVER_URL", "SERVER_URL"}, |
| 72 | {"missing ci user", "CI_USER", "CI_USER"}, | ||
| 73 | {"missing ci password file", "CI_PASSWORD_FILE", "CI_PASSWORD_FILE"}, | ||
| 74 | {"missing ci pat file", "CI_PAT_FILE", "CI_PAT_FILE"}, | ||
| 75 | {"missing agent hosts", "AGENT_HOSTS", "AGENT_HOSTS"}, | 72 | {"missing agent hosts", "AGENT_HOSTS", "AGENT_HOSTS"}, |
| 76 | {"missing agent state dir", "AGENT_STATE_DIR", "AGENT_STATE_DIR"}, | 73 | {"missing agent state dir", "AGENT_STATE_DIR", "AGENT_STATE_DIR"}, |
| 77 | } | 74 | } |
| @@ -95,13 +92,115 @@ func TestLoadConfigMissingAllRequiredVars(t *testing.T) { | |||
| 95 | if err == nil { | 92 | if err == nil { |
| 96 | t.Fatal("loadConfig: want error, got nil") | 93 | t.Fatal("loadConfig: want error, got nil") |
| 97 | } | 94 | } |
| 98 | for _, want := range []string{"SERVER_URL", "CI_USER", "CI_PASSWORD_FILE", "CI_PAT_FILE", "AGENT_HOSTS", "AGENT_STATE_DIR"} { | 95 | for _, want := range []string{"SERVER_URL", "CI_PAT_FILE", "AGENT_HOSTS", "AGENT_STATE_DIR"} { |
| 99 | if !strings.Contains(err.Error(), want) { | 96 | if !strings.Contains(err.Error(), want) { |
| 100 | t.Errorf("error = %q, missing %q", err.Error(), want) | 97 | t.Errorf("error = %q, missing %q", err.Error(), want) |
| 101 | } | 98 | } |
| 102 | } | 99 | } |
| 103 | } | 100 | } |
| 104 | 101 | ||
| 102 | // TestLoadConfigCredentialChainIsOptional pins which credentials a plane may | ||
| 103 | // leave out. A plane fronted by a real identity provider has no password to | ||
| 104 | // post and hands over an operator PAT instead; a plane with a password issuer | ||
| 105 | // signs in and mints its own. Only a plane offering neither is misconfigured — | ||
| 106 | // and setting exactly one half of the sign-in pair is a typo, not a choice. | ||
| 107 | func TestLoadConfigCredentialChainIsOptional(t *testing.T) { | ||
| 108 | cases := []struct { | ||
| 109 | name string | ||
| 110 | unset []string | ||
| 111 | wantErr string | ||
| 112 | }{ | ||
| 113 | {"both unset skips the proof", []string{"CI_USER", "CI_PASSWORD_FILE"}, ""}, | ||
| 114 | {"user without password file", []string{"CI_PASSWORD_FILE"}, "must be set together"}, | ||
| 115 | {"password file without user", []string{"CI_USER"}, "must be set together"}, | ||
| 116 | // A sign-in mints its own token, so a plane with a password issuer needs | ||
| 117 | // no operator PAT pasted anywhere. | ||
| 118 | {"sign-in without an operator PAT", []string{"CI_PAT_FILE"}, ""}, | ||
| 119 | // With neither, nothing can authenticate the VM lifecycle. | ||
| 120 | { | ||
| 121 | "neither a sign-in nor a PAT", | ||
| 122 | []string{"CI_USER", "CI_PASSWORD_FILE", "CI_PAT_FILE"}, | ||
| 123 | "CI_PAT_FILE", | ||
| 124 | }, | ||
| 125 | } | ||
| 126 | for _, tc := range cases { | ||
| 127 | t.Run(tc.name, func(t *testing.T) { | ||
| 128 | vals := requiredVals() | ||
| 129 | for _, k := range tc.unset { | ||
| 130 | delete(vals, k) | ||
| 131 | } | ||
| 132 | cfg, err := loadConfig(fakeGetenv(vals)) | ||
| 133 | if tc.wantErr == "" { | ||
| 134 | if err != nil { | ||
| 135 | t.Fatalf("loadConfig: %v", err) | ||
| 136 | } | ||
| 137 | got := map[string]string{ | ||
| 138 | "CI_USER": cfg.CIUser, | ||
| 139 | "CI_PASSWORD_FILE": cfg.CIPasswordFile, | ||
| 140 | "CI_PAT_FILE": cfg.CIPATFile, | ||
| 141 | } | ||
| 142 | for _, k := range tc.unset { | ||
| 143 | if got[k] != "" { | ||
| 144 | t.Errorf("%s = %q, want empty", k, got[k]) | ||
| 145 | } | ||
| 146 | } | ||
| 147 | return | ||
| 148 | } | ||
| 149 | if err == nil { | ||
| 150 | t.Fatal("loadConfig: want error, got nil") | ||
| 151 | } | ||
| 152 | if !strings.Contains(err.Error(), tc.wantErr) { | ||
| 153 | t.Errorf("error = %q, want to mention %q", err.Error(), tc.wantErr) | ||
| 154 | } | ||
| 155 | }) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | // TestLoadConfigMCPURLs pins the one documented rule of the list: unset means | ||
| 160 | // the console origin alone, so the branch gate is unchanged, and the order of | ||
| 161 | // an explicit list is preserved — the first entry is the one that gets the full | ||
| 162 | // leg. | ||
| 163 | func TestLoadConfigMCPURLs(t *testing.T) { | ||
| 164 | cases := []struct { | ||
| 165 | name string | ||
| 166 | env string | ||
| 167 | want []string | ||
| 168 | }{ | ||
| 169 | {"unset defaults to the server url", "", []string{"https://server.example:8443"}}, | ||
| 170 | {"one origin", "https://api.stg.eitri.sh", []string{"https://api.stg.eitri.sh"}}, | ||
| 171 | { | ||
| 172 | "two origins keep their order", | ||
| 173 | "https://stg.eitri.sh https://api.stg.eitri.sh", | ||
| 174 | []string{"https://stg.eitri.sh", "https://api.stg.eitri.sh"}, | ||
| 175 | }, | ||
| 176 | { | ||
| 177 | "surrounding and repeated whitespace is not an origin", | ||
| 178 | " https://stg.eitri.sh \t https://api.stg.eitri.sh ", | ||
| 179 | []string{"https://stg.eitri.sh", "https://api.stg.eitri.sh"}, | ||
| 180 | }, | ||
| 181 | } | ||
| 182 | for _, tc := range cases { | ||
| 183 | t.Run(tc.name, func(t *testing.T) { | ||
| 184 | vals := requiredVals() | ||
| 185 | if tc.env != "" { | ||
| 186 | vals["SMOKE_MCP_URL"] = tc.env | ||
| 187 | } | ||
| 188 | cfg, err := loadConfig(fakeGetenv(vals)) | ||
| 189 | if err != nil { | ||
| 190 | t.Fatalf("loadConfig: %v", err) | ||
| 191 | } | ||
| 192 | if len(cfg.MCPURLs) != len(tc.want) { | ||
| 193 | t.Fatalf("MCPURLs = %v, want %v", cfg.MCPURLs, tc.want) | ||
| 194 | } | ||
| 195 | for i := range tc.want { | ||
| 196 | if cfg.MCPURLs[i] != tc.want[i] { | ||
| 197 | t.Errorf("MCPURLs[%d] = %q, want %q", i, cfg.MCPURLs[i], tc.want[i]) | ||
| 198 | } | ||
| 199 | } | ||
| 200 | }) | ||
| 201 | } | ||
| 202 | } | ||
| 203 | |||
| 105 | func TestLoadConfigSmokeGateDefaults(t *testing.T) { | 204 | func TestLoadConfigSmokeGateDefaults(t *testing.T) { |
| 106 | cfg, err := loadConfig(fakeGetenv(requiredVals())) | 205 | cfg, err := loadConfig(fakeGetenv(requiredVals())) |
| 107 | if err != nil { | 206 | if err != nil { |
internal/smoke/mcp.go
| Old | New | ||
|---|---|---|---|
| @@ -129,16 +129,11 @@ type delegator struct { | |||
| 129 | now func() time.Time | 129 | now func() time.Time |
| 130 | } | 130 | } |
| 131 | 131 | ||
| 132 | // proveMCP drives one full cycle through the remote MCP endpoint and nothing | 132 | // proveToolList proves an origin advertises the whole remote toolset. It is the |
| 133 | // else: delegate access to eitri, create a VM, run a command in it, publish a | 133 | // cheap half of the MCP leg, and all a second origin gets: a plane fronted two |
| 134 | // port, read the guest's banner back through that port, and destroy it. | 134 | // ways routes and authenticates /mcp on both, but only one of them needs to |
| 135 | // | 135 | // carry the long call that a proxy's silent-origin timeout would cut. |
| 136 | // The CA is registered BEFORE the VM is created, because a guest bakes its CA | 136 | func proveToolList(ctx context.Context, c mcpTools) error { |
| 137 | // set at create and a CA registered afterwards is one it will never trust. The | ||
| 138 | // DELEGATION itself may happen on either side of the create — that is the whole | ||
| 139 | // improvement over holding a signing key, and it is worth stating plainly here | ||
| 140 | // so nobody reintroduces an ordering constraint that no longer exists. | ||
| 141 | func proveMCP(ctx context.Context, c mcpTools, vmName string, d delegator, now func() time.Time, sleep func(time.Duration), dial bannerFunc) error { | ||
| 142 | names, err := c.List(ctx) | 137 | names, err := c.List(ctx) |
| 143 | if err != nil { | 138 | if err != nil { |
| 144 | return fmt.Errorf("list remote MCP tools: %w", err) | 139 | return fmt.Errorf("list remote MCP tools: %w", err) |
| @@ -151,6 +146,36 @@ func proveMCP(ctx context.Context, c mcpTools, vmName string, d delegator, now f | |||
| 151 | return fmt.Errorf("FAIL: remote MCP does not advertise %s (has: %s)", want, strings.Join(names, ", ")) | 146 | return fmt.Errorf("FAIL: remote MCP does not advertise %s (has: %s)", want, strings.Join(names, ", ")) |
| 152 | } | 147 | } |
| 153 | } | 148 | } |
| 149 | return nil | ||
| 150 | } | ||
| 151 | |||
| 152 | // proveRemoteMCPToolset dials one origin with a bearer PAT, proves its toolset, | ||
| 153 | // and hangs up. | ||
| 154 | func proveRemoteMCPToolset(ctx context.Context, serverURL, pat string) error { | ||
| 155 | tools, closeMCP, err := dialMCP(ctx, serverURL, pat) | ||
| 156 | if err != nil { | ||
| 157 | return err | ||
| 158 | } | ||
| 159 | defer closeMCP() | ||
| 160 | if err := proveToolList(ctx, tools); err != nil { | ||
| 161 | return fmt.Errorf("%s: %w", serverURL, err) | ||
| 162 | } | ||
| 163 | return nil | ||
| 164 | } | ||
| 165 | |||
| 166 | // proveMCP drives one full cycle through the remote MCP endpoint and nothing | ||
| 167 | // else: delegate access to eitri, create a VM, run a command in it, publish a | ||
| 168 | // port, read the guest's banner back through that port, and destroy it. | ||
| 169 | // | ||
| 170 | // The CA is registered BEFORE the VM is created, because a guest bakes its CA | ||
| 171 | // set at create and a CA registered afterwards is one it will never trust. The | ||
| 172 | // DELEGATION itself may happen on either side of the create — that is the whole | ||
| 173 | // improvement over holding a signing key, and it is worth stating plainly here | ||
| 174 | // so nobody reintroduces an ordering constraint that no longer exists. | ||
| 175 | func proveMCP(ctx context.Context, c mcpTools, vmName string, d delegator, now func() time.Time, sleep func(time.Duration), dial bannerFunc) error { | ||
| 176 | if err := proveToolList(ctx, c); err != nil { | ||
| 177 | return err | ||
| 178 | } | ||
| 154 | 179 | ||
| 155 | // Through the tool, not the endpoint behind it: registering a CA is the | 180 | // Through the tool, not the endpoint behind it: registering a CA is the |
| 156 | // first step a bare token has to take, so the gate drives the same call an | 181 | // first step a bare token has to take, so the gate drives the same call an |
internal/smoke/mcp_test.go
| Old | New | ||
|---|---|---|---|
| @@ -248,8 +248,46 @@ func TestProveMCPDestroysItsVMOnFailure(t *testing.T) { | |||
| 248 | assert.Contains(t, f.calls, "vm_destroy", "the leg must reap its own VM even when it fails") | 248 | assert.Contains(t, f.calls, "vm_destroy", "the leg must reap its own VM even when it fails") |
| 249 | } | 249 | } |
| 250 | 250 | ||
| 251 | // TestProveMCPRejectsAShortToolset: a transport that advertises fewer tools than | 251 | // TestProveToolList covers the cheap leg every extra origin gets on its own: a |
| 252 | // the stdio binary means the two have drifted apart. | 252 | // transport advertising fewer tools than the stdio binary means the two have |
| 253 | // drifted apart, and a missing tool is named rather than counted. | ||
| 254 | func TestProveToolList(t *testing.T) { | ||
| 255 | cases := []struct { | ||
| 256 | name string | ||
| 257 | tools func(*fakeMCP) | ||
| 258 | wantErr string | ||
| 259 | }{ | ||
| 260 | {"the whole toolset passes", func(*fakeMCP) {}, ""}, | ||
| 261 | {"a short toolset fails", func(f *fakeMCP) { f.tools = f.tools[:5] }, "advertises 5 tools"}, | ||
| 262 | { | ||
| 263 | "a renamed tool is named", | ||
| 264 | func(f *fakeMCP) { | ||
| 265 | for i, name := range f.tools { | ||
| 266 | if name == "delegate_begin" { | ||
| 267 | f.tools[i] = "vm_something_else" | ||
| 268 | } | ||
| 269 | } | ||
| 270 | }, | ||
| 271 | "does not advertise delegate_begin", | ||
| 272 | }, | ||
| 273 | } | ||
| 274 | for _, tc := range cases { | ||
| 275 | t.Run(tc.name, func(t *testing.T) { | ||
| 276 | f := newFakeMCP(t, newCA(t)) | ||
| 277 | tc.tools(f) | ||
| 278 | err := proveToolList(t.Context(), f) | ||
| 279 | if tc.wantErr == "" { | ||
| 280 | require.NoError(t, err) | ||
| 281 | return | ||
| 282 | } | ||
| 283 | require.Error(t, err) | ||
| 284 | assert.Contains(t, err.Error(), tc.wantErr) | ||
| 285 | }) | ||
| 286 | } | ||
| 287 | } | ||
| 288 | |||
| 289 | // TestProveMCPRejectsAShortToolset: the full leg refuses to spend a boot on an | ||
| 290 | // endpoint whose toolset is already wrong. | ||
| 253 | func TestProveMCPRejectsAShortToolset(t *testing.T) { | 291 | func TestProveMCPRejectsAShortToolset(t *testing.T) { |
| 254 | ca, stranger := newCA(t), newCA(t) | 292 | ca, stranger := newCA(t), newCA(t) |
| 255 | f := newFakeMCP(t, ca) | 293 | f := newFakeMCP(t, ca) |
| @@ -259,6 +297,7 @@ func TestProveMCPRejectsAShortToolset(t *testing.T) { | |||
| 259 | err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial) | 297 | err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial) |
| 260 | require.Error(t, err) | 298 | require.Error(t, err) |
| 261 | assert.Contains(t, err.Error(), "advertises 5 tools") | 299 | assert.Contains(t, err.Error(), "advertises 5 tools") |
| 300 | assert.Empty(t, f.calls, "a wrong toolset must fail before any tool is called") | ||
| 262 | } | 301 | } |
| 263 | 302 | ||
| 264 | // TestProveMCPRejectsAMissingDelegateTool guards the tools a remote caller | 303 | // TestProveMCPRejectsAMissingDelegateTool guards the tools a remote caller |
internal/smoke/run.go
| Old | New | ||
|---|---|---|---|
| @@ -38,39 +38,62 @@ func Run() error { | |||
| 38 | // touches VMs — the machine identity's JIT tenant owns no hosts (the fleet's | 38 | // touches VMs — the machine identity's JIT tenant owns no hosts (the fleet's |
| 39 | // `default` tenant is human-owned), so the lifecycle half runs as the operator | 39 | // `default` tenant is human-owned), so the lifecycle half runs as the operator |
| 40 | // below. | 40 | // below. |
| 41 | pwBytes, err := os.ReadFile(cfg.CIPasswordFile) | 41 | // |
| 42 | if err != nil { | 42 | // A plane whose issuer is a real identity provider has no password to post, |
| 43 | return fmt.Errorf("read CI password file %q: %w", cfg.CIPasswordFile, err) | 43 | // so the proof is opt-in: no CI_USER means the run starts at phase 2 and the |
| 44 | } | 44 | // operator PAT carries it. What that plane gives up is stated out loud |
| 45 | password := strings.TrimRight(string(pwBytes), "\r\n") | 45 | // rather than silently, because it is the one leg a hosted run cannot make. |
| 46 | var signedInPAT string | ||
| 47 | if cfg.CIUser == "" { | ||
| 48 | fmt.Println("credential chain: skipped (no CI_USER — this plane signs in against a real identity provider)") | ||
| 49 | } else { | ||
| 50 | pwBytes, err := os.ReadFile(cfg.CIPasswordFile) | ||
| 51 | if err != nil { | ||
| 52 | return fmt.Errorf("read CI password file %q: %w", cfg.CIPasswordFile, err) | ||
| 53 | } | ||
| 54 | password := strings.TrimRight(string(pwBytes), "\r\n") | ||
| 46 | 55 | ||
| 47 | token, err := loginPAT(cfg.ServerURL, cfg.CIUser, password) | 56 | signedInPAT, err = loginPAT(cfg.ServerURL, cfg.CIUser, password) |
| 48 | if err != nil { | 57 | if err != nil { |
| 49 | return err | 58 | return err |
| 50 | } | 59 | } |
| 51 | ciTenant, err := proveCredentialChain(cfg.ServerURL, token) | 60 | ciTenant, err := proveCredentialChain(cfg.ServerURL, signedInPAT) |
| 52 | if err != nil { | 61 | if err != nil { |
| 53 | return err | 62 | return err |
| 63 | } | ||
| 64 | fmt.Printf("credential chain OK (tenant %s)\n", ciTenant) | ||
| 54 | } | 65 | } |
| 55 | fmt.Printf("credential chain OK (tenant %s)\n", ciTenant) | ||
| 56 | 66 | ||
| 57 | // The remote MCP endpoint's cheap half: it must exist and must refuse an | 67 | // The remote MCP endpoint's cheap half: it must exist and must refuse an |
| 58 | // unauthenticated caller. It needs no guest, so a misrouted or unprotected | 68 | // unauthenticated caller. It needs no guest, so a misrouted or unprotected |
| 59 | // /mcp fails here in a second rather than after a VM has booted. | 69 | // /mcp fails here in a second rather than after a VM has booted. Every |
| 60 | if err := proveRemoteMCPNeedsACredential(cfg.ServerURL); err != nil { | 70 | // origin in the list answers for itself — a plane fronted both through a |
| 61 | return err | 71 | // proxy and directly can route one and not the other. |
| 72 | for _, origin := range cfg.MCPURLs { | ||
| 73 | if err := proveRemoteMCPNeedsACredential(origin); err != nil { | ||
| 74 | return err | ||
| 75 | } | ||
| 62 | } | 76 | } |
| 63 | fmt.Println("remote MCP endpoint refuses an unauthenticated caller") | 77 | fmt.Printf("remote MCP endpoint refuses an unauthenticated caller (%s)\n", strings.Join(cfg.MCPURLs, ", ")) |
| 64 | 78 | ||
| 65 | // Phase 2 — VM lifecycle. Authenticate with an operator-minted PAT read from | 79 | // Phase 2 — VM lifecycle, on a normal tenant-scoped console PAT (spec §3's |
| 66 | // CI_PAT_FILE (a normal, tenant-scoped console PAT — spec §3's automation | 80 | // automation story). It comes from CI_PAT_FILE where an operator minted one |
| 67 | // story). The scenario's tenant is DERIVED via Me(), never assumed, and | 81 | // by hand, because the fleet's hosts belong to that operator's tenant and |
| 82 | // the machine identity's own JIT tenant owns none. Where the plane's issuer | ||
| 83 | // is one of ours, the identity that just signed in IS the operator, so the | ||
| 84 | // PAT it minted carries this phase and there is no token to paste anywhere. | ||
| 85 | // The scenario's tenant is DERIVED via Me() either way, never assumed, and | ||
| 68 | // threaded into the user-CA registration and gate connect name below. | 86 | // threaded into the user-CA registration and gate connect name below. |
| 69 | patBytes, err := os.ReadFile(cfg.CIPATFile) | 87 | pat := signedInPAT |
| 70 | if err != nil { | 88 | if cfg.CIPATFile != "" { |
| 71 | return fmt.Errorf("read CI PAT file %q: %w", cfg.CIPATFile, err) | 89 | patBytes, err := os.ReadFile(cfg.CIPATFile) |
| 90 | if err != nil { | ||
| 91 | return fmt.Errorf("read CI PAT file %q: %w", cfg.CIPATFile, err) | ||
| 92 | } | ||
| 93 | pat = strings.TrimRight(string(patBytes), "\r\n") | ||
| 94 | } else { | ||
| 95 | fmt.Println("VM lifecycle runs on the PAT minted by the sign-in above") | ||
| 72 | } | 96 | } |
| 73 | pat := strings.TrimRight(string(patBytes), "\r\n") | ||
| 74 | 97 | ||
| 75 | api := &client.Client{ | 98 | api := &client.Client{ |
| 76 | BaseURL: cfg.ServerURL, | 99 | BaseURL: cfg.ServerURL, |
| @@ -83,7 +106,11 @@ func Run() error { | |||
| 83 | return fmt.Errorf("resolve operator PAT tenant: %w", err) | 106 | return fmt.Errorf("resolve operator PAT tenant: %w", err) |
| 84 | } | 107 | } |
| 85 | if me.Tenant == "" { | 108 | if me.Tenant == "" { |
| 86 | return fmt.Errorf("operator PAT (%s) resolved to an empty tenant", cfg.CIPATFile) | 109 | source := cfg.CIPATFile |
| 110 | if source == "" { | ||
| 111 | source = "minted by " + cfg.CIUser + "'s sign-in" | ||
| 112 | } | ||
| 113 | return fmt.Errorf("operator PAT (%s) resolved to an empty tenant", source) | ||
| 87 | } | 114 | } |
| 88 | tenant := me.Tenant | 115 | tenant := me.Tenant |
| 89 | fmt.Printf("operator PAT tenant: %s\n", tenant) | 116 | fmt.Printf("operator PAT tenant: %s\n", tenant) |
| @@ -117,9 +144,20 @@ func Run() error { | |||
| 117 | 144 | ||
| 118 | ctx := context.Background() | 145 | ctx := context.Background() |
| 119 | 146 | ||
| 147 | // Every origin past the first is proven routable and authenticated and then | ||
| 148 | // let go: it advertises the whole toolset over a bearer PAT. The full cycle | ||
| 149 | // below runs once, on the first origin — the entry a plane points at the | ||
| 150 | // path it most needs watched. | ||
| 151 | for _, origin := range cfg.MCPURLs[1:] { | ||
| 152 | if err := proveRemoteMCPToolset(ctx, origin, pat); err != nil { | ||
| 153 | return err | ||
| 154 | } | ||
| 155 | fmt.Printf("remote MCP toolset OK at %s\n", origin) | ||
| 156 | } | ||
| 157 | |||
| 120 | // Remote MCP, over the same PAT: no local install, no uploaded CA, nothing | 158 | // Remote MCP, over the same PAT: no local install, no uploaded CA, nothing |
| 121 | // but a token and an HTTP endpoint. The session lives for the whole run. | 159 | // but a token and an HTTP endpoint. The session lives for the whole run. |
| 122 | tools, closeMCP, err := dialMCP(ctx, cfg.ServerURL, pat) | 160 | tools, closeMCP, err := dialMCP(ctx, cfg.MCPURLs[0], pat) |
| 123 | if err != nil { | 161 | if err != nil { |
| 124 | return err | 162 | return err |
| 125 | } | 163 | } |
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,8 @@ declare -A FLOOR=( | |||
| 31 | [internal/agent/exposeproxy]=80 | 31 | [internal/agent/exposeproxy]=80 |
| 32 | [internal/server/api]=76 | 32 | [internal/server/api]=76 |
| 33 | [internal/server/delegation]=90 | 33 | [internal/server/delegation]=90 |
| 34 | [internal/server/seal]=85 | ||
| 35 | [internal/server/sshca]=65 | ||
| 34 | [internal/server/mcphttp]=88 | 36 | [internal/server/mcphttp]=88 |
| 35 | [internal/server/vmssh]=90 | 37 | [internal/server/vmssh]=90 |
| 36 | [internal/server/boot]=20 | 38 | [internal/server/boot]=20 |
| @@ -49,7 +51,7 @@ declare -A FLOOR=( | |||
| 49 | [internal/transport]=77 | 51 | [internal/transport]=77 |
| 50 | [internal/shape]=88 | 52 | [internal/shape]=88 |
| 51 | [internal/site]=84 | 53 | [internal/site]=84 |
| 52 | [internal/smoke]=46 | 54 | [internal/smoke]=50 |
| 53 | [internal/cli]=64 | 55 | [internal/cli]=64 |
| 54 | [internal/mcpserver]=57 | 56 | [internal/mcpserver]=57 |
| 55 | [internal/oidcprovider]=79 | 57 | [internal/oidcprovider]=79 |
scripts/deploy.env.example
| Old | New | ||
|---|---|---|---|
| @@ -26,7 +26,11 @@ SERVER_URL="http://127.0.0.1:8080" # http_listen, for health che | |||
| 26 | 26 | ||
| 27 | # ── Hosts (remote eitri-agent) ──────────────────────────────────────────────── | 27 | # ── Hosts (remote eitri-agent) ──────────────────────────────────────────────── |
| 28 | # Space-separated list of ssh targets, each "user@host[:port]" (port defaults 22). | 28 | # Space-separated list of ssh targets, each "user@host[:port]" (port defaults 22). |
| 29 | AGENT_HOSTS="ubuntu@192.168.0.193:2222" | 29 | # The branch gate's fleet is nested VMs on this workstation: run |
| 30 | # `scripts/devhost.sh create` and paste the AGENT_HOSTS line it prints. Real | ||
| 31 | # hardware gates pre-release tags on stg instead (deploy/server/README.md), so a | ||
| 32 | # hosted outage can never fail the fastest, most-run check in the project. | ||
| 33 | AGENT_HOSTS="ubuntu@192.168.122.50" | ||
| 30 | AGENT_BIN="/usr/local/bin/eitri-agent" # install destination on each host | 34 | AGENT_BIN="/usr/local/bin/eitri-agent" # install destination on each host |
| 31 | AGENT_STATE_DIR="/var/lib/eitri-agent" | 35 | AGENT_STATE_DIR="/var/lib/eitri-agent" |
| 32 | # Agent logs live in journald once the systemd unit is adopted: | 36 | # Agent logs live in journald once the systemd unit is adopted: |
scripts/devhost.cloud-init.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,23 @@ | |||
| 1 | #cloud-config | ||
| 2 | # Seed for a nested dev host (scripts/devhost.sh). It carries the bare minimum | ||
| 3 | # to let `make deploy` in: a user with the workstation's key and passwordless | ||
| 4 | # sudo. Nothing eitri needs is installed here — cloud-hypervisor, the guest | ||
| 5 | # firmware and the agent itself all arrive through the deploy and the agent's | ||
| 6 | # own bootstrap, which is precisely the code path a branch gate should be | ||
| 7 | # exercising rather than skipping. | ||
| 8 | # | ||
| 9 | # ${SSH_AUTHORIZED_KEY} is substituted by devhost.sh from the local key. | ||
| 10 | users: | ||
| 11 | - name: ubuntu | ||
| 12 | sudo: "ALL=(ALL) NOPASSWD:ALL" | ||
| 13 | shell: /bin/bash | ||
| 14 | lock_passwd: true | ||
| 15 | ssh_authorized_keys: | ||
| 16 | - ${SSH_AUTHORIZED_KEY} | ||
| 17 | |||
| 18 | package_update: true | ||
| 19 | packages: | ||
| 20 | - qemu-guest-agent | ||
| 21 | |||
| 22 | runcmd: | ||
| 23 | - [systemctl, enable, --now, qemu-guest-agent] | ||
scripts/devhost.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,185 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # | ||
| 3 | # The branch gate's fleet: a nested KVM host on this workstation, defined here | ||
| 4 | # rather than hand-built, so a wedged dev host is a three-minute rebuild instead | ||
| 5 | # of a debugging session. | ||
| 6 | # | ||
| 7 | # scripts/devhost.sh create [name] build it from nothing | ||
| 8 | # scripts/devhost.sh recycle [name] destroy, then create — the whole point | ||
| 9 | # scripts/devhost.sh destroy [name] | ||
| 10 | # scripts/devhost.sh address [name] print its AGENT_HOSTS line | ||
| 11 | # | ||
| 12 | # Workstation-local on purpose. The branch gate is the most-run check in the | ||
| 13 | # project, and a guest on a remote box would make it fail whenever something | ||
| 14 | # hosted broke — inverting what the gate is for. This host has no network | ||
| 15 | # coupling to any fleet and dies the moment `make deploy` is not running. | ||
| 16 | # | ||
| 17 | # Nested guests get hardware virtualization through --cpu host-passthrough, so | ||
| 18 | # the host CPU must expose nested KVM (kvm_amd nested=1 / kvm_intel nested=1). | ||
| 19 | # What nested CANNOT prove is bridged networking and real-hardware quirks — | ||
| 20 | # that coverage moved to stg, which gates pre-release tags on real metal. | ||
| 21 | # | ||
| 22 | # The seed installs nothing eitri needs: cloud-hypervisor, the guest firmware | ||
| 23 | # and the agent all arrive through `make deploy` and the agent's own bootstrap, | ||
| 24 | # so a cold first deploy exercises that path rather than skipping it. | ||
| 25 | # | ||
| 26 | # Disks live in the libvirt system pool, which is root-owned — the qemu-img and | ||
| 27 | # rm calls below use sudo for that and nothing else. | ||
| 28 | set -euo pipefail | ||
| 29 | |||
| 30 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | ||
| 31 | |||
| 32 | # Pinned guest image. Bump deliberately: take the sha from the SHA256SUMS file | ||
| 33 | # beside the image in the same dated directory. | ||
| 34 | IMAGE_URL="${DEVHOST_IMAGE_URL:-https://cloud-images.ubuntu.com/noble/20260801/noble-server-cloudimg-amd64.img}" | ||
| 35 | IMAGE_SHA256="${DEVHOST_IMAGE_SHA256:-0533b0655c32e68b31d792ecd6ccfca95abdbc536c4446874fe0513bd4140ffe}" | ||
| 36 | OS_VARIANT="${DEVHOST_OS_VARIANT:-ubuntu24.04}" | ||
| 37 | |||
| 38 | # Sized so the agent's admission logic runs against a cap BELOW the machine's | ||
| 39 | # real size, the same shape a bare-metal host runs — which keeps the | ||
| 40 | # admission path honestly covered. The caps themselves go in deploy.env's | ||
| 41 | # AGENT_EXTRA_FLAGS; see the line this script prints. | ||
| 42 | MEM_MB="${DEVHOST_MEM_MB:-16384}" | ||
| 43 | VCPUS="${DEVHOST_VCPUS:-8}" | ||
| 44 | DISK_GB="${DEVHOST_DISK_GB:-120}" | ||
| 45 | |||
| 46 | NETWORK="${DEVHOST_NETWORK:-default}" | ||
| 47 | POOL_DIR="${DEVHOST_POOL_DIR:-/var/lib/libvirt/images}" | ||
| 48 | CACHE_DIR="${DEVHOST_CACHE_DIR:-$HOME/.cache/eitri/devhost}" | ||
| 49 | SSH_KEY="${DEVHOST_SSH_KEY:-$HOME/.ssh/id_ed25519.pub}" | ||
| 50 | SSH_PORT="${DEVHOST_SSH_PORT:-22}" | ||
| 51 | |||
| 52 | bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } | ||
| 53 | die() { printf 'devhost: %s\n' "$*" >&2; exit 1; } | ||
| 54 | |||
| 55 | VERB="${1:-}" | ||
| 56 | NAME="${2:-eitri-dev1}" | ||
| 57 | |||
| 58 | case "$VERB" in | ||
| 59 | create | recycle | destroy | address) ;; | ||
| 60 | *) | ||
| 61 | sed -n '2,20p' "$0" | sed 's/^#\{1,2\} \{0,1\}//' >&2 | ||
| 62 | exit 1 | ||
| 63 | ;; | ||
| 64 | esac | ||
| 65 | |||
| 66 | # Address and MAC are derived from the name, so a recycled host comes back at | ||
| 67 | # the same address and AGENT_HOSTS never has to be edited. The index is the | ||
| 68 | # trailing digits of the name ("eitri-dev2" -> 2 -> .51). | ||
| 69 | index="$(printf '%s' "$NAME" | grep -oE '[0-9]+$' || echo 1)" | ||
| 70 | IP="${DEVHOST_IP:-192.168.122.$((49 + index))}" | ||
| 71 | # Locally-administered QEMU prefix plus three bytes of the name's digest: stable | ||
| 72 | # per name, and distinct enough not to collide with anything else on the bridge. | ||
| 73 | mac_tail="$(printf '%s' "$NAME" | sha256sum | cut -c1-6 | sed 's/../&:/g; s/:$//')" | ||
| 74 | MAC="${DEVHOST_MAC:-52:54:00:$mac_tail}" | ||
| 75 | DISK="$POOL_DIR/$NAME.qcow2" | ||
| 76 | |||
| 77 | need() { command -v "$1" >/dev/null 2>&1 || die "$1 is not installed"; } | ||
| 78 | |||
| 79 | destroy_host() { | ||
| 80 | bold "Destroying $NAME" | ||
| 81 | virsh destroy "$NAME" >/dev/null 2>&1 || true | ||
| 82 | # --remove-all-storage takes the root disk and the cloud-init seed ISO with | ||
| 83 | # the domain; the explicit rm is the belt for a domain that never defined. | ||
| 84 | virsh undefine "$NAME" --nvram --remove-all-storage >/dev/null 2>&1 || true | ||
| 85 | sudo rm -f "$DISK" | ||
| 86 | # The DHCP reservation deliberately survives: it is what makes the address | ||
| 87 | # stable across a recycle, and create re-adds it either way. | ||
| 88 | echo "$NAME is gone (its reservation at $IP is kept)" | ||
| 89 | } | ||
| 90 | |||
| 91 | create_host() { | ||
| 92 | need virsh | ||
| 93 | need virt-install | ||
| 94 | need qemu-img | ||
| 95 | [[ -e /dev/kvm ]] || die "/dev/kvm is missing — nested guests need hardware virtualization" | ||
| 96 | [[ -f "$SSH_KEY" ]] || die "no public key at $SSH_KEY (set DEVHOST_SSH_KEY)" | ||
| 97 | virsh net-info "$NETWORK" >/dev/null 2>&1 || die "libvirt network '$NETWORK' does not exist" | ||
| 98 | virsh net-info "$NETWORK" | grep -q 'Active:.*yes' || die "libvirt network '$NETWORK' is not running (virsh net-start $NETWORK)" | ||
| 99 | if virsh dominfo "$NAME" >/dev/null 2>&1; then | ||
| 100 | die "$NAME already exists — 'recycle' rebuilds it, 'destroy' removes it" | ||
| 101 | fi | ||
| 102 | |||
| 103 | bold "Fetching the guest image" | ||
| 104 | mkdir -p "$CACHE_DIR" | ||
| 105 | cached="$CACHE_DIR/$(basename "$IMAGE_URL")" | ||
| 106 | if [[ ! -f "$cached" ]]; then | ||
| 107 | curl -fsSL -o "$cached.part" "$IMAGE_URL" | ||
| 108 | mv "$cached.part" "$cached" | ||
| 109 | fi | ||
| 110 | echo "$IMAGE_SHA256 $cached" | sha256sum -c - >/dev/null || { | ||
| 111 | rm -f "$cached" | ||
| 112 | die "image sha mismatch — cache purged, re-run" | ||
| 113 | } | ||
| 114 | echo "image ok: $cached" | ||
| 115 | |||
| 116 | bold "Pinning $NAME to $IP on the '$NETWORK' network" | ||
| 117 | virsh net-update "$NETWORK" delete ip-dhcp-host "<host mac='$MAC'/>" \ | ||
| 118 | --live --config >/dev/null 2>&1 || true | ||
| 119 | virsh net-update "$NETWORK" add ip-dhcp-host \ | ||
| 120 | "<host mac='$MAC' name='$NAME' ip='$IP'/>" --live --config >/dev/null | ||
| 121 | |||
| 122 | bold "Creating $NAME ($VCPUS vCPU, $((MEM_MB / 1024)) GB, $DISK_GB GB)" | ||
| 123 | sudo qemu-img convert -O qcow2 "$cached" "$DISK" | ||
| 124 | sudo qemu-img resize "$DISK" "${DISK_GB}G" >/dev/null | ||
| 125 | |||
| 126 | seed="$(mktemp)" | ||
| 127 | trap 'rm -f "$seed"' EXIT | ||
| 128 | SSH_AUTHORIZED_KEY="$(cat "$SSH_KEY")" \ | ||
| 129 | envsubst '${SSH_AUTHORIZED_KEY}' <"$REPO_ROOT/scripts/devhost.cloud-init.yaml" >"$seed" | ||
| 130 | |||
| 131 | virt-install \ | ||
| 132 | --name "$NAME" \ | ||
| 133 | --memory "$MEM_MB" \ | ||
| 134 | --vcpus "$VCPUS" \ | ||
| 135 | --cpu host-passthrough \ | ||
| 136 | --disk "path=$DISK,format=qcow2,bus=virtio" \ | ||
| 137 | --network "network=$NETWORK,mac=$MAC,model=virtio" \ | ||
| 138 | --os-variant "$OS_VARIANT" \ | ||
| 139 | --cloud-init "user-data=$seed" \ | ||
| 140 | --graphics none \ | ||
| 141 | --noautoconsole \ | ||
| 142 | --import | ||
| 143 | rm -f "$seed" | ||
| 144 | trap - EXIT | ||
| 145 | |||
| 146 | # A rebuilt host presents a new host key at an address ssh already knows, | ||
| 147 | # which otherwise fails the first deploy with a warning that looks like an | ||
| 148 | # attack rather than a recycle. | ||
| 149 | ssh-keygen -R "$IP" >/dev/null 2>&1 || true | ||
| 150 | |||
| 151 | bold "Waiting for ssh on $IP" | ||
| 152 | for _ in $(seq 1 60); do | ||
| 153 | if ssh -p "$SSH_PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ | ||
| 154 | -o ConnectTimeout=5 "ubuntu@$IP" true 2>/dev/null; then | ||
| 155 | break | ||
| 156 | fi | ||
| 157 | sleep 5 | ||
| 158 | done | ||
| 159 | ssh -p "$SSH_PORT" -o BatchMode=yes -o ConnectTimeout=5 "ubuntu@$IP" true 2>/dev/null || | ||
| 160 | die "$NAME did not answer ssh at $IP within 5 minutes (virsh console $NAME)" | ||
| 161 | |||
| 162 | print_address | ||
| 163 | } | ||
| 164 | |||
| 165 | print_address() { | ||
| 166 | cat <<-EOF | ||
| 167 | |||
| 168 | $NAME is up. Point the branch gate at it in ~/eitri-deploy/deploy.env: | ||
| 169 | |||
| 170 | AGENT_HOSTS="ubuntu@$IP" | ||
| 171 | AGENT_EXTRA_FLAGS="--max-vcpus $((VCPUS - 2)) --max-mem-mb $((MEM_MB - 4096)) --max-disk-gb $((DISK_GB - 30))" | ||
| 172 | |||
| 173 | then run: make deploy | ||
| 174 | EOF | ||
| 175 | } | ||
| 176 | |||
| 177 | case "$VERB" in | ||
| 178 | create) create_host ;; | ||
| 179 | destroy) destroy_host ;; | ||
| 180 | recycle) | ||
| 181 | destroy_host | ||
| 182 | create_host | ||
| 183 | ;; | ||
| 184 | address) print_address ;; | ||
| 185 | esac | ||
scripts/release.sh
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,8 @@ | |||
| 1 | #!/usr/bin/env bash | 1 | #!/usr/bin/env bash |
| 2 | # Cross-compiled release artifacts for eitri.sh, into dist/<version>/: | 2 | # Cross-compiled release artifacts for eitri.sh, into dist/<version>/: |
| 3 | # eitri_<v>_linux_{amd64,arm64}.tar.gz host bundle: server+agent+systemd unit | 3 | # eitri-server_<v>_linux_{amd64,arm64}.tar.gz Linux host bundle: server+agent+unit |
| 4 | # eitri_<v>_darwin_arm64.tar.gz Mac host bundle: agent + LaunchAgent installer | 4 | # eitri-agent_<v>_darwin_arm64.tar.gz Mac host bundle: agent + LaunchAgent installer |
| 5 | # (a Mac runs guests; it carries no server) | ||
| 5 | # eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin | 6 | # eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin |
| 6 | # eitri-oidc_<v>_linux_{amd64,arm64}.tar.gz bundled OIDC issuer + its unit | 7 | # eitri-oidc_<v>_linux_{amd64,arm64}.tar.gz bundled OIDC issuer + its unit |
| 7 | # (optional sidecar; not in manifest.json) | 8 | # (optional sidecar; not in manifest.json) |
| @@ -64,14 +65,15 @@ trap 'rm -rf "$STAGE_ROOT"' EXIT | |||
| 64 | # bare copies exist only to carry the ones already in the field across. | 65 | # bare copies exist only to carry the ones already in the field across. |
| 65 | # | 66 | # |
| 66 | # NEXT RELEASE, once no agent older than this one is still out there: point | 67 | # NEXT RELEASE, once no agent older than this one is still out there: point |
| 67 | # manifest.json at eitri_<v>_<os>_<arch>.tar.gz (widen the matcher in | 68 | # manifest.json at eitri-server_<v>_linux_<arch>.tar.gz and |
| 69 | # eitri-agent_<v>_darwin_arm64.tar.gz (widen the matcher in | ||
| 68 | # internal/site.BuildManifest) and delete the two `cp` lines below. /dl then | 70 | # internal/site.BuildManifest) and delete the two `cp` lines below. /dl then |
| 69 | # serves tarballs and nothing is shipped twice. cloud-hypervisor and CLOUDHV.fd | 71 | # serves tarballs and nothing is shipped twice. cloud-hypervisor and CLOUDHV.fd |
| 70 | # stay bare in either case — agents bootstrap those directly and they duplicate | 72 | # stay bare in either case — agents bootstrap those directly and they duplicate |
| 71 | # nothing. | 73 | # nothing. |
| 72 | # ------------------------------------------------------------------------------ | 74 | # ------------------------------------------------------------------------------ |
| 73 | for arch in amd64 arm64; do | 75 | for arch in amd64 arm64; do |
| 74 | bundle="eitri_${VERSION}_linux_${arch}" | 76 | bundle="eitri-server_${VERSION}_linux_${arch}" |
| 75 | stage="$STAGE_ROOT/$arch" | 77 | stage="$STAGE_ROOT/$arch" |
| 76 | mkdir -p "$stage/$bundle" | 78 | mkdir -p "$stage/$bundle" |
| 77 | echo "==> building linux/$arch" | 79 | echo "==> building linux/$arch" |
| @@ -98,7 +100,7 @@ done | |||
| 98 | # entitlement in its code signature and so can only come from a signed | 100 | # entitlement in its code signature and so can only come from a signed |
| 99 | # distribution (brew install vfkit), never from a mirror of ours. That is why | 101 | # distribution (brew install vfkit), never from a mirror of ours. That is why |
| 100 | # the darwin agent skips the bootstrap the Linux one runs at startup. | 102 | # the darwin agent skips the bootstrap the Linux one runs at startup. |
| 101 | mac_bundle="eitri_${VERSION}_darwin_arm64" | 103 | mac_bundle="eitri-agent_${VERSION}_darwin_arm64" |
| 102 | mac_stage="$STAGE_ROOT/darwin-arm64" | 104 | mac_stage="$STAGE_ROOT/darwin-arm64" |
| 103 | mkdir -p "$mac_stage/$mac_bundle" | 105 | mkdir -p "$mac_stage/$mac_bundle" |
| 104 | echo "==> building darwin/arm64" | 106 | echo "==> building darwin/arm64" |
scripts/server-image.sh
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,10 @@ | |||
| 3 | # pinned to the cluster's arm64 public-IP node; amd64 installs are served by | 3 | # pinned to the cluster's arm64 public-IP node; amd64 installs are served by |
| 4 | # the release tarballs, not this image). | 4 | # the release tarballs, not this image). |
| 5 | # | 5 | # |
| 6 | # The image carries eitri-oidc as well as eitri-server. A plane with no | ||
| 7 | # external identity provider runs the bundled issuer from this same image, so | ||
| 8 | # the two can never be at different versions. | ||
| 9 | # | ||
| 6 | # Reads deploy.env (same file scripts/deploy.sh uses): | 10 | # Reads deploy.env (same file scripts/deploy.sh uses): |
| 7 | # SERVER_IMAGE registry/repo to push, e.g. registry.example/eitri-server (required) | 11 | # SERVER_IMAGE registry/repo to push, e.g. registry.example/eitri-server (required) |
| 8 | # | 12 | # |
| @@ -24,6 +28,8 @@ BUILD_DIR="$(mktemp -d)" | |||
| 24 | trap 'rm -rf "$BUILD_DIR"' EXIT | 28 | trap 'rm -rf "$BUILD_DIR"' EXIT |
| 25 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \ | 29 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \ |
| 26 | -o "$BUILD_DIR/eitri-server" ./cmd/eitri-server | 30 | -o "$BUILD_DIR/eitri-server" ./cmd/eitri-server |
| 31 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \ | ||
| 32 | -o "$BUILD_DIR/eitri-oidc" ./cmd/eitri-oidc | ||
| 27 | cp deploy/server/Dockerfile "$BUILD_DIR/" | 33 | cp deploy/server/Dockerfile "$BUILD_DIR/" |
| 28 | 34 | ||
| 29 | docker build --platform linux/arm64 -t "$SERVER_IMAGE:$VERSION" "$BUILD_DIR" | 35 | docker build --platform linux/arm64 -t "$SERVER_IMAGE:$VERSION" "$BUILD_DIR" |
scripts/ship.env.example
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,55 @@ | |||
| 1 | # eitri pipeline config — sourced by scripts/ship.sh, one file per plane. | ||
| 2 | # | ||
| 3 | # Copy this to ~/eitri-deploy/<target>/ship.env (override the whole path with | ||
| 4 | # $EITRI_SHIP_ENV) and fill it in. This file is site-specific and points at | ||
| 5 | # secrets — keep it OUT of the repo. What the PLANE is (hostnames, host ports, | ||
| 6 | # namespace, which plane gets backups and a purge) is committed instead, in | ||
| 7 | # deploy/server/plane.<target>.env. | ||
| 8 | # | ||
| 9 | # The same layout as scripts/deploy.env.example on purpose: one convention. | ||
| 10 | |||
| 11 | # ── Registries ──────────────────────────────────────────────────────────────── | ||
| 12 | # ship.sh points the image builds at THIS file, so these are the only place a | ||
| 13 | # plane's registry is named. | ||
| 14 | SERVER_IMAGE="registry.example.com/eitri-server" | ||
| 15 | SITE_IMAGE="registry.example.com/eitri-site" | ||
| 16 | # Image platform for the site image; match the node arch in the plane file. | ||
| 17 | SITE_PLATFORM="linux/arm64" | ||
| 18 | |||
| 19 | # ── The plane's fleet, for the hosted smoke ─────────────────────────────────── | ||
| 20 | # The smoke reads the guest's serial log over ssh to its host and dials that | ||
| 21 | # host's own uplink for the exposure leg, so these are LAN addresses of the | ||
| 22 | # hosts joined to THIS plane — unrelated to where the control plane runs. | ||
| 23 | AGENT_HOSTS="ubuntu@192.168.0.193:2222" | ||
| 24 | AGENT_STATE_DIR="/var/lib/eitri-agent" | ||
| 25 | |||
| 26 | # ── Credentials ─────────────────────────────────────────────────────────────── | ||
| 27 | # Name ONE of the two arrangements. ship.sh forwards whatever it finds here and | ||
| 28 | # clears the rest, so the plane decides and the pipeline stays the same script. | ||
| 29 | # | ||
| 30 | # (a) A plane running the bundled eitri-oidc — stg. The smoke signs in through | ||
| 31 | # the real code flow, which proves the whole credential chain, and mints its | ||
| 32 | # own short-lived PAT for the run: no token to paste, none to rotate. The | ||
| 33 | # identity must exist in the issuer (README, "One-time bring-up"). | ||
| 34 | # CI_USER="ship@eitri.local" | ||
| 35 | # CI_PASSWORD_FILE="$HOME/eitri-deploy/stg/ship-password" | ||
| 36 | # | ||
| 37 | # (b) A plane fronted by a real identity provider — prod. There is no password | ||
| 38 | # to post at Google, so the credential-chain proof is skipped and an | ||
| 39 | # operator-minted, non-expiring PAT carries the run. The smoke derives its | ||
| 40 | # tenant from the token rather than being told. | ||
| 41 | CI_PAT_FILE="$HOME/eitri-deploy/prod/deploy-pat" | ||
| 42 | |||
| 43 | # The smoke's own user CA, load-or-created here and registered with the tenant. | ||
| 44 | SMOKE_USER_CA_FILE="$HOME/eitri-deploy/prod/smoke_user_ca" | ||
| 45 | |||
| 46 | # ── Optional ────────────────────────────────────────────────────────────────── | ||
| 47 | # The MCP origins the smoke exercises, space-separated. The FIRST gets the full | ||
| 48 | # cycle — including the minutes-long vm_create that a proxy's silent-origin | ||
| 49 | # timeout would cut — and the rest get an unauthenticated-401 and a toolset | ||
| 50 | # check. Defaults to "https://<console host> https://<api host>". | ||
| 51 | # SMOKE_MCP_URL="https://stg.eitri.sh https://api.stg.eitri.sh" | ||
| 52 | # SMOKE_VM_USER="ubuntu" | ||
| 53 | # A local CLOUDHV.fd to mirror into the release (scripts/release.sh); without | ||
| 54 | # one the release ships no firmware and agents cannot bootstrap it. | ||
| 55 | # FIRMWARE_SRC="$HOME/.cache/eitri/CLOUDHV.fd" | ||
scripts/ship.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,575 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # | ||
| 3 | # Deploy a tagged tree to one plane. A release is this script run twice: first | ||
| 4 | # against stg with a pre-release tag, then — same script, same stages, the same | ||
| 5 | # artifacts rebuilt from the same tag — against prod. Nothing reaches prod that | ||
| 6 | # a machine has not already done to stg. | ||
| 7 | # | ||
| 8 | # scripts/ship.sh --target <stg|prod> --tag <vX.Y.Z[-pre.N]> [options] | ||
| 9 | # | ||
| 10 | # --from <n> resume at stage n after a partial failure | ||
| 11 | # --skip-smoke stop after the roll (stage 9 still reports) | ||
| 12 | # --render-only print the rendered manifests and exit; touches nothing | ||
| 13 | # | ||
| 14 | # Every stage is idempotent: re-running from the top is always safe and is the | ||
| 15 | # documented default. Every remote or destructive action is gated on --target, | ||
| 16 | # and the plane's values come from deploy/server/plane.<target>.env — the | ||
| 17 | # hostnames, the host-port triple, and which plane gets backups and a purge. | ||
| 18 | # | ||
| 19 | # ONE STEP IS UNREHEARSED, DELIBERATELY: the CDN purge runs at prod and only at | ||
| 20 | # prod. stg is served cache-bypassed, so a purge there would invalidate nothing | ||
| 21 | # and make the prod step look practiced when it is not. Its first real execution | ||
| 22 | # is at promote time. That is why it is kept to a single curl with an explicit | ||
| 23 | # file list — small enough that an unrehearsed run is safe. | ||
| 24 | # | ||
| 25 | # The pipeline reads the plane's config Secret and validates it. It never | ||
| 26 | # writes one: a script that can write config secrets is a script that can | ||
| 27 | # overwrite prod's. | ||
| 28 | # | ||
| 29 | # Site-specific values (registries, credential paths, the plane's fleet) come | ||
| 30 | # from ~/eitri-deploy/<target>/ship.env — see scripts/ship.env.example. No | ||
| 31 | # secrets and no site-specific values live in the repo. | ||
| 32 | set -euo pipefail | ||
| 33 | |||
| 34 | TARGET="" | ||
| 35 | TAG="" | ||
| 36 | FROM=1 | ||
| 37 | SKIP_SMOKE=0 | ||
| 38 | RENDER_ONLY=0 | ||
| 39 | |||
| 40 | usage() { | ||
| 41 | sed -n '2,30p' "$0" | sed 's/^#\{1,2\} \{0,1\}//' | ||
| 42 | exit "${1:-1}" | ||
| 43 | } | ||
| 44 | |||
| 45 | while [[ $# -gt 0 ]]; do | ||
| 46 | case "$1" in | ||
| 47 | --target) TARGET="${2:-}"; shift 2 ;; | ||
| 48 | --tag) TAG="${2:-}"; shift 2 ;; | ||
| 49 | --from) FROM="${2:-}"; shift 2 ;; | ||
| 50 | --skip-smoke) SKIP_SMOKE=1; shift ;; | ||
| 51 | --render-only) RENDER_ONLY=1; shift ;; | ||
| 52 | -h | --help) usage 0 ;; | ||
| 53 | *) echo "ship: unknown argument: $1" >&2; usage ;; | ||
| 54 | esac | ||
| 55 | done | ||
| 56 | |||
| 57 | case "$TARGET" in | ||
| 58 | stg | prod) ;; | ||
| 59 | *) echo "ship: --target must be stg or prod (got '${TARGET}')" >&2; usage ;; | ||
| 60 | esac | ||
| 61 | [[ -n "$TAG" ]] || { echo "ship: --tag is required" >&2; usage; } | ||
| 62 | [[ "$FROM" =~ ^[1-9]$ ]] || { echo "ship: --from must be a stage number 1-9" >&2; exit 1; } | ||
| 63 | |||
| 64 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | ||
| 65 | cd "$REPO_ROOT" | ||
| 66 | |||
| 67 | bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } | ||
| 68 | fail() { printf '\nship: %s\n' "$*" >&2; exit 1; } | ||
| 69 | warn() { printf 'ship: WARNING — %s\n' "$*" >&2; } | ||
| 70 | |||
| 71 | # ── Plane values (committed) and site values (not) ──────────────────────────── | ||
| 72 | PLANE_ENV="$REPO_ROOT/deploy/server/plane.$TARGET.env" | ||
| 73 | [[ -f "$PLANE_ENV" ]] || fail "no plane file for target $TARGET at $PLANE_ENV" | ||
| 74 | # shellcheck disable=SC1090 | ||
| 75 | source "$PLANE_ENV" | ||
| 76 | |||
| 77 | SHIP_ENV="${EITRI_SHIP_ENV:-$HOME/eitri-deploy/$TARGET/ship.env}" | ||
| 78 | if [[ ! -f "$SHIP_ENV" ]]; then | ||
| 79 | fail "config not found: $SHIP_ENV | ||
| 80 | cp scripts/ship.env.example \"$SHIP_ENV\" and edit it." | ||
| 81 | fi | ||
| 82 | # shellcheck disable=SC1090 | ||
| 83 | source "$SHIP_ENV" | ||
| 84 | |||
| 85 | # The build scripts (release/site-image/server-image) read the registry names | ||
| 86 | # from $EITRI_DEPLOY_ENV. Point them at THIS plane's file so a prod run can | ||
| 87 | # never pick up the dev fleet's deploy.env by accident. | ||
| 88 | export EITRI_DEPLOY_ENV="$SHIP_ENV" | ||
| 89 | |||
| 90 | # The variables the templates may reference. envsubst is called with exactly | ||
| 91 | # this list, so an unrelated $FOO added to a manifest later is passed through | ||
| 92 | # rather than blanked — and every name here is checked non-empty first, because | ||
| 93 | # envsubst's own answer to an unset variable is to substitute nothing at all. | ||
| 94 | RENDER_VARS=( | ||
| 95 | NAMESPACE NODE_NAME IMAGE_ARCH | ||
| 96 | CONSOLE_HOST API_HOST GATE_HOST SYNC_HOST SITE_HOST | ||
| 97 | HTTP_PORT SYNC_PORT GATE_PORT | ||
| 98 | CONFIG_SECRET PVC_NAME PVC_SIZE TLS_SECRET SITE_TLS_SECRET | ||
| 99 | SERVER_IMAGE SITE_IMAGE TAG | ||
| 100 | ) | ||
| 101 | # The bundled issuer's values join the list only on a plane that runs one, so a | ||
| 102 | # plane without it never carries them. An ${OIDC_*} reference added to a SHARED | ||
| 103 | # template later would then survive rendering as literal text and fail the apply | ||
| 104 | # loudly, rather than being blanked into a plausible-looking manifest. | ||
| 105 | if [[ "$LOCAL_OIDC" == "1" ]]; then | ||
| 106 | RENDER_VARS+=(OIDC_HOST OIDC_PORT OIDC_TLS_SECRET OIDC_CONFIG_SECRET OIDC_PVC_NAME OIDC_PVC_SIZE) | ||
| 107 | fi | ||
| 108 | export TAG | ||
| 109 | |||
| 110 | require_render_vars() { | ||
| 111 | local missing=() name | ||
| 112 | for name in "${RENDER_VARS[@]}"; do | ||
| 113 | [[ -n "${!name:-}" ]] || missing+=("$name") | ||
| 114 | done | ||
| 115 | if [[ ${#missing[@]} -gt 0 ]]; then | ||
| 116 | fail "unset or empty: ${missing[*]} | ||
| 117 | plane values live in $PLANE_ENV; site values in $SHIP_ENV" | ||
| 118 | fi | ||
| 119 | export "${RENDER_VARS[@]?}" | ||
| 120 | } | ||
| 121 | |||
| 122 | # The manifest sets, in the order they are applied. The shape goes on first; the | ||
| 123 | # two Deployments follow in separate stages because their ORDER is load-bearing | ||
| 124 | # (see stage 6). | ||
| 125 | SHAPE_MANIFESTS=( | ||
| 126 | namespace.yaml middleware.yaml | ||
| 127 | pvc.yaml service.yaml certificate.yaml ingressroute.yaml | ||
| 128 | site-service.yaml site-certificate.yaml site-ingressroute.yaml | ||
| 129 | ) | ||
| 130 | if [[ "$BACKUPS" == "1" ]]; then | ||
| 131 | SHAPE_MANIFESTS+=(backup-cronjob.yaml) | ||
| 132 | fi | ||
| 133 | if [[ "$LOCAL_OIDC" == "1" ]]; then | ||
| 134 | SHAPE_MANIFESTS+=(oidc.yaml) | ||
| 135 | fi | ||
| 136 | |||
| 137 | # The Deployments that carry the release tag, rolled in stage 7. The issuer runs | ||
| 138 | # from the server's image so the two are always the same build. | ||
| 139 | SERVER_MANIFESTS=(deployment.yaml) | ||
| 140 | if [[ "$LOCAL_OIDC" == "1" ]]; then | ||
| 141 | SERVER_MANIFESTS+=(oidc-deployment.yaml) | ||
| 142 | fi | ||
| 143 | |||
| 144 | # render prints one manifest with this plane's values substituted. | ||
| 145 | render() { | ||
| 146 | local vars="" name | ||
| 147 | for name in "${RENDER_VARS[@]}"; do vars+="\${$name} "; done | ||
| 148 | envsubst "$vars" <"$REPO_ROOT/deploy/server/$1" | ||
| 149 | } | ||
| 150 | |||
| 151 | render_all() { | ||
| 152 | local f | ||
| 153 | for f in "${SHAPE_MANIFESTS[@]}" site-deployment.yaml "${SERVER_MANIFESTS[@]}"; do | ||
| 154 | echo "---" | ||
| 155 | echo "# deploy/server/$f" | ||
| 156 | render "$f" | ||
| 157 | done | ||
| 158 | } | ||
| 159 | |||
| 160 | # ── --render-only: read the manifests, touch nothing ────────────────────────── | ||
| 161 | if [[ "$RENDER_ONLY" == "1" ]]; then | ||
| 162 | require_render_vars | ||
| 163 | render_all | ||
| 164 | exit 0 | ||
| 165 | fi | ||
| 166 | |||
| 167 | bold "Shipping $TAG to $TARGET (namespace $NAMESPACE)" | ||
| 168 | echo "console https://$CONSOLE_HOST api https://$API_HOST site https://$SITE_HOST" | ||
| 169 | echo "gate $GATE_HOST:$GATE_PORT sync $SYNC_HOST:$SYNC_PORT http :$HTTP_PORT" | ||
| 170 | [[ "$FROM" -gt 1 ]] && echo "(resuming at stage $FROM)" | ||
| 171 | |||
| 172 | # ── 1. Verify the tag ───────────────────────────────────────────────────────── | ||
| 173 | if [[ "$FROM" -le 1 ]]; then | ||
| 174 | bold "1. Verify the tag" | ||
| 175 | git diff --quiet || fail "working tree has unstaged changes; ship from a clean tagged tree" | ||
| 176 | git diff --cached --quiet || fail "working tree has staged changes; ship from a clean tagged tree" | ||
| 177 | git rev-parse "$TAG^{commit}" >/dev/null 2>&1 || fail "tag $TAG does not resolve to a commit" | ||
| 178 | described="$(git describe --tags --exact-match 2>/dev/null || true)" | ||
| 179 | [[ "$described" == "$TAG" ]] || fail "HEAD is at '${described:-no tag}', not $TAG — check out the tag first" | ||
| 180 | |||
| 181 | # The version must be parsable by the SAME code the fleet orders versions | ||
| 182 | # with, because an unparsable one silently disables the upgrade button | ||
| 183 | # everywhere at once. Ask that code rather than re-deriving its rule here: | ||
| 184 | # any parsable version sorts below an absurdly high one, and an unparsable | ||
| 185 | # version sorts below nothing. | ||
| 186 | probe="$(mktemp -d "$REPO_ROOT/.shipcheck.XXXXXX")" | ||
| 187 | trap 'rm -rf "$probe"' EXIT | ||
| 188 | cat >"$probe/main.go" <<-'PROBE' | ||
| 189 | package main | ||
| 190 | |||
| 191 | import ( | ||
| 192 | "fmt" | ||
| 193 | "os" | ||
| 194 | |||
| 195 | "github.com/a73x/eitri/internal/server/release" | ||
| 196 | ) | ||
| 197 | |||
| 198 | func main() { | ||
| 199 | if !release.Less(os.Args[1], "v9999.0.0") { | ||
| 200 | fmt.Println("unparsable") | ||
| 201 | return | ||
| 202 | } | ||
| 203 | fmt.Println("parsable") | ||
| 204 | } | ||
| 205 | PROBE | ||
| 206 | parsable="$(go run "./$(basename "$probe")" "$TAG")" | ||
| 207 | rm -rf "$probe" | ||
| 208 | trap - EXIT | ||
| 209 | if [[ "$parsable" != "parsable" ]]; then | ||
| 210 | fail "$TAG is unparsable to internal/server/release.Less. | ||
| 211 | Every agent orders versions with that code, so shipping this tag would | ||
| 212 | disable the upgrade button fleet-wide. Releases are vX.Y.Z and | ||
| 213 | pre-releases are vX.Y.Z-pre.N." | ||
| 214 | fi | ||
| 215 | echo "tag ok: $TAG at $(git rev-parse --short HEAD)" | ||
| 216 | fi | ||
| 217 | |||
| 218 | # ── 2. Build the release artifacts ──────────────────────────────────────────── | ||
| 219 | # The manifest base is the one build input the two planes legitimately differ | ||
| 220 | # on, and it is why the stg run PROVES the download and upgrade paths rather | ||
| 221 | # than merely rehearsing them: the artifacts stg publishes name stg's own /dl. | ||
| 222 | if [[ "$FROM" -le 2 ]]; then | ||
| 223 | bold "2. Build release artifacts -> dist/$TAG" | ||
| 224 | MANIFEST_BASE="https://$SITE_HOST/dl/$TAG" "$REPO_ROOT/scripts/release.sh" | ||
| 225 | fi | ||
| 226 | |||
| 227 | # ── 3. Build and push the images ────────────────────────────────────────────── | ||
| 228 | # | ||
| 229 | # The site image bakes dist/$TAG in, so the artifacts stage 2 built are what | ||
| 230 | # /dl serves — the two image builds are called directly rather than through | ||
| 231 | # `make site-image`, whose own `make release` would rebuild those artifacts | ||
| 232 | # against the default manifest base and undo stage 2. | ||
| 233 | if [[ "$FROM" -le 3 ]]; then | ||
| 234 | bold "3. Build and push images at $TAG" | ||
| 235 | [[ -d "dist/$TAG" ]] || fail "dist/$TAG missing — run from stage 2" | ||
| 236 | make -C "$REPO_ROOT" web | ||
| 237 | "$REPO_ROOT/scripts/server-image.sh" | ||
| 238 | make -C "$REPO_ROOT" site SITE_DIST="dist/$TAG" | ||
| 239 | "$REPO_ROOT/scripts/site-image.sh" | ||
| 240 | fi | ||
| 241 | |||
| 242 | # ── 4. Config schema and value check ────────────────────────────────────────── | ||
| 243 | # The stage that justifies the script. Two checks, both against the Secret this | ||
| 244 | # plane is about to run with: does the tagged tree's schema agree with the | ||
| 245 | # committed contract, and does the Secret agree with the plane's manifests? | ||
| 246 | # | ||
| 247 | # The Secret is read, never written, and never printed. Schema failures name | ||
| 248 | # keys only. The plane-agreement checks below DO print the values they compare — | ||
| 249 | # listen addresses and public hostnames, none of them secret, and the mismatch | ||
| 250 | # is the whole point of the check. | ||
| 251 | if [[ "$FROM" -le 4 ]]; then | ||
| 252 | bold "4. Check the config schema and the plane's values" | ||
| 253 | require_render_vars | ||
| 254 | |||
| 255 | secret_json="$(kubectl -n "$NAMESPACE" get secret "$CONFIG_SECRET" \ | ||
| 256 | -o jsonpath='{.data.server\.json}' 2>/dev/null | base64 -d)" || | ||
| 257 | fail "cannot read secret $CONFIG_SECRET in namespace $NAMESPACE. | ||
| 258 | Create it once by hand (the pipeline never writes config secrets): | ||
| 259 | kubectl -n $NAMESPACE create secret generic $CONFIG_SECRET \\ | ||
| 260 | --from-file=server.json=\$HOME/eitri-deploy/$TARGET/server.json" | ||
| 261 | printf '%s' "$secret_json" | jq -e . >/dev/null 2>&1 || | ||
| 262 | fail "secret $CONFIG_SECRET does not hold a server.json key with valid JSON" | ||
| 263 | |||
| 264 | CONTRACT="$REPO_ROOT/deploy/server/config.required" | ||
| 265 | SCHEMA_SRC="$REPO_ROOT/internal/server/config/config.go" | ||
| 266 | |||
| 267 | # Inventory the keys the TAGGED TREE declares, as JSON paths. Nested structs | ||
| 268 | # reach a path through a prefix declared in the contract, so a new nested | ||
| 269 | # struct cannot slip in unclassified — it is reported here by name. | ||
| 270 | schema_keys="$(awk ' | ||
| 271 | FNR == NR { | ||
| 272 | if ($0 ~ /^# struct-prefix:/) { | ||
| 273 | line = $0 | ||
| 274 | sub(/^# struct-prefix:[ \t]*/, "", line) | ||
| 275 | eq = index(line, "=") | ||
| 276 | prefix[substr(line, 1, eq - 1)] = substr(line, eq + 1) | ||
| 277 | } | ||
| 278 | next | ||
| 279 | } | ||
| 280 | /^type [A-Za-z_]+ struct \{/ { st = $2; next } | ||
| 281 | st != "" && /^\}/ { st = ""; next } | ||
| 282 | st != "" && match($0, /json:"[^"]+"/) { | ||
| 283 | tag = substr($0, RSTART + 6, RLENGTH - 7) | ||
| 284 | sub(/,.*/, "", tag) | ||
| 285 | if (tag == "" || tag == "-") next | ||
| 286 | if (!(st in prefix)) { print "!" st; next } | ||
| 287 | print prefix[st] tag | ||
| 288 | } | ||
| 289 | ' "$CONTRACT" "$SCHEMA_SRC")" | ||
| 290 | |||
| 291 | undeclared="$(printf '%s\n' "$schema_keys" | grep '^!' | sort -u | tr -d '!' || true)" | ||
| 292 | if [[ -n "$undeclared" ]]; then | ||
| 293 | fail "the tagged tree declares config struct(s) with no JSON path: | ||
| 294 | $(echo "$undeclared" | tr '\n' ' ') | ||
| 295 | Add a '# struct-prefix: <Struct>=<json.path.>' line to | ||
| 296 | deploy/server/config.required and classify the keys it carries." | ||
| 297 | fi | ||
| 298 | |||
| 299 | # The contract's own key list, for the two set comparisons below. | ||
| 300 | contract_keys="$(grep -v '^[[:space:]]*#' "$CONTRACT" | awk 'NF {print $1}')" | ||
| 301 | |||
| 302 | # jq_path turns a contract path into a jq expression. A "*" segment stands | ||
| 303 | # for a map key and iterates every entry. | ||
| 304 | jq_path() { printf '.%s' "${1//.\*./[].}"; } | ||
| 305 | # Collect the path's values and require EVERY one of them: a wildcard path | ||
| 306 | # holds for all the map's entries or for none. A plane with an arm64 image | ||
| 307 | # and a blank amd64 one is not half-configured, it is broken for half its | ||
| 308 | # hosts. An empty collection is a missing key. | ||
| 309 | cfg_has() { | ||
| 310 | printf '%s' "$secret_json" | | ||
| 311 | jq -e "[$(jq_path "$1")] | length > 0 and all(. != null and . != \"\")" >/dev/null 2>&1 | ||
| 312 | } | ||
| 313 | |||
| 314 | schema_fail=0 | ||
| 315 | while read -r key status; do | ||
| 316 | [[ -z "$key" || "$key" == \#* ]] && continue | ||
| 317 | case "$status" in | ||
| 318 | required) | ||
| 319 | if ! cfg_has "$key"; then | ||
| 320 | echo "FAIL: $CONFIG_SECRET does not set required key '$key'" >&2 | ||
| 321 | echo " patch it in (values never come from this repo):" >&2 | ||
| 322 | echo " kubectl -n $NAMESPACE get secret $CONFIG_SECRET -o jsonpath='{.data.server\\.json}' | base64 -d > /tmp/server.json" >&2 | ||
| 323 | echo " # add \"$key\", then:" >&2 | ||
| 324 | echo " kubectl -n $NAMESPACE create secret generic $CONFIG_SECRET --from-file=server.json=/tmp/server.json --dry-run=client -o yaml | kubectl apply -f -" >&2 | ||
| 325 | schema_fail=1 | ||
| 326 | fi | ||
| 327 | ;; | ||
| 328 | retired) | ||
| 329 | cfg_has "$key" && warn "$CONFIG_SECRET still sets retired key '$key'; the server ignores it" | ||
| 330 | ;; | ||
| 331 | optional) ;; | ||
| 332 | *) fail "$CONTRACT classifies '$key' as '$status'; want required, optional or retired" ;; | ||
| 333 | esac | ||
| 334 | # A contract entry the schema no longer declares is stale documentation, | ||
| 335 | # not a deployment hazard — say so and carry on. | ||
| 336 | grep -qxF "$key" <<<"$schema_keys" || | ||
| 337 | warn "$CONTRACT classifies '$key', which the tagged tree no longer declares" | ||
| 338 | done < <(grep -v '^[[:space:]]*#' "$CONTRACT" | grep -v '^[[:space:]]*$') | ||
| 339 | |||
| 340 | # The other direction, and the one the v0.0.3 incident needed: a key the tree | ||
| 341 | # grew that nobody classified. | ||
| 342 | while read -r key; do | ||
| 343 | [[ -z "$key" ]] && continue | ||
| 344 | grep -qxF "$key" <<<"$contract_keys" || { | ||
| 345 | echo "FAIL: the tagged tree grew config key '$key'" >&2 | ||
| 346 | echo " classify it in deploy/server/config.required and, if it is required," >&2 | ||
| 347 | echo " set it in the $NAMESPACE Secret $CONFIG_SECRET before shipping." >&2 | ||
| 348 | schema_fail=1 | ||
| 349 | } | ||
| 350 | done <<<"$schema_keys" | ||
| 351 | |||
| 352 | # Plane agreement. Reported in the same pass as the schema findings above, | ||
| 353 | # not after a re-run: this stage exists to be hit, and an operator fixing a | ||
| 354 | # config should see everything wrong with it at once. Each of these disagreeing is a hosted-shape failure that | ||
| 355 | # presents as something else entirely: a wrong gate domain reads as "pubkey | ||
| 356 | # denied", a wrong advertise_quic as a host that enrolls and never syncs. | ||
| 357 | agree_fail=0 | ||
| 358 | assert_cfg() { | ||
| 359 | local path="$1" want="$2" got | ||
| 360 | got="$(printf '%s' "$secret_json" | jq -r "$(jq_path "$path") // \"\"")" | ||
| 361 | if [[ "$got" != "$want" ]]; then | ||
| 362 | echo "FAIL: $CONFIG_SECRET has $path = '$got', but this plane's manifests say '$want'" >&2 | ||
| 363 | agree_fail=1 | ||
| 364 | fi | ||
| 365 | } | ||
| 366 | assert_cfg http_listen ":$HTTP_PORT" | ||
| 367 | assert_cfg quic_listen ":$SYNC_PORT" | ||
| 368 | assert_cfg ssh_listen ":$GATE_PORT" | ||
| 369 | assert_cfg ssh_gate_domain "$GATE_HOST" | ||
| 370 | assert_cfg advertise_http "https://$CONSOLE_HOST" | ||
| 371 | assert_cfg advertise_quic "$SYNC_HOST:$SYNC_PORT" | ||
| 372 | assert_cfg oidc.public_url "https://$CONSOLE_HOST" | ||
| 373 | |||
| 374 | # A plane running the bundled issuer has a second config to keep in step, and | ||
| 375 | # the two ways it drifts both present as something else: a server pointed at | ||
| 376 | # the wrong issuer fails discovery ("sign-in temporarily unavailable"), and a | ||
| 377 | # redirect_url that does not match the server's callback exactly is a 400 at | ||
| 378 | # the end of an otherwise working login. Neither says what is actually wrong. | ||
| 379 | if [[ "$LOCAL_OIDC" == "1" ]]; then | ||
| 380 | assert_cfg oidc.issuer "https://$OIDC_HOST" | ||
| 381 | issuer_json="$(kubectl -n "$NAMESPACE" get secret "$OIDC_CONFIG_SECRET" \ | ||
| 382 | -o jsonpath='{.data.eitri-oidc\.json}' 2>/dev/null | base64 -d)" || | ||
| 383 | fail "cannot read secret $OIDC_CONFIG_SECRET in namespace $NAMESPACE. | ||
| 384 | This plane runs the bundled issuer; create its config once by hand | ||
| 385 | (see deploy/server/README.md, 'One-time bring-up of a plane')." | ||
| 386 | assert_issuer() { | ||
| 387 | local path="$1" want="$2" got | ||
| 388 | got="$(printf '%s' "$issuer_json" | jq -r "$path // \"\"")" | ||
| 389 | if [[ "$got" != "$want" ]]; then | ||
| 390 | echo "FAIL: $OIDC_CONFIG_SECRET has $path = '$got', want '$want'" >&2 | ||
| 391 | agree_fail=1 | ||
| 392 | fi | ||
| 393 | } | ||
| 394 | assert_issuer .issuer "https://$OIDC_HOST" | ||
| 395 | assert_issuer .listen ":$OIDC_PORT" | ||
| 396 | assert_issuer '.clients[0].redirect_url' "https://$CONSOLE_HOST/auth/callback" | ||
| 397 | # The client the server presents must be one the issuer knows. | ||
| 398 | server_client="$(printf '%s' "$secret_json" | jq -r '.oidc.client_id // ""')" | ||
| 399 | printf '%s' "$issuer_json" | jq -e --arg id "$server_client" \ | ||
| 400 | '[.clients[].id] | index($id) != null' >/dev/null 2>&1 || | ||
| 401 | { | ||
| 402 | echo "FAIL: the server signs in as client '$server_client', which $OIDC_CONFIG_SECRET does not register" >&2 | ||
| 403 | agree_fail=1 | ||
| 404 | } | ||
| 405 | fi | ||
| 406 | |||
| 407 | if [[ "$schema_fail" != "0" || "$agree_fail" != "0" ]]; then | ||
| 408 | fail "$NAMESPACE's config and the $TAG tree do not agree (see above); nothing was deployed" | ||
| 409 | fi | ||
| 410 | echo "config ok: schema classified, required keys present, plane values agree" | ||
| 411 | fi | ||
| 412 | |||
| 413 | # ── 5. Render and apply the shape ───────────────────────────────────────────── | ||
| 414 | # Everything except the two Deployments: namespace, middleware, storage, | ||
| 415 | # service, certificates and routes. Applying these is idempotent and cannot | ||
| 416 | # restart anything. | ||
| 417 | if [[ "$FROM" -le 5 ]]; then | ||
| 418 | bold "5. Apply the plane's shape to $NAMESPACE" | ||
| 419 | require_render_vars | ||
| 420 | for f in "${SHAPE_MANIFESTS[@]}"; do | ||
| 421 | echo " $f" | ||
| 422 | render "$f" | kubectl apply -f - | ||
| 423 | done | ||
| 424 | fi | ||
| 425 | |||
| 426 | # ── 6. Roll the site, then purge ────────────────────────────────────────────── | ||
| 427 | # ORDER IS LOAD-BEARING. The server fetches the release manifest at boot and | ||
| 428 | # pins the answer for 24 hours, so a server rolled ahead of its site serves the | ||
| 429 | # previous release to the whole fleet for a day. | ||
| 430 | if [[ "$FROM" -le 6 ]]; then | ||
| 431 | bold "6. Roll the site to $TAG (before the server)" | ||
| 432 | require_render_vars | ||
| 433 | render site-deployment.yaml | kubectl apply -f - | ||
| 434 | kubectl -n "$NAMESPACE" rollout status deployment/web --timeout=5m | ||
| 435 | echo "site serving $TAG at https://$SITE_HOST/dl/$TAG/" | ||
| 436 | |||
| 437 | PURGED="not attempted" | ||
| 438 | if [[ "$CDN_PURGE" != "1" ]]; then | ||
| 439 | echo "purge: skipped — $TARGET is served cache-bypassed, so there is nothing to invalidate" | ||
| 440 | PURGED="not applicable at $TARGET" | ||
| 441 | else | ||
| 442 | CF_ENV="${EITRI_CF_ENV:-$HOME/eitri-deploy/pipeline/cf-purge.env}" | ||
| 443 | if [[ ! -f "$CF_ENV" ]]; then | ||
| 444 | if [[ "${ALLOW_MANUAL_PURGE:-0}" == "1" ]]; then | ||
| 445 | warn "no $CF_ENV — purge NOT run. Do it by hand before announcing: | ||
| 446 | every URL under https://$SITE_HOST/dl/$TAG/, plus /dl/latest/manifest.json, | ||
| 447 | /dl/ and /docs/releases/. Serving a stale artifact is how a release breaks." | ||
| 448 | PURGED="DEFERRED — run it by hand" | ||
| 449 | else | ||
| 450 | fail "no $CF_ENV, so the CDN purge cannot run. | ||
| 451 | /dl is served immutable: without a purge the CDN keeps serving the | ||
| 452 | previous bytes. Create the file with CF_ZONE_ID and CF_API_TOKEN, or | ||
| 453 | re-run with ALLOW_MANUAL_PURGE=1 to say out loud that you will do it | ||
| 454 | by hand." | ||
| 455 | fi | ||
| 456 | else | ||
| 457 | # shellcheck disable=SC1090 | ||
| 458 | source "$CF_ENV" | ||
| 459 | : "${CF_ZONE_ID:?set in $CF_ENV}" "${CF_API_TOKEN:?set in $CF_ENV}" | ||
| 460 | # An explicit file list, never a zone-wide purge: the tempting | ||
| 461 | # simplification would evict the whole site's cache on every release. | ||
| 462 | purge_urls="$( | ||
| 463 | { | ||
| 464 | find "dist/$TAG" -maxdepth 1 -type f -printf "https://$SITE_HOST/dl/$TAG/%f\n" | ||
| 465 | printf 'https://%s/dl/latest/manifest.json\n' "$SITE_HOST" | ||
| 466 | printf 'https://%s/dl/\n' "$SITE_HOST" | ||
| 467 | printf 'https://%s/docs/releases/\n' "$SITE_HOST" | ||
| 468 | } | jq -R . | jq -s . | ||
| 469 | )" | ||
| 470 | # The API takes at most 30 files per call. | ||
| 471 | while read -r batch; do | ||
| 472 | curl -fsS -X POST \ | ||
| 473 | "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \ | ||
| 474 | -H "Authorization: Bearer $CF_API_TOKEN" \ | ||
| 475 | -H "Content-Type: application/json" \ | ||
| 476 | --data "$batch" >/dev/null | ||
| 477 | done < <(jq -c --argjson u "$purge_urls" -n \ | ||
| 478 | '[range(0; ($u | length); 30) as $i | {files: $u[$i:$i+30]}][]') | ||
| 479 | echo "purge: $(jq 'length' <<<"$purge_urls") URLs invalidated" | ||
| 480 | PURGED="ran" | ||
| 481 | fi | ||
| 482 | fi | ||
| 483 | fi | ||
| 484 | |||
| 485 | # ── 7. Roll the server ──────────────────────────────────────────────────────── | ||
| 486 | # Recreate strategy: a brief control-plane gap. Agents redial on their own. | ||
| 487 | if [[ "$FROM" -le 7 ]]; then | ||
| 488 | bold "7. Roll the control plane to $TAG" | ||
| 489 | require_render_vars | ||
| 490 | for f in "${SERVER_MANIFESTS[@]}"; do | ||
| 491 | echo " $f" | ||
| 492 | render "$f" | kubectl apply -f - | ||
| 493 | done | ||
| 494 | kubectl -n "$NAMESPACE" rollout status deployment/eitri-server --timeout=5m | ||
| 495 | if [[ "$LOCAL_OIDC" == "1" ]]; then | ||
| 496 | kubectl -n "$NAMESPACE" rollout status deployment/eitri-oidc --timeout=5m | ||
| 497 | fi | ||
| 498 | fi | ||
| 499 | |||
| 500 | # ── 8. Hosted smoke ─────────────────────────────────────────────────────────── | ||
| 501 | # The same binary the branch gate runs, pointed at this plane's public names. | ||
| 502 | # The host-facing legs are unchanged by where the control plane lives: the | ||
| 503 | # boot proof reads a serial log over ssh to the agent host and the exposure leg | ||
| 504 | # dials that host's own uplink. What IS new is that the gate leg now crosses the | ||
| 505 | # internet, so GATE_HOST:GATE_PORT must be reachable from here. | ||
| 506 | # | ||
| 507 | # Which credentials the run uses is the plane's business, not this script's: it | ||
| 508 | # forwards whatever ship.env names and nothing else. A plane with a password | ||
| 509 | # issuer sets CI_USER and gets the credential-chain proof plus a freshly minted | ||
| 510 | # PAT; a plane fronted by a real identity provider sets an operator PAT instead, | ||
| 511 | # because there is no password to post at Google. No COVER_OUT either way — the | ||
| 512 | # hosted binaries are not coverage-instrumented. | ||
| 513 | if [[ "$FROM" -le 8 && "$SKIP_SMOKE" != "1" ]]; then | ||
| 514 | bold "8. Hosted smoke against $TARGET" | ||
| 515 | : "${AGENT_HOSTS:?set in $SHIP_ENV}" "${AGENT_STATE_DIR:?}" | ||
| 516 | if [[ -z "${CI_USER:-}" && -z "${CI_PAT_FILE:-}" ]]; then | ||
| 517 | fail "$SHIP_ENV names no credential for the smoke. | ||
| 518 | Set CI_USER + CI_PASSWORD_FILE for a plane with a password issuer, or | ||
| 519 | CI_PAT_FILE for one fronted by an external identity provider." | ||
| 520 | fi | ||
| 521 | go build -o "$REPO_ROOT/bin/eitri-smoke" ./cmd/eitri-smoke | ||
| 522 | |||
| 523 | # Both origins by default: the console host is the one a proxy fronts and | ||
| 524 | # carries the long call, and the API host proves the /mcp-only rule routes | ||
| 525 | # and authenticates with nothing in front of it. | ||
| 526 | SMOKE_MCP_URL="${SMOKE_MCP_URL:-https://$CONSOLE_HOST https://$API_HOST}" | ||
| 527 | SMOKE_USER_CA_FILE="${SMOKE_USER_CA_FILE:-$HOME/eitri-deploy/$TARGET/smoke_user_ca}" | ||
| 528 | smoke_env=( | ||
| 529 | SERVER_URL="https://$CONSOLE_HOST" | ||
| 530 | SMOKE_GATE="$GATE_HOST:$GATE_PORT" | ||
| 531 | SMOKE_MCP_URL="$SMOKE_MCP_URL" | ||
| 532 | SMOKE_USER_CA_FILE="$SMOKE_USER_CA_FILE" | ||
| 533 | AGENT_HOSTS="$AGENT_HOSTS" | ||
| 534 | AGENT_STATE_DIR="$AGENT_STATE_DIR" | ||
| 535 | ) | ||
| 536 | if [[ -n "${CI_USER:-}" ]]; then | ||
| 537 | : "${CI_PASSWORD_FILE:?set it alongside CI_USER in $SHIP_ENV}" | ||
| 538 | smoke_env+=(CI_USER="$CI_USER" CI_PASSWORD_FILE="$CI_PASSWORD_FILE") | ||
| 539 | fi | ||
| 540 | if [[ -n "${CI_PAT_FILE:-}" ]]; then | ||
| 541 | smoke_env+=(CI_PAT_FILE="$CI_PAT_FILE") | ||
| 542 | fi | ||
| 543 | if [[ -n "${SMOKE_VM_USER:-}" ]]; then | ||
| 544 | smoke_env+=(SMOKE_VM_USER="$SMOKE_VM_USER") | ||
| 545 | fi | ||
| 546 | # Clear the trio first so an ambient value from this shell can never stand in | ||
| 547 | # for one the plane did not name. | ||
| 548 | env -u CI_USER -u CI_PASSWORD_FILE -u CI_PAT_FILE "${smoke_env[@]}" \ | ||
| 549 | "$REPO_ROOT/bin/eitri-smoke" || | ||
| 550 | fail "hosted smoke FAILED against $TARGET — the plane is serving $TAG and is not proven." | ||
| 551 | elif [[ "$SKIP_SMOKE" == "1" ]]; then | ||
| 552 | bold "8. Hosted smoke SKIPPED (--skip-smoke)" | ||
| 553 | fi | ||
| 554 | |||
| 555 | # ── 9. Report ───────────────────────────────────────────────────────────────── | ||
| 556 | bold "9. $TARGET is serving $TAG" | ||
| 557 | report_deployments=(eitri-server web) | ||
| 558 | if [[ "$LOCAL_OIDC" == "1" ]]; then | ||
| 559 | report_deployments+=(eitri-oidc) | ||
| 560 | fi | ||
| 561 | kubectl -n "$NAMESPACE" get deployment "${report_deployments[@]}" \ | ||
| 562 | -o custom-columns='DEPLOYMENT:.metadata.name,IMAGE:.spec.template.spec.containers[0].image,READY:.status.readyReplicas' || | ||
| 563 | true | ||
| 564 | echo "console https://$CONSOLE_HOST" | ||
| 565 | if [[ "$LOCAL_OIDC" == "1" ]]; then | ||
| 566 | echo "issuer https://$OIDC_HOST" | ||
| 567 | fi | ||
| 568 | echo "mcp https://$CONSOLE_HOST/mcp (proxied) and https://$API_HOST/mcp" | ||
| 569 | echo "downloads https://$SITE_HOST/dl/$TAG/" | ||
| 570 | echo "cdn purge: ${PURGED:-not reached this run}" | ||
| 571 | # An if, not a trailing &&: as the script's last command, a false conditional | ||
| 572 | # would be the whole run's exit status. | ||
| 573 | if [[ "$SKIP_SMOKE" == "1" ]]; then | ||
| 574 | echo "smoke: SKIPPED — this plane is deployed, not proven" | ||
| 575 | fi | ||
web/src/routes/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -175,10 +175,10 @@ | |||
| 175 | {#if joinBlob} | 175 | {#if joinBlob} |
| 176 | <p>Download and verify the host bundle, then run on the new host:</p> | 176 | <p>Download and verify the host bundle, then run on the new host:</p> |
| 177 | <div class="enroll"> | 177 | <div class="enroll"> |
| 178 | <code>curl -fsSLO "https://eitri.sh/dl/{dlVersion}/eitri_{dlVersion}_linux_amd64.tar.gz"</code> | 178 | <code>curl -fsSLO "https://eitri.sh/dl/{dlVersion}/eitri-server_{dlVersion}_linux_amd64.tar.gz"</code> |
| 179 | <code>curl -fsSLO "https://eitri.sh/dl/{dlVersion}/SHA256SUMS"</code> | 179 | <code>curl -fsSLO "https://eitri.sh/dl/{dlVersion}/SHA256SUMS"</code> |
| 180 | <code>sha256sum -c SHA256SUMS --ignore-missing</code> | 180 | <code>sha256sum -c SHA256SUMS --ignore-missing</code> |
| 181 | <code>tar xzf eitri_{dlVersion}_linux_amd64.tar.gz && cd eitri_{dlVersion}_linux_amd64</code> | 181 | <code>tar xzf eitri-server_{dlVersion}_linux_amd64.tar.gz && cd eitri-server_{dlVersion}_linux_amd64</code> |
| 182 | <code>sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent</code> | 182 | <code>sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent</code> |
| 183 | <code>sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service</code> | 183 | <code>sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service</code> |
| 184 | <code>sudo eitri-agent --state-dir /var/lib/eitri-agent join {joinBlob}</code> | 184 | <code>sudo eitri-agent --state-dir /var/lib/eitri-agent join {joinBlob}</code> |
| @@ -193,10 +193,10 @@ | |||
| 193 | {#if joinBlob} | 193 | {#if joinBlob} |
| 194 | <div class="enroll"> | 194 | <div class="enroll"> |
| 195 | Download and verify the host bundle, then run on the new host: | 195 | Download and verify the host bundle, then run on the new host: |
| 196 | <code>curl -fsSLO "https://eitri.sh/dl/{dlVersion}/eitri_{dlVersion}_linux_amd64.tar.gz"</code> | 196 | <code>curl -fsSLO "https://eitri.sh/dl/{dlVersion}/eitri-server_{dlVersion}_linux_amd64.tar.gz"</code> |
| 197 | <code>curl -fsSLO "https://eitri.sh/dl/{dlVersion}/SHA256SUMS"</code> | 197 | <code>curl -fsSLO "https://eitri.sh/dl/{dlVersion}/SHA256SUMS"</code> |
| 198 | <code>sha256sum -c SHA256SUMS --ignore-missing</code> | 198 | <code>sha256sum -c SHA256SUMS --ignore-missing</code> |
| 199 | <code>tar xzf eitri_{dlVersion}_linux_amd64.tar.gz && cd eitri_{dlVersion}_linux_amd64</code> | 199 | <code>tar xzf eitri-server_{dlVersion}_linux_amd64.tar.gz && cd eitri-server_{dlVersion}_linux_amd64</code> |
| 200 | <code>sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent</code> | 200 | <code>sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent</code> |
| 201 | <code>sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service</code> | 201 | <code>sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service</code> |
| 202 | <code>sudo eitri-agent --state-dir /var/lib/eitri-agent join {joinBlob}</code> | 202 | <code>sudo eitri-agent --state-dir /var/lib/eitri-agent join {joinBlob}</code> |