82b50b87
feat(devhost): the branch gate's fleet runs on the dev machine
a73x 2026-08-08 14:02
Commit message
deploy/server/README.md
| Old | New | ||
|---|---|---|---|
| @@ -221,6 +221,7 @@ end. | |||
| 221 | "advertise_quic": "sync.stg.eitri.sh:8444", | 221 | "advertise_quic": "sync.stg.eitri.sh:8444", |
| 222 | "cidr_pool": "10.79.0.0/16", | 222 | "cidr_pool": "10.79.0.0/16", |
| 223 | "host_secret": "<openssl rand -hex 32>", | 223 | "host_secret": "<openssl rand -hex 32>", |
| 224 | "key_encryption_key": "<openssl rand -hex 32>", | ||
| 224 | "release_manifest_url": "https://dl.stg.eitri.sh/dl/latest/manifest.json", | 225 | "release_manifest_url": "https://dl.stg.eitri.sh/dl/latest/manifest.json", |
| 225 | "default_images": { | 226 | "default_images": { |
| 226 | "amd64": {"url": "...", "sha256": "..."}, | 227 | "amd64": {"url": "...", "sha256": "..."}, |
| @@ -239,6 +240,13 @@ end. | |||
| 239 | server pins the token request's client-auth style accordingly. prod's Google | 240 | server pins the token request's client-auth style accordingly. prod's Google |
| 240 | client is confidential and does set one. | 241 | client is confidential and does set one. |
| 241 | 242 | ||
| 243 | `key_encryption_key` encrypts everything the plane signs with: the host CA | ||
| 244 | and gate host key on the PVC. The volume and its backups therefore hold | ||
| 245 | ciphertext, and this Secret is the key's only home. Each plane mints its own and keeps | ||
| 246 | it—it is never rotated in place, and a plane that loses it loses its host CA, | ||
| 247 | which is the identity every client pins and every VM's host certificate | ||
| 248 | names. | ||
| 249 | |||
| 242 | `allowed_identities` closes the signup gate. Both architectures get a | 250 | `allowed_identities` closes the signup gate. Both architectures get a |
| 243 | `default_images` entry: a host handed an image it cannot execute fails at | 251 | `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 | 252 | boot, which is the mistake that map exists to prevent — and stage 4 enforces |
| @@ -311,17 +319,85 @@ manifests do not describe — an unused `web-data` PVC, say — is left alone. | |||
| 311 | 319 | ||
| 312 | ## Runbook: the nested dev fleet | 320 | ## Runbook: the nested dev fleet |
| 313 | 321 | ||
| 314 | The branch gate's fleet lives on the workstation, defined by | 322 | The branch gate's fleet lives on the dev machine, defined by |
| 315 | `scripts/devhost.sh` rather than hand-built. Bring it up **before** mewtwo | 323 | `scripts/devhost.sh` rather than hand-built, and driven from the workstation. |
| 316 | moves to stg — until it is green, the project has no branch gate. | 324 | Bring it up **before** mewtwo moves to stg — until it is green, the project has |
| 325 | no branch gate. | ||
| 317 | 326 | ||
| 327 | export DEVHOST_REMOTE=ubuntu@192.168.0.193:2222 # the dev machine, mewtwo | ||
| 318 | scripts/devhost.sh create # from nothing | 328 | scripts/devhost.sh create # from nothing |
| 319 | scripts/devhost.sh recycle # destroy + create; a wedged host is 3 minutes | 329 | scripts/devhost.sh recycle # destroy + create; a wedged host is 3 minutes |
| 320 | scripts/devhost.sh destroy | 330 | scripts/devhost.sh destroy |
| 321 | 331 | scripts/devhost.sh address # its AGENT_HOSTS line, any time after | |
| 322 | `create` prints the `AGENT_HOSTS` and `AGENT_EXTRA_FLAGS` lines for | 332 | |
| 323 | `~/eitri-deploy/deploy.env`. **`make deploy` itself does not change** — only | 333 | `DEVHOST_REMOTE` is the whole of the remote story: the script copies itself and |
| 324 | which host it points at. | 334 | its cloud-init seed to that machine and re-execs there under `sudo`, so libvirt, |
| 335 | the image pool and the one-time `apt-get install` of the hypervisor toolchain | ||
| 336 | all happen where the guest does. **The workstation needs neither root nor | ||
| 337 | libvirt** — it runs the dev plane as userspace binaries and keeps doing so. | ||
| 338 | Output streams back and the exit status is the dev machine's. Unset | ||
| 339 | `DEVHOST_REMOTE` and the same four verbs build the host locally against the | ||
| 340 | libvirt `default` network. | ||
| 341 | |||
| 342 | The guest is a macvtap child of the dev machine's LAN uplink (`DEVHOST_UPLINK`, | ||
| 343 | default `enp4s0`), so the LAN's router leases it an address and the workstation | ||
| 344 | dials it directly. macvtap isolates a guest from its own host by design: **the | ||
| 345 | dev machine cannot reach the guest it runs.** That costs nothing here, because | ||
| 346 | every gate connection — `deploy.sh` over ssh, the smoke's serial-log reads — | ||
| 347 | comes from the workstation anyway. It does mean the address is discovered | ||
| 348 | rather than assigned: `create` waits for the guest agent to report it, then | ||
| 349 | prints the `AGENT_HOSTS` and `AGENT_EXTRA_FLAGS` lines for | ||
| 350 | `~/eitri-deploy/deploy.env` with the leased LAN address in them. | ||
| 351 | |||
| 352 | AGENT_HOSTS="ubuntu@<the address create printed>" | ||
| 353 | |||
| 354 | **`make deploy` itself does not change** — only which host it points at. The | ||
| 355 | address is stable across a recycle as long as the router keeps offering the | ||
| 356 | same lease to the MAC, which the script derives from the host's name; if it | ||
| 357 | ever moves, `scripts/devhost.sh address` prints the current one. Discovery | ||
| 358 | depends on the guest agent the seed installs on first boot, so a guest that is | ||
| 359 | up but slow to install it leaves `create` waiting; giving the router a | ||
| 360 | reservation for that MAC and setting `DEVHOST_IP` to the reserved address skips | ||
| 361 | the wait entirely. | ||
| 362 | |||
| 363 | **A fresh host must be enrolled before its first gate** — `deploy.sh` rolls | ||
| 364 | agents, it never joins them. After `create` (and after the first `make deploy` | ||
| 365 | has installed the agent binary and unit), mint a token on the local plane and | ||
| 366 | join: | ||
| 367 | |||
| 368 | PAT=$(cat ~/eitri-deploy/oidc/deploy-pat) | ||
| 369 | JOIN=$(curl -s -X POST http://127.0.0.1:8080/api/v1/enroll-tokens \ | ||
| 370 | -H "Authorization: Bearer $PAT" | python3 -c 'import json,sys; print(json.load(sys.stdin)["join"])') | ||
| 371 | ssh ubuntu@<address> "sudo eitri-agent --state-dir /var/lib/eitri-agent join '$JOIN' \ | ||
| 372 | && sudo systemctl reset-failed eitri-agent && sudo systemctl restart eitri-agent" | ||
| 373 | |||
| 374 | The `reset-failed` matters: an installed-but-unenrolled agent crash-loops until | ||
| 375 | systemd's start-rate limit trips, and a plain restart is then refused for a | ||
| 376 | reason that looks nothing like the cause. | ||
| 377 | |||
| 378 | **The smoke assumes the plane's only host is the one it reads.** It places its | ||
| 379 | throwaway VM on the fleet's first host but reads boot evidence over ssh to | ||
| 380 | `AGENT_HOSTS` — on a two-host plane those can differ, and the gate fails with | ||
| 381 | "no userspace boot evidence" while the guest boots happily elsewhere. Keep the | ||
| 382 | local plane single-host; decommission the old host before gating on the new | ||
| 383 | one. | ||
| 384 | |||
| 385 | **A recycle makes a new machine, and the plane must agree.** The proven flow: | ||
| 386 | `recycle` (the lease may change even with the same MAC — repoint | ||
| 387 | `AGENT_HOSTS`), then let the old fleet row drain out (it tombstones on | ||
| 388 | `DELETE /api/v1/hosts/{id}` and clears itself within the grace once empty — | ||
| 389 | `?force=true` is the escape hatch for hardware that is really gone), then | ||
| 390 | `make deploy` to install the agent (its start fails unenrolled — expected), | ||
| 391 | then the join above, then `make deploy` for the PASS. | ||
| 392 | |||
| 393 | After mewtwo moves to stg it carries **both** fleets at once: the stg agent on | ||
| 394 | bare metal, and this dev VM nested inside it, joined to the local plane. They | ||
| 395 | share a machine and nothing else. Each is a host in its own right with its own | ||
| 396 | LAN address, so the 30000-32767 exposure range each allocates from is its own | ||
| 397 | and two published ports can hold the same number without colliding. Their guest | ||
| 398 | subnets come from different pools — the local plane allocates from | ||
| 399 | `10.77.0.0/16`, stg from `10.79.0.0/16` — so neither agent's bridge can swallow | ||
| 400 | the other's guests. | ||
| 325 | 401 | ||
| 326 | All four of these must pass before mewtwo is allowed to move: | 402 | All four of these must pass before mewtwo is allowed to move: |
| 327 | 403 | ||
| @@ -452,3 +528,10 @@ 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 | 528 | `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: | 529 | not survive the node, so the off-node copy is the DR story. stg has no CronJob: |
| 454 | its database is disposable by design. | 530 | its database is disposable by design. |
| 531 | |||
| 532 | A restore needs the plane's `key_encryption_key` as well as its data. The host | ||
| 533 | CA on the PVC is sealed under it, so data alone rebuilds a plane that cannot | ||
| 534 | open it — it would come back without the identity every client pins. Your `~/eitri-deploy/<target>/server.json` is | ||
| 535 | that key's off-cluster custody: it is not in this repo, not in the backups, and | ||
| 536 | not recoverable from the cluster if the Secret goes with the node. Keep it, and | ||
| 537 | keep it somewhere the backups are not. | ||
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -444,9 +444,10 @@ tag—stays unordered and is never offered an upgrade. | |||
| 444 | A guest of a workstation VM boots under cloud-hypervisor and exercises the same | 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 | 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. | 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 | 447 | **Proven** 2026-08-07: all four proofs in deploy/server/README.md passed on |
| 448 | deploy, deploy after a recycle, coverage merged—have not been run. Bridged | 448 | eitri-dev1 (nested on the dev machine over macvtap) — cold deploy with the |
| 449 | networking and real-hardware quirks are known to be out of its reach, which is | 449 | agent bootstrap, warm re-deploy, deploy after a recycle, coverage merged. |
| 450 | Bridged networking and real-hardware quirks remain out of its reach, which is | ||
| 450 | why real metal gates pre-release tags instead. | 451 | why real metal gates pre-release tags instead. |
| 451 | 452 | ||
| 452 | ### A proxied MCP origin carries long calls | 453 | ### A proxied MCP origin carries long calls |
scripts/devhost.sh
| Old | New | ||
|---|---|---|---|
| @@ -1,23 +1,39 @@ | |||
| 1 | #!/usr/bin/env bash | 1 | #!/usr/bin/env bash |
| 2 | # | 2 | # |
| 3 | # The branch gate's fleet: a nested KVM host on this workstation, defined here | 3 | # The branch gate's fleet: a nested KVM host, defined here rather than |
| 4 | # rather than hand-built, so a wedged dev host is a three-minute rebuild instead | 4 | # hand-built, so a wedged dev host is a three-minute rebuild instead of a |
| 5 | # of a debugging session. | 5 | # debugging session. |
| 6 | # | 6 | # |
| 7 | # scripts/devhost.sh create [name] build it from nothing | 7 | # scripts/devhost.sh create [name] build it from nothing |
| 8 | # scripts/devhost.sh recycle [name] destroy, then create — the whole point | 8 | # scripts/devhost.sh recycle [name] destroy, then create — the whole point |
| 9 | # scripts/devhost.sh destroy [name] | 9 | # scripts/devhost.sh destroy [name] |
| 10 | # scripts/devhost.sh address [name] print its AGENT_HOSTS line | 10 | # scripts/devhost.sh address [name] print its AGENT_HOSTS line |
| 11 | # | 11 | # |
| 12 | # Workstation-local on purpose. The branch gate is the most-run check in the | 12 | # The host runs on the dev machine rather than the workstation: |
| 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 | # | 13 | # |
| 17 | # Nested guests get hardware virtualization through --cpu host-passthrough, so | 14 | # DEVHOST_REMOTE=ubuntu@192.168.0.193:2222 scripts/devhost.sh create |
| 18 | # the host CPU must expose nested KVM (kvm_amd nested=1 / kvm_intel nested=1). | 15 | # |
| 19 | # What nested CANNOT prove is bridged networking and real-hardware quirks — | 16 | # With DEVHOST_REMOTE set the script copies itself and the seed there and |
| 20 | # that coverage moved to stg, which gates pre-release tags on real metal. | 17 | # re-execs under sudo, so every hypervisor call — libvirt, qemu-img, apt — |
| 18 | # happens on the machine that owns the guest, and the workstation stays free of | ||
| 19 | # both root and libvirt. Output streams back; the exit status is the remote's. | ||
| 20 | # Unset, the same four verbs build the host on this machine against the libvirt | ||
| 21 | # 'default' network. | ||
| 22 | # | ||
| 23 | # The gate's fleet has no coupling to any hosted thing and dies the moment | ||
| 24 | # `make deploy` is not running. Nested guests get hardware virtualization | ||
| 25 | # through --cpu host-passthrough, so the machine's CPU must expose nested KVM | ||
| 26 | # (kvm_amd nested=1 / kvm_intel nested=1). What nested CANNOT prove is bridged | ||
| 27 | # networking and real-hardware quirks — that coverage moved to stg, which gates | ||
| 28 | # pre-release tags on real metal. | ||
| 29 | # | ||
| 30 | # On the dev machine the guest is a macvtap child of the LAN uplink | ||
| 31 | # (DEVHOST_UPLINK, default enp4s0): the LAN's router leases it an address and | ||
| 32 | # the workstation dials it directly. macvtap isolates a guest from its own | ||
| 33 | # host by design, so the dev machine cannot reach the guest it runs — which | ||
| 34 | # costs nothing here, because every gate connection comes from the workstation | ||
| 35 | # anyway. It does mean the address is discovered after boot rather than | ||
| 36 | # assigned: the guest agent reports it, and this script waits for it. | ||
| 21 | # | 37 | # |
| 22 | # The seed installs nothing eitri needs: cloud-hypervisor, the guest firmware | 38 | # 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, | 39 | # and the agent all arrive through `make deploy` and the agent's own bootstrap, |
| @@ -27,7 +43,12 @@ | |||
| 27 | # rm calls below use sudo for that and nothing else. | 43 | # rm calls below use sudo for that and nothing else. |
| 28 | set -euo pipefail | 44 | set -euo pipefail |
| 29 | 45 | ||
| 46 | # The pool dir and the 'default' network live on the SYSTEM daemon; a bare | ||
| 47 | # virsh from a user shell talks to qemu:///session and sees neither. | ||
| 48 | export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}" | ||
| 49 | |||
| 30 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | 50 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 51 | SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" | ||
| 31 | 52 | ||
| 32 | # Pinned guest image. Bump deliberately: take the sha from the SHA256SUMS file | 53 | # Pinned guest image. Bump deliberately: take the sha from the SHA256SUMS file |
| 33 | # beside the image in the same dated directory. | 54 | # beside the image in the same dated directory. |
| @@ -48,6 +69,26 @@ POOL_DIR="${DEVHOST_POOL_DIR:-/var/lib/libvirt/images}" | |||
| 48 | CACHE_DIR="${DEVHOST_CACHE_DIR:-$HOME/.cache/eitri/devhost}" | 69 | CACHE_DIR="${DEVHOST_CACHE_DIR:-$HOME/.cache/eitri/devhost}" |
| 49 | SSH_KEY="${DEVHOST_SSH_KEY:-$HOME/.ssh/id_ed25519.pub}" | 70 | SSH_KEY="${DEVHOST_SSH_KEY:-$HOME/.ssh/id_ed25519.pub}" |
| 50 | SSH_PORT="${DEVHOST_SSH_PORT:-22}" | 71 | SSH_PORT="${DEVHOST_SSH_PORT:-22}" |
| 72 | SEED_TEMPLATE="${DEVHOST_SEED:-$REPO_ROOT/scripts/devhost.cloud-init.yaml}" | ||
| 73 | |||
| 74 | # Where the host is built. REMOTE empty means this machine. MANAGED is set only | ||
| 75 | # on the copy that lands on the dev machine, and says two things: this machine | ||
| 76 | # is ours to prepare, and something upstream is waiting to print the result. | ||
| 77 | REMOTE="${DEVHOST_REMOTE:-}" | ||
| 78 | MANAGED="${DEVHOST_MANAGED:-}" | ||
| 79 | |||
| 80 | # A non-empty uplink switches the guest onto macvtap over that interface; empty | ||
| 81 | # keeps it on the libvirt network. Driving a remote implies the former, because | ||
| 82 | # a dev machine is a LAN citizen and its guest should be one too. | ||
| 83 | UPLINK="${DEVHOST_UPLINK:-}" | ||
| 84 | if [[ -n "$REMOTE" && -z "$UPLINK" ]]; then | ||
| 85 | UPLINK=enp4s0 | ||
| 86 | fi | ||
| 87 | |||
| 88 | # The key the guest must admit is the workstation's, wherever the guest is | ||
| 89 | # built: the driving side reads the file and hands the material over, so the | ||
| 90 | # dev machine never needs a key of its own. | ||
| 91 | SSH_PUBKEY="${DEVHOST_SSH_PUBKEY:-}" | ||
| 51 | 92 | ||
| 52 | bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } | 93 | bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } |
| 53 | die() { printf 'devhost: %s\n' "$*" >&2; exit 1; } | 94 | die() { printf 'devhost: %s\n' "$*" >&2; exit 1; } |
| @@ -58,44 +99,198 @@ NAME="${2:-eitri-dev1}" | |||
| 58 | case "$VERB" in | 99 | case "$VERB" in |
| 59 | create | recycle | destroy | address) ;; | 100 | create | recycle | destroy | address) ;; |
| 60 | *) | 101 | *) |
| 61 | sed -n '2,20p' "$0" | sed 's/^#\{1,2\} \{0,1\}//' >&2 | 102 | sed -n '2,21p' "$0" | sed 's/^#\{1,2\} \{0,1\}//' >&2 |
| 62 | exit 1 | 103 | exit 1 |
| 63 | ;; | 104 | ;; |
| 64 | esac | 105 | esac |
| 65 | 106 | ||
| 66 | # Address and MAC are derived from the name, so a recycled host comes back at | 107 | RTARGET="" |
| 67 | # the same address and AGENT_HOSTS never has to be edited. The index is the | 108 | RPORT=22 |
| 68 | # trailing digits of the name ("eitri-dev2" -> 2 -> .51). | 109 | if [[ -n "$REMOTE" ]]; then |
| 69 | index="$(printf '%s' "$NAME" | grep -oE '[0-9]+$' || echo 1)" | 110 | RTARGET="${REMOTE%:*}" |
| 70 | IP="${DEVHOST_IP:-192.168.122.$((49 + index))}" | 111 | if [[ "$REMOTE" == *:* ]]; then |
| 71 | # Locally-administered QEMU prefix plus three bytes of the name's digest: stable | 112 | RPORT="${REMOTE##*:}" |
| 72 | # per name, and distinct enough not to collide with anything else on the bridge. | 113 | fi |
| 114 | [[ "$RTARGET" == *@* ]] || die "DEVHOST_REMOTE must read user@host[:port] (got '$REMOTE')" | ||
| 115 | fi | ||
| 116 | |||
| 117 | # Where to look when a host will not answer. Named early because half the | ||
| 118 | # failure paths below want to say it. | ||
| 119 | CONSOLE_HINT="virsh console $NAME" | ||
| 120 | if [[ -n "$REMOTE" ]]; then | ||
| 121 | CONSOLE_HINT="ssh -p $RPORT $RTARGET sudo virsh console $NAME" | ||
| 122 | fi | ||
| 123 | |||
| 124 | # The MAC is derived from the name, so a recycled host comes back the same | ||
| 125 | # machine to the router and to libvirt. Locally-administered QEMU prefix plus | ||
| 126 | # three bytes of the name's digest: stable per name, and distinct enough not to | ||
| 127 | # collide with anything else on the segment. | ||
| 73 | mac_tail="$(printf '%s' "$NAME" | sha256sum | cut -c1-6 | sed 's/../&:/g; s/:$//')" | 128 | mac_tail="$(printf '%s' "$NAME" | sha256sum | cut -c1-6 | sed 's/../&:/g; s/:$//')" |
| 74 | MAC="${DEVHOST_MAC:-52:54:00:$mac_tail}" | 129 | MAC="${DEVHOST_MAC:-52:54:00:$mac_tail}" |
| 75 | DISK="$POOL_DIR/$NAME.qcow2" | 130 | DISK="$POOL_DIR/$NAME.qcow2" |
| 76 | 131 | ||
| 132 | # On the libvirt network the address comes from the name too, via a reservation | ||
| 133 | # this script owns, so AGENT_HOSTS never has to be edited. The index is the | ||
| 134 | # trailing digits of the name ("eitri-dev2" -> 2 -> .51). On the LAN uplink the | ||
| 135 | # router owns DHCP, so the address is whatever it leased and is discovered. | ||
| 136 | IP="${DEVHOST_IP:-}" | ||
| 137 | if [[ -z "$UPLINK" && -z "$IP" ]]; then | ||
| 138 | index="$(printf '%s' "$NAME" | grep -oE '[0-9]+$' || echo 1)" | ||
| 139 | IP="192.168.122.$((49 + index))" | ||
| 140 | fi | ||
| 141 | |||
| 77 | need() { command -v "$1" >/dev/null 2>&1 || die "$1 is not installed"; } | 142 | need() { command -v "$1" >/dev/null 2>&1 || die "$1 is not installed"; } |
| 78 | 143 | ||
| 144 | # The dev machine gets the hypervisor toolchain the first time it is asked for | ||
| 145 | # it, and nothing at all on every run after that. Only ever on a machine this | ||
| 146 | # script was copied to: the workstation's package set is the workstation's own | ||
| 147 | # business. | ||
| 148 | ensure_prereqs() { | ||
| 149 | [[ -n "$MANAGED" ]] || return 0 | ||
| 150 | |||
| 151 | missing=() | ||
| 152 | for cmd in virsh virt-install qemu-img curl; do | ||
| 153 | command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") | ||
| 154 | done | ||
| 155 | if ((${#missing[@]} > 0)); then | ||
| 156 | # Named rather than pulled in by recommendation, and every one of them | ||
| 157 | # is used: the emulator, qemu-img, the daemon, virsh, virt-install. A | ||
| 158 | # guest on the LAN uplink needs no NAT network, so nothing here has to | ||
| 159 | # drag in dnsmasq or the bridge tooling to serve one. | ||
| 160 | bold "Installing the hypervisor toolchain (missing: ${missing[*]})" | ||
| 161 | sudo -n env DEBIAN_FRONTEND=noninteractive apt-get update -qq | ||
| 162 | sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y \ | ||
| 163 | --no-install-recommends qemu-system-x86 qemu-utils \ | ||
| 164 | libvirt-daemon-system libvirt-clients virtinst curl | ||
| 165 | fi | ||
| 166 | if systemctl is-active --quiet libvirtd; then | ||
| 167 | echo "libvirtd is running" | ||
| 168 | else | ||
| 169 | bold "Starting libvirtd" | ||
| 170 | sudo -n systemctl enable --now libvirtd | ||
| 171 | fi | ||
| 172 | } | ||
| 173 | |||
| 174 | # The script is its own payload. It and the seed go to the dev machine's mktemp | ||
| 175 | # dir and are re-exec'd there as root, which is what lets a single ssh session | ||
| 176 | # do libvirt, the pool and apt without the driving side holding any privilege. | ||
| 177 | # The remote's stdout is teed so the address it discovers can be read back; | ||
| 178 | # its stderr passes straight through untouched. | ||
| 179 | run_remote() { | ||
| 180 | if [[ "$VERB" == create || "$VERB" == recycle ]]; then | ||
| 181 | [[ -f "$SSH_KEY" ]] || die "no public key at $SSH_KEY (set DEVHOST_SSH_KEY)" | ||
| 182 | SSH_PUBKEY="$(cat "$SSH_KEY")" | ||
| 183 | fi | ||
| 184 | |||
| 185 | # ServerAliveInterval, because a create is ten quiet minutes of image | ||
| 186 | # fetching and first boot on the far end, and a session that times out | ||
| 187 | # during them abandons a guest that is already running. | ||
| 188 | ssh_opts=(-o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=30) | ||
| 189 | tmp="$(ssh -p "$RPORT" "${ssh_opts[@]}" "$RTARGET" 'mktemp -d /tmp/devhost.XXXXXX')" || | ||
| 190 | die "$REMOTE did not answer ssh" | ||
| 191 | scp -P "$RPORT" -q "${ssh_opts[@]}" "$SCRIPT" "$SEED_TEMPLATE" "$RTARGET:$tmp/" || | ||
| 192 | die "could not copy the script to $REMOTE" | ||
| 193 | |||
| 194 | envs=( | ||
| 195 | "DEVHOST_REMOTE=" | ||
| 196 | "DEVHOST_MANAGED=1" | ||
| 197 | "DEVHOST_SEED=$tmp/$(basename "$SEED_TEMPLATE")" | ||
| 198 | "DEVHOST_UPLINK=$UPLINK" | ||
| 199 | "DEVHOST_MAC=$MAC" | ||
| 200 | "DEVHOST_MEM_MB=$MEM_MB" | ||
| 201 | "DEVHOST_VCPUS=$VCPUS" | ||
| 202 | "DEVHOST_DISK_GB=$DISK_GB" | ||
| 203 | "DEVHOST_IMAGE_URL=$IMAGE_URL" | ||
| 204 | "DEVHOST_IMAGE_SHA256=$IMAGE_SHA256" | ||
| 205 | "DEVHOST_OS_VARIANT=$OS_VARIANT" | ||
| 206 | "DEVHOST_POOL_DIR=$POOL_DIR" | ||
| 207 | ) | ||
| 208 | if [[ -n "$SSH_PUBKEY" ]]; then | ||
| 209 | envs+=("DEVHOST_SSH_PUBKEY=$SSH_PUBKEY") | ||
| 210 | fi | ||
| 211 | if [[ -n "$IP" ]]; then | ||
| 212 | envs+=("DEVHOST_IP=$IP") | ||
| 213 | fi | ||
| 214 | # The image cache belongs to whichever machine fetches, so it is forwarded | ||
| 215 | # only when someone has said where they want it. | ||
| 216 | if [[ -n "${DEVHOST_CACHE_DIR:-}" ]]; then | ||
| 217 | envs+=("DEVHOST_CACHE_DIR=$DEVHOST_CACHE_DIR") | ||
| 218 | fi | ||
| 219 | |||
| 220 | cmd="sudo -n env" | ||
| 221 | for assignment in "${envs[@]}"; do | ||
| 222 | cmd+=" $(printf '%q' "$assignment")" | ||
| 223 | done | ||
| 224 | cmd+=" bash $tmp/$(basename "$SCRIPT") $(printf '%q %q' "$VERB" "$NAME")" | ||
| 225 | # The temp dir goes whatever the run did, and the run's status survives it. | ||
| 226 | cmd="$cmd; rc=\$?; rm -rf $tmp; exit \$rc" | ||
| 227 | |||
| 228 | bold "Driving $VERB of $NAME on $RTARGET" | ||
| 229 | out="$(mktemp)" | ||
| 230 | trap 'rm -f "$out"' EXIT | ||
| 231 | if ssh -p "$RPORT" "${ssh_opts[@]}" "$RTARGET" "$cmd" | tee "$out"; then | ||
| 232 | rc=0 | ||
| 233 | else | ||
| 234 | rc=$? | ||
| 235 | fi | ||
| 236 | [[ $rc -eq 0 ]] || exit "$rc" | ||
| 237 | |||
| 238 | case "$VERB" in | ||
| 239 | create | recycle | address) | ||
| 240 | IP="$(sed -n 's/^devhost-address: //p' "$out" | tail -n1)" | ||
| 241 | [[ -n "$IP" ]] || die "$RTARGET never reported an address for $NAME" | ||
| 242 | ;; | ||
| 243 | esac | ||
| 244 | rm -f "$out" | ||
| 245 | trap - EXIT | ||
| 246 | |||
| 247 | case "$VERB" in | ||
| 248 | create | recycle) | ||
| 249 | wait_for_ssh | ||
| 250 | print_address | ||
| 251 | ;; | ||
| 252 | address) print_address ;; | ||
| 253 | esac | ||
| 254 | } | ||
| 255 | |||
| 79 | destroy_host() { | 256 | destroy_host() { |
| 80 | bold "Destroying $NAME" | 257 | bold "Destroying $NAME" |
| 81 | virsh destroy "$NAME" >/dev/null 2>&1 || true | 258 | if command -v virsh >/dev/null 2>&1; then |
| 82 | # --remove-all-storage takes the root disk and the cloud-init seed ISO with | 259 | virsh destroy "$NAME" >/dev/null 2>&1 || true |
| 83 | # the domain; the explicit rm is the belt for a domain that never defined. | 260 | # --remove-all-storage takes the root disk and the cloud-init seed ISO |
| 84 | virsh undefine "$NAME" --nvram --remove-all-storage >/dev/null 2>&1 || true | 261 | # with the domain; the explicit rm is the belt for a domain that never |
| 262 | # defined. | ||
| 263 | virsh undefine "$NAME" --nvram --remove-all-storage >/dev/null 2>&1 || true | ||
| 264 | fi | ||
| 85 | sudo rm -f "$DISK" | 265 | sudo rm -f "$DISK" |
| 86 | # The DHCP reservation deliberately survives: it is what makes the address | 266 | if [[ -n "$UPLINK" ]]; then |
| 87 | # stable across a recycle, and create re-adds it either way. | 267 | echo "$NAME is gone" |
| 88 | echo "$NAME is gone (its reservation at $IP is kept)" | 268 | else |
| 269 | # The DHCP reservation deliberately survives: it is what makes the | ||
| 270 | # address stable across a recycle, and create re-adds it either way. | ||
| 271 | echo "$NAME is gone (its reservation at $IP is kept)" | ||
| 272 | fi | ||
| 89 | } | 273 | } |
| 90 | 274 | ||
| 91 | create_host() { | 275 | create_host() { |
| 276 | ensure_prereqs | ||
| 92 | need virsh | 277 | need virsh |
| 93 | need virt-install | 278 | need virt-install |
| 94 | need qemu-img | 279 | need qemu-img |
| 95 | [[ -e /dev/kvm ]] || die "/dev/kvm is missing — nested guests need hardware virtualization" | 280 | [[ -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)" | 281 | if [[ -z "$SSH_PUBKEY" ]]; then |
| 97 | virsh net-info "$NETWORK" >/dev/null 2>&1 || die "libvirt network '$NETWORK' does not exist" | 282 | [[ -f "$SSH_KEY" ]] || die "no public key at $SSH_KEY (set DEVHOST_SSH_KEY)" |
| 98 | virsh net-info "$NETWORK" | grep -q 'Active:.*yes' || die "libvirt network '$NETWORK' is not running (virsh net-start $NETWORK)" | 283 | SSH_PUBKEY="$(cat "$SSH_KEY")" |
| 284 | fi | ||
| 285 | if [[ -n "$UPLINK" ]]; then | ||
| 286 | ip link show "$UPLINK" >/dev/null 2>&1 || | ||
| 287 | die "no interface '$UPLINK' on this machine (set DEVHOST_UPLINK)" | ||
| 288 | else | ||
| 289 | virsh net-info "$NETWORK" >/dev/null 2>&1 || die "libvirt network '$NETWORK' does not exist" | ||
| 290 | # grep consumes all input on purpose: under pipefail, grep -q's early exit | ||
| 291 | # EPIPEs the writer and fails the pipeline even when the match succeeded. | ||
| 292 | virsh net-info "$NETWORK" | grep 'Active:.*yes' >/dev/null || die "libvirt network '$NETWORK' is not running (virsh net-start $NETWORK)" | ||
| 293 | fi | ||
| 99 | if virsh dominfo "$NAME" >/dev/null 2>&1; then | 294 | if virsh dominfo "$NAME" >/dev/null 2>&1; then |
| 100 | die "$NAME already exists — 'recycle' rebuilds it, 'destroy' removes it" | 295 | die "$NAME already exists — 'recycle' rebuilds it, 'destroy' removes it" |
| 101 | fi | 296 | fi |
| @@ -113,11 +308,17 @@ create_host() { | |||
| 113 | } | 308 | } |
| 114 | echo "image ok: $cached" | 309 | echo "image ok: $cached" |
| 115 | 310 | ||
| 116 | bold "Pinning $NAME to $IP on the '$NETWORK' network" | 311 | if [[ -n "$UPLINK" ]]; then |
| 117 | virsh net-update "$NETWORK" delete ip-dhcp-host "<host mac='$MAC'/>" \ | 312 | netarg="type=direct,source=$UPLINK,source_mode=bridge,model=virtio,mac=$MAC" |
| 118 | --live --config >/dev/null 2>&1 || true | 313 | bold "Putting $NAME on the LAN through $UPLINK ($MAC)" |
| 119 | virsh net-update "$NETWORK" add ip-dhcp-host \ | 314 | else |
| 120 | "<host mac='$MAC' name='$NAME' ip='$IP'/>" --live --config >/dev/null | 315 | netarg="network=$NETWORK,mac=$MAC,model=virtio" |
| 316 | bold "Pinning $NAME to $IP on the '$NETWORK' network" | ||
| 317 | virsh net-update "$NETWORK" delete ip-dhcp-host "<host mac='$MAC'/>" \ | ||
| 318 | --live --config >/dev/null 2>&1 || true | ||
| 319 | virsh net-update "$NETWORK" add ip-dhcp-host \ | ||
| 320 | "<host mac='$MAC' name='$NAME' ip='$IP'/>" --live --config >/dev/null | ||
| 321 | fi | ||
| 121 | 322 | ||
| 122 | bold "Creating $NAME ($VCPUS vCPU, $((MEM_MB / 1024)) GB, $DISK_GB GB)" | 323 | bold "Creating $NAME ($VCPUS vCPU, $((MEM_MB / 1024)) GB, $DISK_GB GB)" |
| 123 | sudo qemu-img convert -O qcow2 "$cached" "$DISK" | 324 | sudo qemu-img convert -O qcow2 "$cached" "$DISK" |
| @@ -125,8 +326,8 @@ create_host() { | |||
| 125 | 326 | ||
| 126 | seed="$(mktemp)" | 327 | seed="$(mktemp)" |
| 127 | trap 'rm -f "$seed"' EXIT | 328 | trap 'rm -f "$seed"' EXIT |
| 128 | SSH_AUTHORIZED_KEY="$(cat "$SSH_KEY")" \ | 329 | template="$(cat "$SEED_TEMPLATE")" |
| 129 | envsubst '${SSH_AUTHORIZED_KEY}' <"$REPO_ROOT/scripts/devhost.cloud-init.yaml" >"$seed" | 330 | printf '%s\n' "${template//'${SSH_AUTHORIZED_KEY}'/$SSH_PUBKEY}" >"$seed" |
| 130 | 331 | ||
| 131 | virt-install \ | 332 | virt-install \ |
| 132 | --name "$NAME" \ | 333 | --name "$NAME" \ |
| @@ -134,7 +335,7 @@ create_host() { | |||
| 134 | --vcpus "$VCPUS" \ | 335 | --vcpus "$VCPUS" \ |
| 135 | --cpu host-passthrough \ | 336 | --cpu host-passthrough \ |
| 136 | --disk "path=$DISK,format=qcow2,bus=virtio" \ | 337 | --disk "path=$DISK,format=qcow2,bus=virtio" \ |
| 137 | --network "network=$NETWORK,mac=$MAC,model=virtio" \ | 338 | --network "$netarg" \ |
| 138 | --os-variant "$OS_VARIANT" \ | 339 | --os-variant "$OS_VARIANT" \ |
| 139 | --cloud-init "user-data=$seed" \ | 340 | --cloud-init "user-data=$seed" \ |
| 140 | --graphics none \ | 341 | --graphics none \ |
| @@ -143,6 +344,64 @@ create_host() { | |||
| 143 | rm -f "$seed" | 344 | rm -f "$seed" |
| 144 | trap - EXIT | 345 | trap - EXIT |
| 145 | 346 | ||
| 347 | if [[ -n "$UPLINK" ]]; then | ||
| 348 | # Discovery waits on the guest agent, which the seed installs on first | ||
| 349 | # boot. DEVHOST_IP is the way past it for a guest the router already has | ||
| 350 | # a reservation for: the address is known before the machine exists. | ||
| 351 | if [[ -z "$IP" ]]; then | ||
| 352 | IP="$(await_address)" | ||
| 353 | fi | ||
| 354 | # macvtap keeps this machine off its own guest, so the ssh proof belongs | ||
| 355 | # to whoever drives this script. Report the address and let them make it. | ||
| 356 | printf 'devhost-address: %s\n' "$IP" | ||
| 357 | if [[ -z "$MANAGED" ]]; then | ||
| 358 | print_address | ||
| 359 | fi | ||
| 360 | else | ||
| 361 | wait_for_ssh | ||
| 362 | print_address | ||
| 363 | fi | ||
| 364 | } | ||
| 365 | |||
| 366 | # The guest's address, from the first source that knows it. Under macvtap the | ||
| 367 | # host has no path to its own guest, so the ARP table never learns it and | ||
| 368 | # libvirt owns no lease to read — the guest agent's virtio channel is the one | ||
| 369 | # source that answers. The other two are kept for a host whose guest shares its | ||
| 370 | # L2, where they answer sooner. | ||
| 371 | domain_ipv4() { | ||
| 372 | for src in agent arp; do | ||
| 373 | found="$(virsh domifaddr "$NAME" --source "$src" 2>/dev/null | | ||
| 374 | awk '{ | ||
| 375 | for (i = 1; i < NF; i++) | ||
| 376 | if ($i == "ipv4") { | ||
| 377 | split($(i + 1), a, "/") | ||
| 378 | if (a[1] !~ /^(127\.|169\.254\.)/ && !ip) ip = a[1] | ||
| 379 | } | ||
| 380 | } END { if (ip) print ip }' || true)" | ||
| 381 | if [[ -n "$found" ]]; then | ||
| 382 | printf '%s' "$found" | ||
| 383 | return 0 | ||
| 384 | fi | ||
| 385 | done | ||
| 386 | ip -4 neigh show 2>/dev/null | | ||
| 387 | awk -v mac="$MAC" 'BEGIN { mac = tolower(mac) } | ||
| 388 | tolower($0) ~ mac && !ip { ip = $1 } END { if (ip) print ip }' || true | ||
| 389 | } | ||
| 390 | |||
| 391 | await_address() { | ||
| 392 | bold "Waiting for $NAME to report the address the LAN leased it" >&2 | ||
| 393 | for _ in $(seq 1 90); do | ||
| 394 | found="$(domain_ipv4)" | ||
| 395 | if [[ -n "$found" ]]; then | ||
| 396 | printf '%s' "$found" | ||
| 397 | return 0 | ||
| 398 | fi | ||
| 399 | sleep 5 | ||
| 400 | done | ||
| 401 | die "$NAME never reported an address ($CONSOLE_HINT)" | ||
| 402 | } | ||
| 403 | |||
| 404 | wait_for_ssh() { | ||
| 146 | # A rebuilt host presents a new host key at an address ssh already knows, | 405 | # 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 | 406 | # which otherwise fails the first deploy with a warning that looks like an |
| 148 | # attack rather than a recycle. | 407 | # attack rather than a recycle. |
| @@ -152,14 +411,11 @@ create_host() { | |||
| 152 | for _ in $(seq 1 60); do | 411 | for _ in $(seq 1 60); do |
| 153 | if ssh -p "$SSH_PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ | 412 | if ssh -p "$SSH_PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ |
| 154 | -o ConnectTimeout=5 "ubuntu@$IP" true 2>/dev/null; then | 413 | -o ConnectTimeout=5 "ubuntu@$IP" true 2>/dev/null; then |
| 155 | break | 414 | return 0 |
| 156 | fi | 415 | fi |
| 157 | sleep 5 | 416 | sleep 5 |
| 158 | done | 417 | done |
| 159 | ssh -p "$SSH_PORT" -o BatchMode=yes -o ConnectTimeout=5 "ubuntu@$IP" true 2>/dev/null || | 418 | die "$NAME did not answer ssh at $IP within 5 minutes ($CONSOLE_HINT)" |
| 160 | die "$NAME did not answer ssh at $IP within 5 minutes (virsh console $NAME)" | ||
| 161 | |||
| 162 | print_address | ||
| 163 | } | 419 | } |
| 164 | 420 | ||
| 165 | print_address() { | 421 | print_address() { |
| @@ -174,6 +430,11 @@ print_address() { | |||
| 174 | EOF | 430 | EOF |
| 175 | } | 431 | } |
| 176 | 432 | ||
| 433 | if [[ -n "$REMOTE" && -z "$MANAGED" ]]; then | ||
| 434 | run_remote | ||
| 435 | exit 0 | ||
| 436 | fi | ||
| 437 | |||
| 177 | case "$VERB" in | 438 | case "$VERB" in |
| 178 | create) create_host ;; | 439 | create) create_host ;; |
| 179 | destroy) destroy_host ;; | 440 | destroy) destroy_host ;; |
| @@ -181,5 +442,18 @@ recycle) | |||
| 181 | destroy_host | 442 | destroy_host |
| 182 | create_host | 443 | create_host |
| 183 | ;; | 444 | ;; |
| 184 | address) print_address ;; | 445 | address) |
| 446 | if [[ -n "$UPLINK" ]]; then | ||
| 447 | if [[ -z "$IP" ]]; then | ||
| 448 | IP="$(domain_ipv4)" | ||
| 449 | fi | ||
| 450 | [[ -n "$IP" ]] || die "$NAME has no address to report ($CONSOLE_HINT)" | ||
| 451 | printf 'devhost-address: %s\n' "$IP" | ||
| 452 | if [[ -z "$MANAGED" ]]; then | ||
| 453 | print_address | ||
| 454 | fi | ||
| 455 | else | ||
| 456 | print_address | ||
| 457 | fi | ||
| 458 | ;; | ||
| 185 | esac | 459 | esac |