max / makenotwork
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 files changed,
+3351 insertions,
-256 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | MIT License | |
| 2 | 2 | ||
| 3 | - | Copyright (c) 2026 Max Jacobson | |
| 3 | + | Copyright (c) 2026 Make Creative, LLC | |
| 4 | 4 | ||
| 5 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | 6 | of this software and associated documentation files (the "Software"), to deal |
| @@ -103,12 +103,29 @@ | |||
| 103 | 103 | | Method | Path | Body | Purpose | | |
| 104 | 104 | |--------|------|------|---------| | |
| 105 | 105 | | GET | `/state` | — | Tier list + current/previous version + last gate outcomes | | |
| 106 | - | | POST | `/rebuild` | `{sha?: string}` | Force a build; if `sha` is absent, resolves the configured deploy branch | | |
| 107 | - | | POST | `/promote/{tier}` | `{version, hotfix?, reset_burn_in?}` | Verify predecessor gates, deploy to tier nodes, advance state | | |
| 106 | + | | POST | `/rebuild` | `{sha?: string}` | Force a build; if `sha` is absent, resolves the configured deploy branch. Aborts any in-flight build (latest wins). | | |
| 107 | + | | POST | `/promote/{tier}` | `{version?, hotfix?, reset_burn_in?}` | Verify predecessor gates, deploy to tier nodes, advance state. `version` defaults to the predecessor tier's `current_version`. | | |
| 108 | 108 | | POST | `/rollback/{tier}` | — | Swap `current` symlink to `previous_version` on every node in the tier | | |
| 109 | - | | POST | `/backup/fetch` | — | Pull the prod backup to `backup.local_path` (file:// or rsync://) | | |
| 109 | + | | POST | `/confirm/{tier}` | — | Insert a passing `manual_confirm` gate row for the tier's `current_version`. Replaces hand-SQL. | | |
| 110 | + | | POST | `/backup/fetch` | — | Pull the prod backup. Supports `file://`, `rsync://`, `ssh://user@host[:port]/path`. | | |
| 110 | 111 | | GET | `/metrics` | — | Prometheus exposition | | |
| 111 | - | | GET | `/events` | — | WebSocket stream of deploy + gate events (not yet implemented) | | |
| 112 | + | | GET | `/events` | — | WebSocket stream of typed events (RebuildRequested, BuildStart/Ok/Failed, GateStart/Done, DeployStart/Ok/Failed, PromoteComplete, Rollback, BackupFetched, ManualConfirm, BuildAborted). | | |
| 113 | + | ||
| 114 | + | ## TUI | |
| 115 | + | ||
| 116 | + | `sando` (the TUI binary) connects to `$SANDO_DAEMON` (default `http://127.0.0.1:7766`), polls `/state` every 2s, and subscribes to `/events` over WS. Keybindings: | |
| 117 | + | ||
| 118 | + | | key | action | | |
| 119 | + | |-----|--------| | |
| 120 | + | | ↑/↓ or j/k | select tier | | |
| 121 | + | | p | `POST /promote/<selected>` (no body — version defaults to predecessor's current) | | |
| 122 | + | | R | `POST /rollback/<selected>` | | |
| 123 | + | | b | `POST /backup/fetch` | | |
| 124 | + | | c | `POST /confirm/<selected>` | | |
| 125 | + | | r | refresh hint (poller is already every 2s) | | |
| 126 | + | | q / Esc / Ctrl-C | quit | | |
| 127 | + | ||
| 128 | + | Action results show up in the events log a moment later (the actions themselves emit events from the daemon side). | |
| 112 | 129 | ||
| 113 | 130 | ## Hotfix flow | |
| 114 | 131 | ||
| @@ -123,14 +140,9 @@ | |||
| 123 | 140 | ||
| 124 | 141 | ## v0 limitations | |
| 125 | 142 | ||
| 126 | - | - Remote deploys (real SSH/rsync) are stubbed. Use `ssh_target = "local"` and | |
| 127 | - | a local `release_root` for dev. Production wiring is a follow-up. | |
| 128 | 143 | - `migration_dry_run` requires a scratch Postgres at `scratch_db_url`. The | |
| 129 | - | gate drops and recreates `public` on every run; do not point this at | |
| 144 | + | gate drops every non-system schema on every run; do not point this at | |
| 130 | 145 | anything that matters. | |
| 131 | - | - `/events` WebSocket is not implemented; the TUI polls `/state` every 2s. | |
| 132 | - | - `manual_confirm` has no operator-facing trigger yet (you have to insert a | |
| 133 | - | `gate_runs` row with `passed=1` by hand to satisfy it). | |
| 134 | 146 | ||
| 135 | 147 | ## License | |
| 136 | 148 |
| @@ -17,9 +17,10 @@ | |||
| 17 | 17 | branch = "main" | |
| 18 | 18 | ||
| 19 | 19 | [backup] | |
| 20 | - | # Source of the prod-backup clone used by migration_dry_run on MM. | |
| 21 | - | # For localhost dev this can be a file:// path to a fixture dump. | |
| 22 | - | source = "rsync://astra/var/backups/mnw/latest.sql.gz" | |
| 20 | + | # Source of the prod-backup clone used by migration_dry_run on the Sando host. | |
| 21 | + | # For localhost dev this can be a file:// path to a fixture dump. In prod we | |
| 22 | + | # pull directly from alpha-west-1 via a scoped `backup-puller` rrsync user. | |
| 23 | + | source = "ssh://backup-puller@alpha-west-1:2200/latest.sql.gz" | |
| 23 | 24 | local_path = "/srv/sando/backups/latest.sql.gz" | |
| 24 | 25 | ||
| 25 | 26 | # ---- host: pop-os local pre-staging gate ---- | |
| @@ -45,8 +46,9 @@ | |||
| 45 | 46 | ] | |
| 46 | 47 | [[tier.node]] | |
| 47 | 48 | name = "testnot-1" | |
| 48 | - | ssh_target = "deploy@testnot.work" | |
| 49 | + | ssh_target = "deploy@testnot" # tailnet name; never the public IP / testnot.work hostname | |
| 49 | 50 | release_root = "/opt/mnw" | |
| 51 | + | service_name = "makenotwork.service" | |
| 50 | 52 | ||
| 51 | 53 | # ---- B: prod-1 ---- | |
| 52 | 54 | [[tier]] |
| @@ -4,6 +4,79 @@ | |||
| 4 | 4 | ||
| 5 | 5 | Format rule: every actionable line is a `- [ ]` checkbox. Headings group phases and themes; do not put status updates in them. | |
| 6 | 6 | ||
| 7 | + | ## Resume here (next session) | |
| 8 | + | ||
| 9 | + | User-blocking before anything else: | |
| 10 | + | ||
| 11 | + | - [ ] Apply updated Tailscale ACL (`_private/infra/tailscale-acl-policy.json`) at https://login.tailscale.com/admin/acls — adds the `tag:server → tag:server as user max` SSH rule needed for offsite backup sync. Once live, Claude can finish: verify `makenotwork@alpha-west-1 → max@astra` ssh works, scp `MNW/server/deploy/sync-backup-offsite.sh` to `/opt/makenotwork/sync-backup-offsite.sh`, chmod +x, then trigger `sudo -u makenotwork /opt/makenotwork/backup-db.sh` and confirm a file lands in `max@astra:/opt/backups/mnw/`. Closes the "offsite broken" adjacent fire. | |
| 12 | + | ||
| 13 | + | Claude-only follow-ups (no user input needed; pick the next slice): | |
| 14 | + | ||
| 15 | + | - error-pages bake-into-binary via `include_dir!` (separate MNW PR) — closes Phase 3 §2 long-term | |
| 16 | + | - `cargo_test` gate red on MNW (Phase 0 follow-up) — diagnose, likely needs DB/env setup hook per test or `--test-threads=1` | |
| 17 | + | - Sandod build/test output streaming (Phase 0 follow-up) — pipe stdout to per-run log files instead of `Output` buffer; surface in WS `/events` | |
| 18 | + | - Phase 6 monitoring + alerting — Prometheus counters + alert rules | |
| 19 | + | - Phase 4 prep — first Sando-only deploy to testnot (needs Track B — see below) | |
| 20 | + | - Sando test suite — see "Testing" section below; sandod and TUI have zero unit/integration tests today | |
| 21 | + | ||
| 22 | + | Decision-gated (needs user input first): | |
| 23 | + | ||
| 24 | + | - Track B testnot live-app: postgres role+db (Claude), `.env` secrets (which Stripe/SMTP/S3 creds to use for staging — needs user), Caddyfile + Cloudflare Origin CA cert for testnot.work (user issues cert in CF dashboard; Claude installs) | |
| 25 | + | - Restart-warning hook for prod tier (Phase 5) — needs `CLI_SERVICE_TOKEN` accessible to sandod | |
| 26 | + | ||
| 27 | + | ||
| 28 | + | ||
| 29 | + | ## Testing | |
| 30 | + | ||
| 31 | + | Sando has zero automated tests today — daemon + TUI have been validated by running real scenarios end-to-end. Worth a pass before relying on it for prod cutover. | |
| 32 | + | ||
| 33 | + | ### TUI hands-on (Phase 5 acceptance — run interactively) | |
| 34 | + | ||
| 35 | + | - [ ] Launches against `SANDO_DAEMON=http://100.103.89.95:7766` without crashing; header shows daemon URL. | |
| 36 | + | - [ ] WS status: `ws ok` appears in the header within ~1s of launch (sandod is reachable). | |
| 37 | + | - [ ] WS reconnects: `sudo systemctl restart sandod` on pop-os; header flips `ws ok → ws ... → ws ok` within ~5s. Events resume. | |
| 38 | + | - [ ] `↑/↓` and `j/k` move the row highlight through all 4 tiers; selection persists across the 2s state refresh. | |
| 39 | + | - [ ] `b` triggers backup fetch: status bar shows `[ok] backup/fetch: ...`, events log gets a `backup_fetched` line a moment later. | |
| 40 | + | - [ ] `c` on tier `a` (which has `current_version=0.8.12`) records a manual_confirm; event appears. | |
| 41 | + | - [ ] `c` on tier `mm` (no current_version) returns an HTTP error; status bar shows `[err]`. | |
| 42 | + | - [ ] `p` on tier `a` (assuming gates pass) issues a real deploy; sequence of `deploy_start → deploy_ok → promote_complete` events appears. | |
| 43 | + | - [ ] `R` on tier `a` rolls back to `previous_version`; `rollback` event appears. Reverse with `p` again. | |
| 44 | + | - [ ] `q`, `Esc`, `Ctrl-C` all quit cleanly; terminal restores correctly (no leftover raw mode). | |
| 45 | + | - [ ] Events ring buffer trims to 200: trigger ≥200 events (loop /backup/fetch), confirm the oldest scroll out, no panic. | |
| 46 | + | - [ ] Action while disconnected: kill sandod, hit `b`. Status shows error, TUI stays responsive. | |
| 47 | + | ||
| 48 | + | ### Sandod unit + integration tests (Claude-only) | |
| 49 | + | ||
| 50 | + | 55 tests passing as of 2026-05-31 (14 TUI + 41 daemon). Remaining gaps: | |
| 51 | + | ||
| 52 | + | - [x] `gates::reset_scratch` — verifies dropping every non-system schema (planted `foo` + `tower_sessions`, ran reset, asserted only `public` remains). Gated by `SANDO_TEST_PG_URL` env var so it skips on hosts without postgres. Run on pop-os with `SANDO_TEST_PG_URL=postgres:///sando_scratch?host=/var/run/postgresql cargo test`. | |
| 53 | + | - [x] `deploy::deploy_local` — copies multiple binaries (`PRIMARY`/`ADMIN`), swaps symlink atomically across two consecutive deploys, gc_local_releases keeps last N by mtime + handles missing dir + noop under threshold. `sh_quote` round-trip. | |
| 54 | + | - [x] `deploy::deploy_remote` failure path — against unroutable `192.0.2.1`, verifies clean ssh-attributed error (no panic / hang); ConnectTimeout bounds the test wallclock to ~10s. Plus `deploy_node` with `ssh_target="local"` short-circuits to symlink swap. | |
| 55 | + | - [x] `backup::fetch` URL parsing — extracted `parse_source` → `BackupSource` enum. 10 tests: file://, rsync://, ssh:// with/without port, multi-segment ssh path, non-numeric `:foo` colon treated as part of host (not port), and all malformed-input rejections (empty, scheme-only, ftp, no path on ssh, empty user@host). | |
| 56 | + | - [x] `events::emit` no-subscribers no-op; `emit_reaches_a_subscriber`; envelope serializes with flat `kind` field (locks the WS/TUI contract); `lagged_subscriber_observes_recv_error_lagged` exercises broadcast capacity. | |
| 57 | + | - [ ] `events_ws` handler end-to-end — drive WS through a slow client, assert `{"kind":"lagged",...}` frame arrives. Possible (bind axum to ephemeral port + tungstenite client) but the bus-level lag detection is already locked in by `lagged_subscriber_observes_recv_error_lagged`. Diminishing returns vs effort. Deferred. | |
| 58 | + | - [ ] `build` mutex behavior — requires real cargo or a slow stub. Treated as a manual checklist item under "TUI hands-on" instead. (Already validated by hand 2026-05-31.) | |
| 59 | + | - [x] `routes::confirm` — rejects when tier has no `current_version` (409 Conflict — surfaced that GateBlocked maps to 409 not 400, locked in), accepts + inserts a passing gate_runs row when set, 404 on unknown tier. | |
| 60 | + | - [x] `routes::promote` — refuses promote-to-first-tier (409), errors when neither body nor predecessor has a version, 404 when explicit version's `versions` row is missing. | |
| 61 | + | - [x] `unsatisfied_gates` — 6 tests: empty, failed-kind flagging, latest-row-wins (red→green flap clears), hotfix skips burn_in only, ignores other tiers/versions, **null `passed` treated as failing** (locks the in-flight-race safety property). | |
| 62 | + | - [x] `run_migrator` errors on missing migrations dir. | |
| 63 | + | - [x] sqlx migrations exercised via existing `sync` tests. | |
| 64 | + | ||
| 65 | + | ### End-to-end harness | |
| 66 | + | ||
| 67 | + | - [ ] Single-binary smoke: spin up sandod against tmpdir config + a tmp postgres; push a fixture commit; assert the full pipeline (build → gates → MM tier_state advance) completes in under 30s. Run on CI for every sando PR. | |
| 68 | + | - [ ] Pre-cutover dry run: stand up a throwaway tier-B node, point production-shape config at it, run `cargo_test → migration_dry_run → boot_smoke → promote` end to end. Use existing testnot for this once Track B is done. | |
| 69 | + | ||
| 70 | + | ### TUI unit tests | |
| 71 | + | ||
| 72 | + | - [x] `format_event` — golden tests for build_ok, gate_done (pass+fail), backup_fetched, deploy_failed, unknown kind, malformed JSON. | |
| 73 | + | - [x] `ws_url_from`: `http://` → `ws://`, `https://` → `wss://`, only replaces scheme once, unknown scheme passes through. | |
| 74 | + | - [x] `Action::Display` impl produces `backup/fetch`, `promote/<tier>`, etc. | |
| 75 | + | - [x] `Shared::push_event` ring-buffer cap at 200; oldest entries drop in FIFO order. | |
| 76 | + | - [x] `truncate` short-string passthrough vs long-string ellipsis. | |
| 77 | + | ||
| 78 | + | --- | |
| 79 | + | ||
| 7 | 80 | Roadmap target: replace `server/deploy/deploy.sh` and astra-hosted `server/deploy/run-ci.sh` with Sando running on **pop-os**, gating Hetzner prod through testnot.work. | |
| 8 | 81 | ||
| 9 | 82 | **Host decision:** Sando runs on pop-os (x86_64 Ubuntu-derived, systemd). Architecturally closest to Hetzner prod, no cross-compile, no init-system split. MakeMachine and EveryCycle are now a separate project — not Sando's concern. | |
| @@ -35,41 +108,66 @@ | |||
| 35 | 108 | - [x] Write systemd unit for `sandod` (long-run service, restart on failure, env from `/etc/sando/sando.env`). Installed at `/etc/systemd/system/sandod.service`. | |
| 36 | 109 | - [x] Write the production `sando.toml`; bare repo path under `/srv/sando/mnw.git`. Installed at `/etc/sando/sando.toml`; daemon config at `/etc/sando/sando-daemon.toml`. | |
| 37 | 110 | - [x] Install `sandod` binary at `/usr/local/bin/sandod`; enable + start the service. Live on `100.103.89.95:7766`; bare repo auto-bootstrapped at `/srv/sando/mnw.git`. | |
| 38 | - | - [ ] Verify MNW server builds reproducibly on pop-os. | |
| 39 | - | - [ ] Register sando pubkey with Hetzner prod (`deploy@alpha-west-1`) and testnot.work once that node exists. Pubkey: `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEK+vhpr1V8VnsEemN9x6tAA2S05kmv/mQ3eVgSXSkJ8 sando@pop-os`. | |
| 111 | + | - [x] Verify MNW server builds reproducibly on pop-os. `makenotwork` 0.8.12 built in 132s; sqlx online mode against `sando_scratch` postgres (sandod prep-resets all non-system schemas + applies all 133 MNW migrations before invoking cargo). | |
| 112 | + | - [ ] Register sando pubkey with Hetzner prod (`deploy@alpha-west-1`) and testnot.work once that node exists. Pubkey: `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEK+vhpr1V8VnsEemN9x6tAA2S05kmv/mQ3eVgSXSkJ8 sando@pop-os`. (Moved to Phase 1 — not blocking Phase 0 exit.) | |
| 113 | + | ||
| 114 | + | ### Phase 0 follow-ups (not blocking, but visible) | |
| 115 | + | ||
| 116 | + | - [ ] `cargo_test` gate fails on MNW today — beyond the sqlx-online fix (already in), tests likely need a separate prepared DB (or per-test isolation). Investigate when wiring up Phase 1 gates. | |
| 117 | + | - [ ] Sandod observability: add `WS /events` (Phase 5) and consider streaming build/test stdout to a per-run log file rather than buffering in `Output`. | |
| 118 | + | - [ ] sqlx-cli (`v0.9.0`) at `/srv/sando/.cargo/bin/sqlx` is installed for the sando user but unused — sandod uses `sqlx::migrate::Migrator` programmatically (v0.8.6). Decide later whether to drop sqlx-cli or use it for diagnostics. | |
| 119 | + | - [ ] pop-os WoL: `ethtool` shows no wake-on capability on the USB ethernet — WoL likely won't work; rely on manual wake or BIOS settings. Record in `_meta/` if a solution surfaces. | |
| 40 | 120 | ||
| 41 | 121 | ## Phase 1 — Remote deploy | |
| 42 | 122 | ||
| 43 | 123 | The MVP only deploys to `ssh_target=local`. Production needs real SSH/rsync. | |
| 44 | 124 | ||
| 45 | - | - [ ] Implement `deploy::deploy_node` remote path: rsync staged binary to `<ssh_target>:<release_root>/releases/<version>/server`, then `ssh <ssh_target>` runs `ln -sfn releases/<version> current` plus `systemctl reload-or-restart <service>`. | |
| 46 | - | - [ ] Add `node.service_name` to `sando.toml` (default `makenotwork.service`). | |
| 47 | - | - [ ] Bootstrap script for adding a fresh node: creates `<release_root>`, installs the systemd unit pointing at `<release_root>/current/server`, adds the sando SSH key to `authorized_keys`. Idempotent. | |
| 48 | - | - [ ] Garbage-collect old releases on the remote: keep last N (configurable, default 5) per node. Run at end of each successful deploy. | |
| 49 | - | - [ ] Handle `rsync` failure mid-deploy: leave the previous `current` symlink intact; mark `deploys.outcome = 'failed'`; do not advance `tier_state`. | |
| 125 | + | - [x] Implement `deploy::deploy_node` remote path: rsync staged binary to `<ssh_target>:<release_root>/releases/<version>/<bin_name>`, then `ssh <ssh_target>` does `mv -Tf` symlink swap + `sudo systemctl reload-or-restart <service>`. First real promote landed 2026-05-31: pop-os → testnot, version 0.8.12. | |
| 126 | + | - [x] Add `node.service_name` to `sando.toml` (default `makenotwork.service`). | |
| 127 | + | - [x] Bootstrap script for adding a fresh node: `MNW/sando/deploy/bootstrap-node.sh`. (See Phase 3 — node-bootstrap script for full details.) | |
| 128 | + | - [x] Garbage-collect old releases on the remote: keep last N=5 per node, sorted by mtime. Runs at end of each successful deploy (local + remote variants). Tied via `RELEASES_TO_KEEP` const. | |
| 129 | + | - [x] Handle `rsync` failure mid-deploy: leave the previous `current` symlink intact; mark `deploys.outcome = 'failed'`; do not advance `tier_state`. (Verified the routes.rs path; rsync runs before symlink swap so failure naturally leaves `current` untouched.) | |
| 130 | + | ||
| 131 | + | ### Phase 1 — Track B: testnot live-app setup (NOT blocking Phase 2) | |
| 132 | + | ||
| 133 | + | Sando's deploy machinery is done, but testnot's MNW runtime needs the rest before its `makenotwork.service` can stay up: | |
| 134 | + | ||
| 135 | + | - [ ] Provision `makenotwork` postgres role + db on testnot (postgres-18 already installed). | |
| 136 | + | - [ ] `/opt/mnw/.env` with staging Stripe keys, SMTP, S3, DATABASE_URL, all other MNW env. Decide which subset of integrations get test/sandbox credentials vs are stubbed. | |
| 137 | + | - [ ] Caddyfile for testnot.work — strip prod's blocks down to just the main reverse_proxy (and forums/cdn if needed). Cloudflare Origin CA cert for testnot.work issued + placed at `/etc/caddy/`. AOP CA already universal. | |
| 138 | + | - [ ] `error-pages/` for testnot (copy or symlink from a release dir). | |
| 139 | + | - [ ] Wire post-deploy smoke check (`curl https://testnot.work/health` after the symlink swap, before declaring deploy ok). Sando-side, gate-like; spec in Phase 2 boot_smoke wording. | |
| 50 | 140 | ||
| 51 | 141 | ## Phase 2 — Backup pipeline + migration dry-run | |
| 52 | 142 | ||
| 53 | 143 | `migration_dry_run` is the load-bearing gate. It needs a real backup source, not a fixture. | |
| 54 | 144 | ||
| 55 | - | - [ ] Confirm astra's offsite replica (per `sync-backup-offsite.sh`) writes a deterministic latest-link path Sando can rsync from. If not, add one. | |
| 56 | - | - [ ] Wire the production `sando.toml` `backup.source` to the astra rsync URL. | |
| 57 | - | - [ ] Schedule a daily `POST /backup/fetch` (systemd timer on pop-os) so a fresh backup is always within 24h of any promote attempt. | |
| 58 | - | - [ ] First end-to-end `migration_dry_run` against a real prod backup; confirm it catches the 2026-05-22 incident class (drop+recreate column migration sequence). | |
| 59 | - | - [ ] Document the failure modes: what does the operator see in `/state` when the dry-run fails? Capture in `plans/migration-dryrun-failures.md`. | |
| 60 | - | - [ ] Decide retention on `backups` table — prune rows older than N days so SQLite doesn't grow forever. | |
| 145 | + | - [x] ~~Confirm astra's offsite replica writes a deterministic latest-link path.~~ Pivoted: pull direct from prod (`backup-puller@alpha-west-1:2200`, rrsync-locked to `/opt/makenotwork/backups/`). Astra offsite is separately broken — see carryover below. | |
| 146 | + | - [x] Wire the production `sando.toml` `backup.source` — `ssh://backup-puller@alpha-west-1:2200/latest.sql.gz` with `latest.sql.gz` as a hard link on prod. | |
| 147 | + | - [x] Schedule a daily `POST /backup/fetch` (systemd timer on pop-os). `sandod-backup-fetch.{service,timer}` in `MNW/sando/deploy/`. Runs daily at 04:00 UTC (one hour after prod's 03:00 UTC backup-db.sh). Service uses `EnvironmentFile=/etc/sando/sando.env` for `$SANDO_DAEMON`. Verified 2026-05-31: one-shot test pulled 36MB backup, recorded in `backups` table. | |
| 148 | + | - [x] First end-to-end `migration_dry_run` against a real prod backup. Passed 2026-05-31 for sha 4541ebc in 1.2s: restored 36MB dump + applied all 133 migrations cleanly. Sha eee96a7 correctly failed `migration_dry_run` because it lacked migrations 123-132 that prod has applied — exactly the prod-vs-repo drift the gate is designed to catch. | |
| 149 | + | - [x] Document the failure modes: `plans/migration-dryrun-failures.md`. Covers all 7 fail modes (no backup, scratch_url unset, scratch reset, restore, drift, checksum mismatch, content broken against prod data) with operator playbook. | |
| 150 | + | - [x] Decide retention on `backups` table. 30 days; pruned at end of `backup::fetch`. `DELETE FROM backups WHERE fetched_at < datetime('now', '-30 days')`. | |
| 151 | + | ||
| 152 | + | ### Phase 2 carryovers / adjacent fires | |
| 153 | + | ||
| 154 | + | - [ ] **Offsite backup sync from prod → astra still broken.** Diagnosed 2026-05-31: `sync-backup-offsite.sh` was never deployed to prod (`deploy.sh` gap when it was added). `makenotwork@prod` had no SSH key. Generated key + installed pubkey on `max@astra:~/.ssh/authorized_keys`, created `/opt/backups/mnw` on astra. **Blocked** on Tailscale ACL: astra runs only Tailscale SSH (no regular sshd on a bypass port), and the ACL denies `tag:tagged-devices` (alpha-west-1) → astra as user `max`. Needs ACL update in the Tailscale admin console, then deploy `sync-backup-offsite.sh` to `/opt/makenotwork/` and test. Makenotwork@prod pubkey: `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzyQQ7pmBIZat8fABlpG/opwh4w5GhLIfkX2qxKxuT0 makenotwork@alpha-west-1`. | |
| 155 | + | - [x] **Prod backup `latest.sql.gz` hard link.** `backup-db.sh` now maintains `latest.sql.gz` atomically (`ln -f $LATEST.new && mv -Tf .new latest.sql.gz`). Deployed 2026-05-31; manual run verified (nlinks=2). | |
| 61 | 156 | ||
| 62 | 157 | ## Phase 3 — Parity with current `deploy.sh` | |
| 63 | 158 | ||
| 64 | - | Sando currently only ships the binary. `deploy.sh` does more. Inventory each piece and either fold it into Sando or document the explicit hand-off. | |
| 159 | + | Decisions captured in `plans/config-artifacts.md`. Summary: Caddyfile / systemd unit / backup script / security configs all move to **one-time node-bootstrap**, not per-deploy. error-pages bake into binary (MNW PR) with sibling fallback. mnw-admin ships alongside server via `bin_names: Vec<String>`. Restart warning is Phase 5, prod-tier-only. Prod migrations: server self-applies on startup (`main.rs:73`), sando does not. | |
| 65 | 160 | ||
| 66 | - | - [ ] **Caddyfile** — `deploy.sh upload_config` pushes `server/deploy/Caddyfile` to `/etc/caddy/Caddyfile` and reloads Caddy. Decide: ship as a versioned config artifact alongside the binary (cleanest), or keep Caddy config out-of-band? Capture in `plans/config-artifacts.md`. | |
| 67 | - | - [ ] **systemd unit** — `deploy.sh` uploads `makenotwork.service`. With Sando the unit points at `current/server` and shouldn't change per release. Move unit ownership to the node-bootstrap script (Phase 1) and remove from per-deploy flow. | |
| 68 | - | - [ ] **Backup script** — `backup-db.sh` is uploaded by `deploy.sh`. Move to node-bootstrap; not a per-release artifact. | |
| 69 | - | - [ ] **Error pages** — static HTML in `server/deploy/error-pages/`. Either bake into the binary (preferred — versions with code) or ship as a `releases/<version>/error-pages/` sibling. Capture decision. | |
| 70 | - | - [ ] **Security configs** — `sshd-git.conf`, `fail2ban-sshd.conf`, `setup-firewall.sh`. Move to node-bootstrap. | |
| 71 | - | - [ ] **Restart warning** — `deploy.sh send_restart_warning` posts a banner before restart. Decide whether Sando emits this and through what surface (probably the existing in-app banner mechanism). | |
| 72 | - | - [ ] **Prod migrations** — today, who runs `sqlx migrate run` against prod? `deploy.sh` doesn't (verify). Sando should run prod migrations as part of `POST /promote/{tier}` for the prod tiers, OR there should be an explicit `POST /migrate/{tier}` operator action. Decide. | |
| 161 | + | - [x] **Caddyfile** — decided: bootstrap-only. Not per-deploy. (`plans/config-artifacts.md` §1.) | |
| 162 | + | - [x] **systemd unit** — decided: bootstrap-only. (§4.) | |
| 163 | + | - [x] **Backup script** — decided: bootstrap-only. (§6.) | |
| 164 | + | - [x] **Error pages** — short-term done: ship as release-dir sibling. `build_and_run_mm` `cp -a` from `worktree/server/deploy/error-pages/` into the staged release dir; deploy_node's rsync of the whole dir picks it up. Verified on testnot 2026-05-31. Long-term `include_dir!` bake-in still a separate MNW PR. | |
| 165 | + | - [x] **mnw-admin binary** — `cfg.bin_names: Vec<String>` (default `["server"]`, MNW uses `["makenotwork","mnw-admin"]`). `deploy_local` copies each from worktree's `target/release/<bin>`; `deploy_node` rsyncs the whole staged dir. `Config::primary_bin()` returns first entry for systemd reference. `versions.artifact_path` stores the primary; release dir is derived as `.parent()`. Verified on testnot 2026-05-31. | |
| 166 | + | - [x] **Security configs** — decided: bootstrap-only. (§5.) | |
| 167 | + | - [ ] **Restart warning** — Phase 5, prod-tier only via `tier.restart_warning_seconds` in `sando.toml`; needs `CLI_SERVICE_TOKEN` in `/etc/sando/sando.env`. (§7.) | |
| 168 | + | - [x] **Cross-compile from macOS** — decided: retire after one sprint of testnot parity verification. Pop-os builds natively. (§8.) | |
| 169 | + | - [x] **Prod migrations** — decided: server self-applies on startup. Sando does NOT run them. `migration_dry_run` gate is the prod safety net. (§9.) | |
| 170 | + | - [x] **Node-bootstrap script** — `MNW/sando/deploy/bootstrap-node.sh`. Idempotent. Takes `SANDO_PUBKEY` (required), `BIN_NAME`, `SERVICE_NAME`, `SERVICE_USER`, `DEPLOY_ROOT` env. Installs base packages (rsync/ufw/fail2ban), optionally postgres/tailscale/caddy, creates deploy user + dirs + sudoers entry + systemd unit, sets up UFW. Deliberately does NOT touch Caddyfile content, certs, postgres role/db, or secrets — those are operator-decisions per-node. testnot was done by hand and matches roughly what the script produces. Test by re-running on the next node added (tier B Hetzner prod move or tier C). | |
| 73 | 171 | ||
| 74 | 172 | ## Phase 4 — Cutover | |
| 75 | 173 | ||
| @@ -86,11 +184,12 @@ | |||
| 86 | 184 | ||
| 87 | 185 | The TUI polls. The MVP requires you to hand-insert a row for `manual_confirm`. Both are fine for one operator but rough. | |
| 88 | 186 | ||
| 89 | - | - [ ] Implement `WS /events`: tail of gate starts/finishes, deploy events, build logs. Subscribe from the TUI. | |
| 90 | - | - [ ] TUI: actions pane. `p` for promote (prompts for version + tier), `R` for rollback, `b` for backup fetch, `c` for manual_confirm. | |
| 91 | - | - [ ] `POST /confirm/{tier}` endpoint that inserts a `gate_runs` row with `passed=1, gate_kind='manual_confirm'` for the current pending version. Replaces the hand-SQL workaround. | |
| 92 | - | - [ ] TUI live log pane that follows the most recent build / gate run; backed by `WS /events`. | |
| 93 | - | - [ ] `POST /promote` body should accept `version` as optional; default to the current host version when target is A, predecessor's current when target is B+. Reduces ceremony. | |
| 187 | + | - [x] Build mutex: single-slot `AppState.active_build: Mutex<Option<AbortHandle>>`; newer `/rebuild` aborts any in-flight build. Cargo commands set `.kill_on_drop(true)` so abort propagates SIGKILL to cargo + rustc children. (Landed 2026-05-31 after observing two concurrent builds racing the scratch DB.) | |
| 188 | + | - [x] Implement `WS /events`: tail of gate starts/finishes, deploy events, build logs. Event enum in `daemon/src/events.rs`; `broadcast::channel(256)` in `AppState`; emit sites in build.rs, gates.rs, routes.rs (rebuild, promote, rollback, confirm, backup_fetch). Verified 2026-05-31: live JSON envelopes stream to a python `websockets` client. | |
| 189 | + | - [x] TUI: actions pane. `↑↓`/`jk` select tier; `p` promote (no body — defaults version); `R` rollback; `b` backup fetch; `c` manual_confirm. Action results land in the events log. Daemon URL via `$SANDO_DAEMON`. Built in `tui/src/main.rs` 2026-05-31. | |
| 190 | + | - [x] `POST /confirm/{tier}` endpoint — inserts `gate_runs` row with `passed=1, gate_kind='manual_confirm'` for the tier's `current_version`. Replaces hand-SQL workaround. Verified 2026-05-31 against tier `a`. | |
| 191 | + | - [x] TUI live log pane that follows the most recent build / gate run; backed by `WS /events`. 200-event ring buffer, human-formatted per kind. WS auto-reconnects every 3s. Header shows ws connection state. | |
| 192 | + | - [x] `POST /promote` body — `version` now optional; defaults to predecessor tier's `current_version`. (Unblocks the "promote what just baked" flow.) | |
| 94 | 193 | ||
| 95 | 194 | ## Phase 6 — Monitoring + alerting | |
| 96 | 195 |
| @@ -184,6 +184,12 @@ | |||
| 184 | 184 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 185 | 185 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 186 | 186 | ||
| 187 | + | [[package]] | |
| 188 | + | name = "cfg_aliases" | |
| 189 | + | version = "0.2.1" | |
| 190 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 191 | + | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | |
| 192 | + | ||
| 187 | 193 | [[package]] | |
| 188 | 194 | name = "chrono" | |
| 189 | 195 | version = "0.4.44" | |
| @@ -381,6 +387,12 @@ | |||
| 381 | 387 | "smallvec", | |
| 382 | 388 | ] | |
| 383 | 389 | ||
| 390 | + | [[package]] | |
| 391 | + | name = "fastrand" | |
| 392 | + | version = "2.4.1" | |
| 393 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 394 | + | checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" | |
| 395 | + | ||
| 384 | 396 | [[package]] | |
| 385 | 397 | name = "find-msvc-tools" | |
| 386 | 398 | version = "0.1.9" | |
| @@ -522,8 +534,10 @@ | |||
| 522 | 534 | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" | |
| 523 | 535 | dependencies = [ | |
| 524 | 536 | "cfg-if", | |
| 537 | + | "js-sys", | |
| 525 | 538 | "libc", | |
| 526 | 539 | "wasi", | |
| 540 | + | "wasm-bindgen", | |
| 527 | 541 | ] | |
| 528 | 542 | ||
| 529 | 543 | [[package]] | |
| @@ -533,9 +547,11 @@ | |||
| 533 | 547 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" | |
| 534 | 548 | dependencies = [ | |
| 535 | 549 | "cfg-if", | |
| 550 | + | "js-sys", | |
| 536 | 551 | "libc", | |
| 537 | 552 | "r-efi", | |
| 538 | 553 | "wasip2", | |
| 554 | + | "wasm-bindgen", | |
| 539 | 555 | ] | |
| 540 | 556 | ||
| 541 | 557 | [[package]] | |
| @@ -681,6 +697,23 @@ | |||
| 681 | 697 | "pin-project-lite", | |
| 682 | 698 | "smallvec", | |
| 683 | 699 | "tokio", | |
| 700 | + | "want", | |
| 701 | + | ] | |
| 702 | + | ||
| 703 | + | [[package]] | |
| 704 | + | name = "hyper-rustls" | |
| 705 | + | version = "0.27.9" | |
| 706 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 707 | + | checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" | |
| 708 | + | dependencies = [ | |
| 709 | + | "http", | |
| 710 | + | "hyper", | |
| 711 | + | "hyper-util", | |
| 712 | + | "rustls", | |
| 713 | + | "tokio", | |
| 714 | + | "tokio-rustls", | |
| 715 | + | "tower-service", | |
| 716 | + | "webpki-roots", | |
| 684 | 717 | ] | |
| 685 | 718 | ||
| 686 | 719 | [[package]] | |
| @@ -689,13 +722,21 @@ | |||
| 689 | 722 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 690 | 723 | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" | |
| 691 | 724 | dependencies = [ | |
| 725 | + | "base64", | |
| 692 | 726 | "bytes", | |
| 727 | + | "futures-channel", | |
| 728 | + | "futures-util", | |
| 693 | 729 | "http", | |
| 694 | 730 | "http-body", | |
| 695 | 731 | "hyper", | |
| 732 | + | "ipnet", | |
| 733 | + | "libc", | |
| 734 | + | "percent-encoding", | |
| 696 | 735 | "pin-project-lite", | |
| 736 | + | "socket2", | |
| 697 | 737 | "tokio", | |
| 698 | 738 | "tower-service", | |
| 739 | + | "tracing", | |
| 699 | 740 | ] | |
| 700 | 741 | ||
| 701 | 742 | [[package]] | |
| @@ -835,6 +876,12 @@ | |||
| 835 | 876 | "hashbrown 0.17.1", | |
| 836 | 877 | ] | |
| 837 | 878 | ||
| 879 | + | [[package]] | |
| 880 | + | name = "ipnet" | |
| 881 | + | version = "2.12.0" | |
| 882 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 883 | + | checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" | |
| 884 | + | ||
| 838 | 885 | [[package]] | |
| 839 | 886 | name = "itoa" | |
| 840 | 887 | version = "1.0.18" | |
| @@ -908,6 +955,12 @@ | |||
| 908 | 955 | "vcpkg", | |
| 909 | 956 | ] | |
| 910 | 957 | ||
| 958 | + | [[package]] | |
| 959 | + | name = "linux-raw-sys" | |
| 960 | + | version = "0.12.1" | |
| 961 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 962 | + | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | |
| 963 | + | ||
| 911 | 964 | [[package]] | |
| 912 | 965 | name = "litemap" | |
| 913 | 966 | version = "0.8.2" | |
| @@ -942,6 +995,12 @@ | |||
| 942 | 995 | "tracing-subscriber", | |
| 943 | 996 | ] | |
| 944 | 997 | ||
| 998 | + | [[package]] | |
| 999 | + | name = "lru-slab" | |
| 1000 | + | version = "0.1.2" | |
| 1001 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1002 | + | checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" | |
| 1003 | + | ||
| 945 | 1004 | [[package]] | |
| 946 | 1005 | name = "matchers" | |
| 947 | 1006 | version = "0.2.0" | |
| @@ -1224,6 +1283,61 @@ | |||
| 1224 | 1283 | "winapi", | |
| 1225 | 1284 | ] | |
| 1226 | 1285 | ||
| 1286 | + | [[package]] | |
| 1287 | + | name = "quinn" | |
| 1288 | + | version = "0.11.9" | |
| 1289 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1290 | + | checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" | |
| 1291 | + | dependencies = [ | |
| 1292 | + | "bytes", | |
| 1293 | + | "cfg_aliases", | |
| 1294 | + | "pin-project-lite", | |
| 1295 | + | "quinn-proto", | |
| 1296 | + | "quinn-udp", | |
| 1297 | + | "rustc-hash", | |
| 1298 | + | "rustls", | |
| 1299 | + | "socket2", | |
| 1300 | + | "thiserror", | |
| 1301 | + | "tokio", | |
| 1302 | + | "tracing", | |
| 1303 | + | "web-time", | |
| 1304 | + | ] | |
| 1305 | + | ||
| 1306 | + | [[package]] | |
| 1307 | + | name = "quinn-proto" | |
| 1308 | + | version = "0.11.14" | |
| 1309 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1310 | + | checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" | |
| 1311 | + | dependencies = [ | |
| 1312 | + | "bytes", | |
| 1313 | + | "getrandom 0.3.4", | |
| 1314 | + | "lru-slab", | |
| 1315 | + | "rand 0.9.4", | |
| 1316 | + | "ring", | |
| 1317 | + | "rustc-hash", | |
| 1318 | + | "rustls", | |
| 1319 | + | "rustls-pki-types", | |
| 1320 | + | "slab", | |
| 1321 | + | "thiserror", | |
| 1322 | + | "tinyvec", | |
| 1323 | + | "tracing", | |
| 1324 | + | "web-time", | |
| 1325 | + | ] | |
| 1326 | + | ||
| 1327 | + | [[package]] | |
| 1328 | + | name = "quinn-udp" | |
| 1329 | + | version = "0.5.14" | |
| 1330 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1331 | + | checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" | |
| 1332 | + | dependencies = [ | |
| 1333 | + | "cfg_aliases", | |
| 1334 | + | "libc", | |
| 1335 | + | "once_cell", | |
| 1336 | + | "socket2", | |
| 1337 | + | "tracing", | |
| 1338 | + | "windows-sys 0.60.2", | |
| 1339 | + | ] | |
| 1340 | + | ||
| 1227 | 1341 | [[package]] | |
| 1228 | 1342 | name = "quote" | |
| 1229 | 1343 | version = "1.0.45" | |
| @@ -1360,6 +1474,58 @@ | |||
| 1360 | 1474 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1361 | 1475 | checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" | |
| 1362 | 1476 | ||
| 1477 | + | [[package]] | |
| 1478 | + | name = "reqwest" | |
| 1479 | + | version = "0.12.28" | |
| 1480 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1481 | + | checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" | |
| 1482 | + | dependencies = [ | |
| 1483 | + | "base64", | |
| 1484 | + | "bytes", | |
| 1485 | + | "futures-core", | |
| 1486 | + | "http", | |
| 1487 | + | "http-body", | |
| 1488 | + | "http-body-util", | |
| 1489 | + | "hyper", | |
| 1490 | + | "hyper-rustls", | |
| 1491 | + | "hyper-util", | |
| 1492 | + | "js-sys", | |
| 1493 | + | "log", | |
| 1494 | + | "percent-encoding", | |
| 1495 | + | "pin-project-lite", | |
| 1496 | + | "quinn", | |
| 1497 | + | "rustls", | |
| 1498 | + | "rustls-pki-types", | |
| 1499 | + | "serde", | |
| 1500 | + | "serde_json", | |
| 1501 | + | "serde_urlencoded", | |
| 1502 | + | "sync_wrapper", | |
| 1503 | + | "tokio", | |
| 1504 | + | "tokio-rustls", | |
| 1505 | + | "tower", | |
| 1506 | + | "tower-http", | |
| 1507 | + | "tower-service", | |
| 1508 | + | "url", | |
| 1509 | + | "wasm-bindgen", | |
| 1510 | + | "wasm-bindgen-futures", | |
| 1511 | + | "web-sys", | |
| 1512 | + | "webpki-roots", | |
| 1513 | + | ] | |
| 1514 | + | ||
| 1515 | + | [[package]] | |
| 1516 | + | name = "ring" | |
| 1517 | + | version = "0.17.14" | |
| 1518 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1519 | + | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" | |
| 1520 | + | dependencies = [ | |
| 1521 | + | "cc", | |
| 1522 | + | "cfg-if", | |
| 1523 | + | "getrandom 0.2.17", | |
| 1524 | + | "libc", | |
| 1525 | + | "untrusted", | |
| 1526 | + | "windows-sys 0.52.0", | |
| 1527 | + | ] | |
| 1528 | + | ||
| 1363 | 1529 | [[package]] | |
| 1364 | 1530 | name = "rsa" | |
| 1365 | 1531 | version = "0.9.10" | |
| @@ -1380,6 +1546,60 @@ | |||
| 1380 | 1546 | "zeroize", | |
| 1381 | 1547 | ] | |
| 1382 | 1548 | ||
| 1549 | + | [[package]] | |
| 1550 | + | name = "rustc-hash" | |
| 1551 | + | version = "2.1.2" | |
| 1552 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1553 | + | checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" | |
| 1554 | + | ||
| 1555 | + | [[package]] | |
| 1556 | + | name = "rustix" | |
| 1557 | + | version = "1.1.4" | |
| 1558 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1559 | + | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | |
| 1560 | + | dependencies = [ | |
| 1561 | + | "bitflags", | |
| 1562 | + | "errno", | |
| 1563 | + | "libc", | |
| 1564 | + | "linux-raw-sys", | |
| 1565 | + | "windows-sys 0.61.2", | |
| 1566 | + | ] | |
| 1567 | + | ||
| 1568 | + | [[package]] | |
| 1569 | + | name = "rustls" | |
| 1570 | + | version = "0.23.40" | |
| 1571 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1572 | + | checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" | |
| 1573 | + | dependencies = [ | |
| 1574 | + | "once_cell", | |
| 1575 | + | "ring", | |
| 1576 | + | "rustls-pki-types", | |
| 1577 | + | "rustls-webpki", | |
| 1578 | + | "subtle", | |
| 1579 | + | "zeroize", | |
| 1580 | + | ] | |
| 1581 | + | ||
| 1582 | + | [[package]] | |
| 1583 | + | name = "rustls-pki-types" | |
| 1584 | + | version = "1.14.1" | |
| 1585 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1586 | + | checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" | |
| 1587 | + | dependencies = [ | |
| 1588 | + | "web-time", | |
| 1589 | + | "zeroize", | |
| 1590 | + | ] | |
| 1591 | + | ||
| 1592 | + | [[package]] | |
| 1593 | + | name = "rustls-webpki" | |
| 1594 | + | version = "0.103.13" | |
| 1595 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1596 | + | checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" | |
| 1597 | + | dependencies = [ | |
| 1598 | + | "ring", | |
| 1599 | + | "rustls-pki-types", | |
| 1600 | + | "untrusted", | |
| 1601 | + | ] | |
| 1602 | + | ||
| 1383 | 1603 | [[package]] | |
| 1384 | 1604 | name = "rustversion" | |
| 1385 | 1605 | version = "1.0.22" | |
| @@ -1399,14 +1619,18 @@ | |||
| 1399 | 1619 | "anyhow", | |
| 1400 | 1620 | "axum", | |
| 1401 | 1621 | "chrono", | |
| 1622 | + | "http-body-util", | |
| 1402 | 1623 | "metrics", | |
| 1403 | 1624 | "metrics-exporter-prometheus", | |
| 1625 | + | "reqwest", | |
| 1404 | 1626 | "serde", | |
| 1405 | 1627 | "serde_json", | |
| 1406 | 1628 | "sqlx", | |
| 1629 | + | "tempfile", | |
| 1407 | 1630 | "thiserror", | |
| 1408 | 1631 | "tokio", | |
| 1409 | 1632 | "toml", | |
| 1633 | + | "tower", | |
| 1410 | 1634 | "tracing", | |
| 1411 | 1635 | "tracing-subscriber", | |
| 1412 | 1636 | ] | |
| @@ -1836,6 +2060,9 @@ | |||
| 1836 | 2060 | version = "1.0.2" | |
| 1837 | 2061 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1838 | 2062 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | |
| 2063 | + | dependencies = [ | |
| 2064 | + | "futures-core", | |
| 2065 | + | ] | |
| 1839 | 2066 | ||
| 1840 | 2067 | [[package]] | |
| 1841 | 2068 | name = "synstructure" | |
| @@ -1848,6 +2075,19 @@ | |||
| 1848 | 2075 | "syn", | |
| 1849 | 2076 | ] | |
| 1850 | 2077 | ||
| 2078 | + | [[package]] | |
| 2079 | + | name = "tempfile" | |
| 2080 | + | version = "3.27.0" | |
| 2081 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2082 | + | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 2083 | + | dependencies = [ | |
| 2084 | + | "fastrand", | |
| 2085 | + | "getrandom 0.3.4", | |
| 2086 | + | "once_cell", | |
| 2087 | + | "rustix", | |
| 2088 | + | "windows-sys 0.61.2", | |
| 2089 | + | ] | |
| 2090 | + | ||
| 1851 | 2091 | [[package]] | |
| 1852 | 2092 | name = "thiserror" | |
| 1853 | 2093 | version = "2.0.18" | |
| @@ -1929,6 +2169,16 @@ | |||
| 1929 | 2169 | "syn", | |
| 1930 | 2170 | ] | |
| 1931 | 2171 | ||
| 2172 | + | [[package]] | |
| 2173 | + | name = "tokio-rustls" | |
| 2174 | + | version = "0.26.4" | |
| 2175 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2176 | + | checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" | |
| 2177 | + | dependencies = [ | |
| 2178 | + | "rustls", | |
| 2179 | + | "tokio", | |
| 2180 | + | ] | |
| 2181 | + | ||
| 1932 | 2182 | [[package]] | |
| 1933 | 2183 | name = "tokio-stream" | |
| 1934 | 2184 | version = "0.1.18" | |
| @@ -2009,6 +2259,24 @@ | |||
| 2009 | 2259 | "tracing", | |
| 2010 | 2260 | ] | |
| 2011 | 2261 | ||
| 2262 | + | [[package]] | |
| 2263 | + | name = "tower-http" | |
| 2264 | + | version = "0.6.11" | |
| 2265 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2266 | + | checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" | |
| 2267 | + | dependencies = [ | |
| 2268 | + | "bitflags", | |
| 2269 | + | "bytes", | |
| 2270 | + | "futures-util", | |
| 2271 | + | "http", | |
| 2272 | + | "http-body", | |
| 2273 | + | "pin-project-lite", | |
| 2274 | + | "tower", | |
| 2275 | + | "tower-layer", | |
| 2276 | + | "tower-service", | |
| 2277 | + | "url", | |
| 2278 | + | ] | |
| 2279 | + | ||
| 2012 | 2280 | [[package]] | |
| 2013 | 2281 | name = "tower-layer" | |
| 2014 | 2282 | version = "0.3.3" | |
| @@ -2096,6 +2364,12 @@ | |||
| 2096 | 2364 | "tracing-serde", | |
| 2097 | 2365 | ] | |
| 2098 | 2366 | ||
| 2367 | + | [[package]] | |
| 2368 | + | name = "try-lock" | |
| 2369 | + | version = "0.2.5" | |
| 2370 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2371 | + | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | |
| 2372 | + | ||
| 2099 | 2373 | [[package]] | |
| 2100 | 2374 | name = "tungstenite" | |
| 2101 | 2375 | version = "0.29.0" | |
| @@ -2145,6 +2419,12 @@ | |||
| 2145 | 2419 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2146 | 2420 | checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" | |
| 2147 | 2421 | ||
| 2422 | + | [[package]] | |
| 2423 | + | name = "untrusted" | |
| 2424 | + | version = "0.9.0" | |
| 2425 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2426 | + | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" | |
| 2427 | + | ||
| 2148 | 2428 | [[package]] | |
| 2149 | 2429 | name = "url" | |
| 2150 | 2430 | version = "2.5.8" | |
| @@ -2181,6 +2461,15 @@ | |||
| 2181 | 2461 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2182 | 2462 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | |
| 2183 | 2463 | ||
| 2464 | + | [[package]] | |
| 2465 | + | name = "want" | |
| 2466 | + | version = "0.3.1" | |
| 2467 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2468 | + | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" | |
| 2469 | + | dependencies = [ | |
| 2470 | + | "try-lock", | |
| 2471 | + | ] | |
| 2472 | + | ||
| 2184 | 2473 | [[package]] | |
| 2185 | 2474 | name = "wasi" | |
| 2186 | 2475 | version = "0.11.1+wasi-snapshot-preview1" | |
| @@ -2215,6 +2504,16 @@ | |||
| 2215 | 2504 | "wasm-bindgen-shared", | |
| 2216 | 2505 | ] | |
| 2217 | 2506 | ||
| 2507 | + | [[package]] | |
| 2508 | + | name = "wasm-bindgen-futures" | |
| 2509 | + | version = "0.4.72" | |
| 2510 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2511 | + | checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" | |
| 2512 | + | dependencies = [ | |
| 2513 | + | "js-sys", | |
| 2514 | + | "wasm-bindgen", | |
| 2515 | + | ] | |
| 2516 | + | ||
| 2218 | 2517 | [[package]] | |
| 2219 | 2518 | name = "wasm-bindgen-macro" | |
| 2220 | 2519 | version = "0.2.122" | |
| @@ -2257,6 +2556,25 @@ | |||
| 2257 | 2556 | "wasm-bindgen", | |
| 2258 | 2557 | ] | |
| 2259 | 2558 | ||
| 2559 | + | [[package]] | |
| 2560 | + | name = "web-time" | |
| 2561 | + | version = "1.1.0" | |
| 2562 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2563 | + | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" | |
| 2564 | + | dependencies = [ | |
| 2565 | + | "js-sys", | |
| 2566 | + | "wasm-bindgen", | |
| 2567 | + | ] | |
| 2568 | + | ||
| 2569 | + | [[package]] | |
| 2570 | + | name = "webpki-roots" | |
| 2571 | + | version = "1.0.7" | |
| 2572 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2573 | + | checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" | |
| 2574 | + | dependencies = [ | |
| 2575 | + | "rustls-pki-types", | |
| 2576 | + | ] | |
| 2577 | + | ||
| 2260 | 2578 | [[package]] | |
| 2261 | 2579 | name = "whoami" | |
| 2262 | 2580 | version = "1.6.1" | |
| @@ -2354,7 +2672,25 @@ | |||
| 2354 | 2672 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2355 | 2673 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" | |
| 2356 | 2674 | dependencies = [ | |
| 2357 | - | "windows-targets", | |
| 2675 | + | "windows-targets 0.48.5", | |
| 2676 | + | ] | |
| 2677 | + | ||
| 2678 | + | [[package]] | |
| 2679 | + | name = "windows-sys" | |
| 2680 | + | version = "0.52.0" | |
| 2681 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2682 | + | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" | |
| 2683 | + | dependencies = [ | |
| 2684 | + | "windows-targets 0.52.6", | |
| 2685 | + | ] | |
| 2686 | + | ||
| 2687 | + | [[package]] | |
| 2688 | + | name = "windows-sys" | |
| 2689 | + | version = "0.60.2" | |
| 2690 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2691 | + | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" | |
| 2692 | + | dependencies = [ | |
| 2693 | + | "windows-targets 0.53.5", | |
| 2358 | 2694 | ] | |
| 2359 | 2695 | ||
| 2360 | 2696 | [[package]] | |
| @@ -2372,13 +2708,46 @@ | |||
| 2372 | 2708 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2373 | 2709 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" | |
| 2374 | 2710 | dependencies = [ | |
| 2375 | - | "windows_aarch64_gnullvm", |
Lines truncated
| @@ -22,3 +22,9 @@ | |||
| 22 | 22 | anyhow = "1.0.102" | |
| 23 | 23 | thiserror = "2.0.18" | |
| 24 | 24 | chrono = { version = "0.4", features = ["serde"] } | |
| 25 | + | ||
| 26 | + | [dev-dependencies] | |
| 27 | + | tempfile = "3.20" | |
| 28 | + | tower = { version = "0.5", features = ["util"] } | |
| 29 | + | http-body-util = "0.1" | |
| 30 | + | reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } |
| @@ -5,5 +5,6 @@ | |||
| 5 | 5 | db_path = "/srv/sando/state/sando.db" | |
| 6 | 6 | topology_path = "/etc/sando/sando.toml" | |
| 7 | 7 | workdir = "/srv/sando/work" | |
| 8 | - | release_root = "/srv/sando/releases" | |
| 8 | + | release_root = "/srv/sando" | |
| 9 | 9 | scratch_db_url = "postgres:///sando_scratch?host=/var/run/postgresql" | |
| 10 | + | bin_names = ["makenotwork", "mnw-admin"] |
| @@ -8,6 +8,15 @@ | |||
| 8 | 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | 9 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 10 | 10 | ||
| 11 | + | [[package]] | |
| 12 | + | name = "android_system_properties" | |
| 13 | + | version = "0.1.5" | |
| 14 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 16 | + | dependencies = [ | |
| 17 | + | "libc", | |
| 18 | + | ] | |
| 19 | + | ||
| 11 | 20 | [[package]] | |
| 12 | 21 | name = "anyhow" | |
| 13 | 22 | version = "1.0.102" | |
| @@ -20,6 +29,12 @@ | |||
| 20 | 29 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 21 | 30 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 22 | 31 | ||
| 32 | + | [[package]] | |
| 33 | + | name = "autocfg" | |
| 34 | + | version = "1.5.1" | |
| 35 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 36 | + | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 37 | + | ||
| 23 | 38 | [[package]] | |
| 24 | 39 | name = "base64" | |
| 25 | 40 | version = "0.22.1" | |
| @@ -32,12 +47,27 @@ | |||
| 32 | 47 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 33 | 48 | checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" | |
| 34 | 49 | ||
| 50 | + | [[package]] | |
| 51 | + | name = "block-buffer" | |
| 52 | + | version = "0.10.4" | |
| 53 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 54 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 55 | + | dependencies = [ | |
| 56 | + | "generic-array", | |
| 57 | + | ] | |
| 58 | + | ||
| 35 | 59 | [[package]] | |
| 36 | 60 | name = "bumpalo" | |
| 37 | 61 | version = "3.20.3" | |
| 38 | 62 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | 63 | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 40 | 64 | ||
| 65 | + | [[package]] | |
| 66 | + | name = "byteorder" | |
| 67 | + | version = "1.5.0" | |
| 68 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 69 | + | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 70 | + | ||
| 41 | 71 | [[package]] | |
| 42 | 72 | name = "bytes" | |
| 43 | 73 | version = "1.11.1" | |
| @@ -81,6 +111,18 @@ | |||
| 81 | 111 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 82 | 112 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | |
| 83 | 113 | ||
| 114 | + | [[package]] | |
| 115 | + | name = "chrono" | |
| 116 | + | version = "0.4.44" | |
| 117 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 118 | + | checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" | |
| 119 | + | dependencies = [ | |
| 120 | + | "iana-time-zone", | |
| 121 | + | "num-traits", | |
| 122 | + | "serde", | |
| 123 | + | "windows-link", | |
| 124 | + | ] | |
| 125 | + | ||
| 84 | 126 | [[package]] | |
| 85 | 127 | name = "compact_str" | |
| 86 | 128 | version = "0.8.1" | |
| @@ -95,6 +137,21 @@ | |||
| 95 | 137 | "static_assertions", | |
| 96 | 138 | ] | |
| 97 | 139 | ||
| 140 | + | [[package]] | |
| 141 | + | name = "core-foundation-sys" | |
| 142 | + | version = "0.8.7" | |
| 143 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 145 | + | ||
| 146 | + | [[package]] | |
| 147 | + | name = "cpufeatures" | |
| 148 | + | version = "0.2.17" | |
| 149 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 150 | + | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 151 | + | dependencies = [ | |
| 152 | + | "libc", | |
| 153 | + | ] | |
| 154 | + | ||
| 98 | 155 | [[package]] | |
| 99 | 156 | name = "crossterm" | |
| 100 | 157 | version = "0.28.1" | |
| @@ -120,6 +177,16 @@ | |||
| 120 | 177 | "winapi", | |
| 121 | 178 | ] | |
| 122 | 179 | ||
| 180 | + | [[package]] | |
| 181 | + | name = "crypto-common" | |
| 182 | + | version = "0.1.7" | |
| 183 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 184 | + | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | |
| 185 | + | dependencies = [ | |
| 186 | + | "generic-array", | |
| 187 | + | "typenum", | |
| 188 | + | ] | |
| 189 | + | ||
| 123 | 190 | [[package]] | |
| 124 | 191 | name = "darling" | |
| 125 | 192 | version = "0.23.0" | |
| @@ -154,6 +221,22 @@ | |||
| 154 | 221 | "syn", | |
| 155 | 222 | ] | |
| 156 | 223 | ||
| 224 | + | [[package]] | |
| 225 | + | name = "data-encoding" | |
| 226 | + | version = "2.11.0" | |
| 227 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 228 | + | checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" | |
| 229 | + | ||
| 230 | + | [[package]] | |
| 231 | + | name = "digest" | |
| 232 | + | version = "0.10.7" | |
| 233 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 234 | + | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 235 | + | dependencies = [ | |
| 236 | + | "block-buffer", | |
| 237 | + | "crypto-common", | |
| 238 | + | ] | |
| 239 | + | ||
| 157 | 240 | [[package]] | |
| 158 | 241 | name = "displaydoc" | |
| 159 | 242 | version = "0.2.5" | |
| @@ -223,6 +306,12 @@ | |||
| 223 | 306 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 224 | 307 | checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" | |
| 225 | 308 | ||
| 309 | + | [[package]] | |
| 310 | + | name = "futures-sink" | |
| 311 | + | version = "0.3.32" | |
| 312 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 313 | + | checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" | |
| 314 | + | ||
| 226 | 315 | [[package]] | |
| 227 | 316 | name = "futures-task" | |
| 228 | 317 | version = "0.3.32" | |
| @@ -236,11 +325,22 @@ | |||
| 236 | 325 | checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" | |
| 237 | 326 | dependencies = [ | |
| 238 | 327 | "futures-core", | |
| 328 | + | "futures-sink", | |
| 239 | 329 | "futures-task", | |
| 240 | 330 | "pin-project-lite", | |
| 241 | 331 | "slab", | |
| 242 | 332 | ] | |
| 243 | 333 | ||
| 334 | + | [[package]] | |
| 335 | + | name = "generic-array" | |
| 336 | + | version = "0.14.7" | |
| 337 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 338 | + | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" | |
| 339 | + | dependencies = [ | |
| 340 | + | "typenum", | |
| 341 | + | "version_check", | |
| 342 | + | ] | |
| 343 | + | ||
| 244 | 344 | [[package]] | |
| 245 | 345 | name = "getrandom" | |
| 246 | 346 | version = "0.2.17" | |
| @@ -357,7 +457,7 @@ | |||
| 357 | 457 | "tokio", | |
| 358 | 458 | "tokio-rustls", | |
| 359 | 459 | "tower-service", | |
| 360 | - | "webpki-roots", | |
| 460 | + | "webpki-roots 1.0.7", | |
| 361 | 461 | ] | |
| 362 | 462 | ||
| 363 | 463 | [[package]] | |
| @@ -383,6 +483,30 @@ | |||
| 383 | 483 | "tracing", | |
| 384 | 484 | ] | |
| 385 | 485 | ||
| 486 | + | [[package]] | |
| 487 | + | name = "iana-time-zone" | |
| 488 | + | version = "0.1.65" | |
| 489 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 490 | + | checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" | |
| 491 | + | dependencies = [ | |
| 492 | + | "android_system_properties", | |
| 493 | + | "core-foundation-sys", | |
| 494 | + | "iana-time-zone-haiku", | |
| 495 | + | "js-sys", | |
| 496 | + | "log", | |
| 497 | + | "wasm-bindgen", | |
| 498 | + | "windows-core", | |
| 499 | + | ] | |
| 500 | + | ||
| 501 | + | [[package]] | |
| 502 | + | name = "iana-time-zone-haiku" | |
| 503 | + | version = "0.1.2" | |
| 504 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 505 | + | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" | |
| 506 | + | dependencies = [ | |
| 507 | + | "cc", | |
| 508 | + | ] | |
| 509 | + | ||
| 386 | 510 | [[package]] | |
| 387 | 511 | name = "icu_collections" | |
| 388 | 512 | version = "2.2.0" | |
| @@ -613,6 +737,15 @@ | |||
| 613 | 737 | "windows-sys 0.61.2", | |
| 614 | 738 | ] | |
| 615 | 739 | ||
| 740 | + | [[package]] | |
| 741 | + | name = "num-traits" | |
| 742 | + | version = "0.2.19" | |
| 743 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 744 | + | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 745 | + | dependencies = [ | |
| 746 | + | "autocfg", | |
| 747 | + | ] | |
| 748 | + | ||
| 616 | 749 | [[package]] | |
| 617 | 750 | name = "once_cell" | |
| 618 | 751 | version = "1.21.4" | |
| @@ -701,7 +834,7 @@ | |||
| 701 | 834 | "rustc-hash", | |
| 702 | 835 | "rustls", | |
| 703 | 836 | "socket2", | |
| 704 | - | "thiserror", | |
| 837 | + | "thiserror 2.0.18", | |
| 705 | 838 | "tokio", | |
| 706 | 839 | "tracing", | |
| 707 | 840 | "web-time", | |
| @@ -716,13 +849,13 @@ | |||
| 716 | 849 | "bytes", | |
| 717 | 850 | "getrandom 0.3.4", | |
| 718 | 851 | "lru-slab", | |
| 719 | - | "rand", | |
| 852 | + | "rand 0.9.4", | |
| 720 | 853 | "ring", | |
| 721 | 854 | "rustc-hash", | |
| 722 | 855 | "rustls", | |
| 723 | 856 | "rustls-pki-types", | |
| 724 | 857 | "slab", | |
| 725 | - | "thiserror", | |
| 858 | + | "thiserror 2.0.18", | |
| 726 | 859 | "tinyvec", | |
| 727 | 860 | "tracing", | |
| 728 | 861 | "web-time", | |
| @@ -757,14 +890,35 @@ | |||
| 757 | 890 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 758 | 891 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" | |
| 759 | 892 | ||
| 893 | + | [[package]] | |
| 894 | + | name = "rand" | |
| 895 | + | version = "0.8.6" | |
| 896 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 897 | + | checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" | |
| 898 | + | dependencies = [ | |
| 899 | + | "libc", | |
| 900 | + | "rand_chacha 0.3.1", | |
| 901 | + | "rand_core 0.6.4", | |
| 902 | + | ] | |
| 903 | + | ||
| 760 | 904 | [[package]] | |
| 761 | 905 | name = "rand" | |
| 762 | 906 | version = "0.9.4" | |
| 763 | 907 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 764 | 908 | checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" | |
| 765 | 909 | dependencies = [ | |
| 766 | - | "rand_chacha", | |
| 767 | - | "rand_core", | |
| 910 | + | "rand_chacha 0.9.0", | |
| 911 | + | "rand_core 0.9.5", | |
| 912 | + | ] | |
| 913 | + | ||
| 914 | + | [[package]] | |
| 915 | + | name = "rand_chacha" | |
| 916 | + | version = "0.3.1" | |
| 917 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 918 | + | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" | |
| 919 | + | dependencies = [ | |
| 920 | + | "ppv-lite86", | |
| 921 | + | "rand_core 0.6.4", | |
| 768 | 922 | ] | |
| 769 | 923 | ||
| 770 | 924 | [[package]] | |
| @@ -774,7 +928,16 @@ | |||
| 774 | 928 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | |
| 775 | 929 | dependencies = [ | |
| 776 | 930 | "ppv-lite86", | |
| 777 | - | "rand_core", | |
| 931 | + | "rand_core 0.9.5", | |
| 932 | + | ] | |
| 933 | + | ||
| 934 | + | [[package]] | |
| 935 | + | name = "rand_core" | |
| 936 | + | version = "0.6.4" | |
| 937 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 938 | + | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" | |
| 939 | + | dependencies = [ | |
| 940 | + | "getrandom 0.2.17", | |
| 778 | 941 | ] | |
| 779 | 942 | ||
| 780 | 943 | [[package]] | |
| @@ -851,7 +1014,7 @@ | |||
| 851 | 1014 | "wasm-bindgen", | |
| 852 | 1015 | "wasm-bindgen-futures", | |
| 853 | 1016 | "web-sys", | |
| 854 | - | "webpki-roots", | |
| 1017 | + | "webpki-roots 1.0.7", | |
| 855 | 1018 | ] | |
| 856 | 1019 | ||
| 857 | 1020 | [[package]] | |
| @@ -939,12 +1102,15 @@ | |||
| 939 | 1102 | version = "0.1.0" | |
| 940 | 1103 | dependencies = [ | |
| 941 | 1104 | "anyhow", | |
| 1105 | + | "chrono", | |
| 942 | 1106 | "crossterm", | |
| 1107 | + | "futures-util", | |
| 943 | 1108 | "ratatui", | |
| 944 | 1109 | "reqwest", | |
| 945 | 1110 | "serde", | |
| 946 | 1111 | "serde_json", | |
| 947 | 1112 | "tokio", | |
| 1113 | + | "tokio-tungstenite", | |
| 948 | 1114 | ] | |
| 949 | 1115 | ||
| 950 | 1116 | [[package]] | |
| @@ -1008,6 +1174,17 @@ | |||
| 1008 | 1174 | "serde", | |
| 1009 | 1175 | ] | |
| 1010 | 1176 | ||
| 1177 | + | [[package]] | |
| 1178 | + | name = "sha1" | |
| 1179 | + | version = "0.10.6" | |
| 1180 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1181 | + | checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" | |
| 1182 | + | dependencies = [ | |
| 1183 | + | "cfg-if", | |
| 1184 | + | "cpufeatures", | |
| 1185 | + | "digest", | |
| 1186 | + | ] | |
| 1187 | + | ||
| 1011 | 1188 | [[package]] | |
| 1012 | 1189 | name = "shlex" | |
| 1013 | 1190 | version = "1.3.0" | |
| @@ -1144,13 +1321,33 @@ | |||
| 1144 | 1321 | "syn", | |
| 1145 | 1322 | ] | |
| 1146 | 1323 | ||
| 1324 | + | [[package]] | |
| 1325 | + | name = "thiserror" | |
| 1326 | + | version = "1.0.69" | |
| 1327 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1328 | + | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" | |
| 1329 | + | dependencies = [ | |
| 1330 | + | "thiserror-impl 1.0.69", | |
| 1331 | + | ] | |
| 1332 | + | ||
| 1147 | 1333 | [[package]] | |
| 1148 | 1334 | name = "thiserror" | |
| 1149 | 1335 | version = "2.0.18" | |
| 1150 | 1336 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1151 | 1337 | checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" | |
| 1152 | 1338 | dependencies = [ | |
| 1153 | - | "thiserror-impl", | |
| 1339 | + | "thiserror-impl 2.0.18", | |
| 1340 | + | ] | |
| 1341 | + | ||
| 1342 | + | [[package]] | |
| 1343 | + | name = "thiserror-impl" | |
| 1344 | + | version = "1.0.69" | |
| 1345 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1346 | + | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" | |
| 1347 | + | dependencies = [ | |
| 1348 | + | "proc-macro2", | |
| 1349 | + | "quote", | |
| 1350 | + | "syn", | |
| 1154 | 1351 | ] | |
| 1155 | 1352 | ||
| 1156 | 1353 | [[package]] | |
| @@ -1226,6 +1423,22 @@ | |||
| 1226 | 1423 | "tokio", | |
| 1227 | 1424 | ] | |
| 1228 | 1425 | ||
| 1426 | + | [[package]] | |
| 1427 | + | name = "tokio-tungstenite" | |
| 1428 | + | version = "0.24.0" | |
| 1429 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1430 | + | checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" | |
| 1431 | + | dependencies = [ | |
| 1432 | + | "futures-util", | |
| 1433 | + | "log", | |
| 1434 | + | "rustls", | |
| 1435 | + | "rustls-pki-types", | |
| 1436 | + | "tokio", | |
| 1437 | + | "tokio-rustls", | |
| 1438 | + | "tungstenite", | |
| 1439 | + | "webpki-roots 0.26.11", | |
| 1440 | + | ] | |
| 1441 | + | ||
| 1229 | 1442 | [[package]] | |
| 1230 | 1443 | name = "tower" | |
| 1231 | 1444 | version = "0.5.3" | |
| @@ -1296,6 +1509,32 @@ | |||
| 1296 | 1509 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1297 | 1510 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | |
| 1298 | 1511 | ||
| 1512 | + | [[package]] | |
| 1513 | + | name = "tungstenite" | |
| 1514 | + | version = "0.24.0" | |
| 1515 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1516 | + | checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" | |
| 1517 | + | dependencies = [ | |
| 1518 | + | "byteorder", | |
| 1519 | + | "bytes", | |
| 1520 | + | "data-encoding", | |
| 1521 | + | "http", | |
| 1522 | + | "httparse", | |
| 1523 | + | "log", | |
| 1524 | + | "rand 0.8.6", | |
| 1525 | + | "rustls", | |
| 1526 | + | "rustls-pki-types", | |
| 1527 | + | "sha1", | |
| 1528 | + | "thiserror 1.0.69", | |
| 1529 | + | "utf-8", | |
| 1530 | + | ] | |
| 1531 | + | ||
| 1532 | + | [[package]] | |
| 1533 | + | name = "typenum" | |
| 1534 | + | version = "1.20.1" | |
| 1535 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1536 | + | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | |
| 1537 | + | ||
| 1299 | 1538 | [[package]] | |
| 1300 | 1539 | name = "unicode-ident" | |
| 1301 | 1540 | version = "1.0.24" | |
| @@ -1349,12 +1588,24 @@ | |||
| 1349 | 1588 | "serde", | |
| 1350 | 1589 | ] | |
| 1351 | 1590 | ||
| 1591 | + | [[package]] | |
| 1592 | + | name = "utf-8" | |
| 1593 | + | version = "0.7.6" | |
| 1594 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1595 | + | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" | |
| 1596 | + | ||
| 1352 | 1597 | [[package]] | |
| 1353 | 1598 | name = "utf8_iter" | |
| 1354 | 1599 | version = "1.0.4" | |
| 1355 | 1600 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1356 | 1601 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | |
| 1357 | 1602 | ||
| 1603 | + | [[package]] | |
| 1604 | + | name = "version_check" | |
| 1605 | + | version = "0.9.5" | |
| 1606 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1607 | + | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | |
| 1608 | + | ||
| 1358 | 1609 | [[package]] | |
| 1359 | 1610 | name = "want" | |
| 1360 | 1611 | version = "0.3.1" | |
| @@ -1454,6 +1705,15 @@ | |||
| 1454 | 1705 | "wasm-bindgen", | |
| 1455 | 1706 | ] | |
| 1456 | 1707 | ||
| 1708 | + | [[package]] | |
| 1709 | + | name = "webpki-roots" | |
| 1710 | + | version = "0.26.11" | |
| 1711 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1712 | + | checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" | |
| 1713 | + | dependencies = [ | |
| 1714 | + | "webpki-roots 1.0.7", | |
| 1715 | + | ] | |
| 1716 | + | ||
| 1457 | 1717 | [[package]] | |
| 1458 | 1718 | name = "webpki-roots" | |
| 1459 | 1719 | version = "1.0.7" | |
| @@ -1485,12 +1745,65 @@ | |||
| 1485 | 1745 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1486 | 1746 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
| 1487 | 1747 | ||
| 1748 | + | [[package]] | |
| 1749 | + | name = "windows-core" | |
| 1750 | + | version = "0.62.2" | |
| 1751 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1752 | + | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | |
| 1753 | + | dependencies = [ | |
| 1754 | + | "windows-implement", | |
| 1755 | + | "windows-interface", | |
| 1756 | + | "windows-link", | |
| 1757 | + | "windows-result", | |
| 1758 | + | "windows-strings", | |
| 1759 | + | ] | |
| 1760 | + | ||
| 1761 | + | [[package]] | |
| 1762 | + | name = "windows-implement" | |
| 1763 | + | version = "0.60.2" | |
| 1764 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1765 | + | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | |
| 1766 | + | dependencies = [ | |
| 1767 | + | "proc-macro2", | |
| 1768 | + | "quote", | |
| 1769 | + | "syn", | |
| 1770 | + | ] | |
| 1771 | + | ||
| 1772 | + | [[package]] | |
| 1773 | + | name = "windows-interface" | |
| 1774 | + | version = "0.59.3" | |
| 1775 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1776 | + | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | |
| 1777 | + | dependencies = [ | |
| 1778 | + | "proc-macro2", | |
| 1779 | + | "quote", | |
| 1780 | + | "syn", | |
| 1781 | + | ] | |
| 1782 | + |
Lines truncated
| @@ -11,8 +11,11 @@ | |||
| 11 | 11 | [dependencies] | |
| 12 | 12 | ratatui = "0.29" | |
| 13 | 13 | crossterm = "0.28" | |
| 14 | - | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal"] } | |
| 14 | + | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } | |
| 15 | + | tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } | |
| 16 | + | futures-util = { version = "0.3", default-features = false } | |
| 15 | 17 | reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } | |
| 16 | 18 | serde = { version = "1.0.228", features = ["derive"] } | |
| 17 | 19 | serde_json = "1" | |
| 18 | 20 | anyhow = "1.0.102" | |
| 21 | + | chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } |
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | //! Fetch the prod backup that `migration_dry_run` runs against. | |
| 2 | 2 | //! | |
| 3 | - | //! Sources supported in v0: | |
| 4 | - | //! - `file:///abs/path/to/dump.sql.gz` — local copy. Used for localhost dev. | |
| 5 | - | //! - `rsync://host/module/path` — shells out to `rsync`. Used when MM | |
| 6 | - | //! pulls from an astra/Hetzner replica. | |
| 3 | + | //! Sources supported: | |
| 4 | + | //! - `file:///abs/path/to/dump.sql.gz` — local copy (dev). | |
| 5 | + | //! - `rsync://host/module/path` — rsync daemon protocol. | |
| 6 | + | //! - `ssh://user@host[:port]/path/file.sql.gz` — rsync-over-ssh. Used to pull | |
| 7 | + | //! prod backups from | |
| 8 | + | //! `backup-puller@alpha-west-1`. | |
| 7 | 9 | //! | |
| 8 | 10 | //! The fetch is command-driven: the operator triggers it via /backup/fetch, it | |
| 9 | 11 | //! is not implicit in promote. That keeps the slowest, most failure-prone step | |
| @@ -11,7 +13,7 @@ | |||
| 11 | 13 | ||
| 12 | 14 | use crate::config::Config; | |
| 13 | 15 | use crate::topology::Topology; | |
| 14 | - | use anyhow::{Context, Result}; | |
| 16 | + | use anyhow::{Context, Result, bail}; | |
| 15 | 17 | use chrono::Utc; | |
| 16 | 18 | use sqlx::SqlitePool; | |
| 17 | 19 | use std::path::Path; | |
| @@ -25,6 +27,61 @@ | |||
| 25 | 27 | pub byte_size: Option<i64>, | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 30 | + | /// Parsed `backup.source` URL. Owned strings so the parsed form outlives the | |
| 31 | + | /// (possibly transient) URL we read from config. | |
| 32 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 33 | + | pub(crate) enum BackupSource { | |
| 34 | + | /// Local file copy. Path follows the `file://` prefix. | |
| 35 | + | File { path: String }, | |
| 36 | + | /// rsync daemon protocol. Full URL stays intact (rsync handles it). | |
| 37 | + | RsyncDaemon { url: String }, | |
| 38 | + | /// rsync-over-ssh. Port is optional. | |
| 39 | + | Ssh { | |
| 40 | + | user_host: String, | |
| 41 | + | port: Option<u16>, | |
| 42 | + | path: String, | |
| 43 | + | }, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Parse a `backup.source` URL into a `BackupSource`. Rejects unsupported | |
| 47 | + | /// schemes and malformed `ssh://` URLs (no path part). | |
| 48 | + | pub(crate) fn parse_source(s: &str) -> Result<BackupSource> { | |
| 49 | + | if let Some(rest) = s.strip_prefix("file://") { | |
| 50 | + | if rest.is_empty() { | |
| 51 | + | bail!("file:// URL is missing a path: {s}"); | |
| 52 | + | } | |
| 53 | + | return Ok(BackupSource::File { path: rest.into() }); | |
| 54 | + | } | |
| 55 | + | if s.starts_with("rsync://") { | |
| 56 | + | return Ok(BackupSource::RsyncDaemon { url: s.into() }); | |
| 57 | + | } | |
| 58 | + | if let Some(rest) = s.strip_prefix("ssh://") { | |
| 59 | + | let (user_host_port, path_rest) = rest | |
| 60 | + | .split_once('/') | |
| 61 | + | .with_context(|| format!("ssh:// URL missing path: {s}"))?; | |
| 62 | + | if user_host_port.is_empty() { | |
| 63 | + | bail!("ssh:// URL missing user@host: {s}"); | |
| 64 | + | } | |
| 65 | + | let path = format!("/{path_rest}"); | |
| 66 | + | let (user_host, port) = match user_host_port.rsplit_once(':') { | |
| 67 | + | Some((uh, p)) => { | |
| 68 | + | // Heuristic: trailing `:digits` after the final `:` is the port. | |
| 69 | + | // Anything else (IPv6 literal, etc.) gets left alone. | |
| 70 | + | match p.parse::<u16>() { | |
| 71 | + | Ok(n) => (uh.to_string(), Some(n)), | |
| 72 | + | Err(_) => (user_host_port.to_string(), None), | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | None => (user_host_port.to_string(), None), | |
| 76 | + | }; | |
| 77 | + | if user_host.is_empty() { | |
| 78 | + | bail!("ssh:// URL has empty host (port {:?})", port); | |
| 79 | + | } | |
| 80 | + | return Ok(BackupSource::Ssh { user_host, port, path }); | |
| 81 | + | } | |
| 82 | + | bail!("unsupported backup source scheme: {s}"); | |
| 83 | + | } | |
| 84 | + | ||
| 28 | 85 | pub async fn fetch( | |
| 29 | 86 | pool: &SqlitePool, | |
| 30 | 87 | _cfg: &Arc<Config>, | |
| @@ -37,23 +94,45 @@ | |||
| 37 | 94 | tokio::fs::create_dir_all(parent).await?; | |
| 38 | 95 | } | |
| 39 | 96 | ||
| 40 | - | if let Some(rest) = source.strip_prefix("file://") { | |
| 41 | - | tokio::fs::copy(rest, &local_path) | |
| 42 | - | .await | |
| 43 | - | .with_context(|| format!("copy {rest} -> {local_path}"))?; | |
| 44 | - | } else if source.starts_with("rsync://") { | |
| 45 | - | let out = Command::new("rsync") | |
| 46 | - | .args(["-az", "--inplace", &source, &local_path]) | |
| 47 | - | .output() | |
| 48 | - | .await | |
| 49 | - | .context("spawning rsync")?; | |
| 50 | - | anyhow::ensure!( | |
| 51 | - | out.status.success(), | |
| 52 | - | "rsync failed: {}", | |
| 53 | - | String::from_utf8_lossy(&out.stderr), | |
| 54 | - | ); | |
| 55 | - | } else { | |
| 56 | - | anyhow::bail!("unsupported backup source scheme: {source}"); | |
| 97 | + | let parsed = parse_source(&source)?; | |
| 98 | + | match parsed { | |
| 99 | + | BackupSource::File { path } => { | |
| 100 | + | tokio::fs::copy(&path, &local_path) | |
| 101 | + | .await | |
| 102 | + | .with_context(|| format!("copy {path} -> {local_path}"))?; | |
| 103 | + | } | |
| 104 | + | BackupSource::RsyncDaemon { url } => { | |
| 105 | + | let out = Command::new("rsync") | |
| 106 | + | .args(["-az", "--inplace", &url, &local_path]) | |
| 107 | + | .output() | |
| 108 | + | .await | |
| 109 | + | .context("spawning rsync")?; | |
| 110 | + | anyhow::ensure!( | |
| 111 | + | out.status.success(), | |
| 112 | + | "rsync (daemon) failed: {}", | |
| 113 | + | String::from_utf8_lossy(&out.stderr), | |
| 114 | + | ); | |
| 115 | + | } | |
| 116 | + | BackupSource::Ssh { user_host, port, path } => { | |
| 117 | + | let ssh_cmd = match port { | |
| 118 | + | Some(p) => format!("ssh -p {p} -o BatchMode=yes -o StrictHostKeyChecking=accept-new"), | |
| 119 | + | None => "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new".into(), | |
| 120 | + | }; | |
| 121 | + | let remote = format!("{user_host}:{path}"); | |
| 122 | + | let out = Command::new("rsync") | |
| 123 | + | .args(["-a", "--partial"]) | |
| 124 | + | .arg("-e").arg(&ssh_cmd) | |
| 125 | + | .arg(&remote) | |
| 126 | + | .arg(&local_path) | |
| 127 | + | .output() | |
| 128 | + | .await | |
| 129 | + | .context("spawning rsync")?; | |
| 130 | + | anyhow::ensure!( | |
| 131 | + | out.status.success(), | |
| 132 | + | "rsync (ssh) failed: {}", | |
| 133 | + | String::from_utf8_lossy(&out.stderr), | |
| 134 | + | ); | |
| 135 | + | } | |
| 57 | 136 | } | |
| 58 | 137 | ||
| 59 | 138 | let meta = tokio::fs::metadata(&local_path).await?; | |
| @@ -69,5 +148,106 @@ | |||
| 69 | 148 | .execute(pool) | |
| 70 | 149 | .await?; | |
| 71 | 150 | ||
| 151 | + | // Retention: prune rows fetched more than 30 days ago. The on-disk file | |
| 152 | + | // is overwritten each fetch (single `local_path`), so old rows reference | |
| 153 | + | // a path that no longer exists — keep the table from growing for no | |
| 154 | + | // good reason. | |
| 155 | + | sqlx::query( | |
| 156 | + | "DELETE FROM backups WHERE fetched_at < datetime('now', '-30 days')", | |
| 157 | + | ) | |
| 158 | + | .execute(pool) | |
| 159 | + | .await?; | |
| 160 | + | ||
| 72 | 161 | Ok(FetchedBackup { source, local_path, byte_size: Some(size) }) | |
| 73 | 162 | } | |
| 163 | + | ||
| 164 | + | #[cfg(test)] | |
| 165 | + | mod tests { | |
| 166 | + | use super::*; | |
| 167 | + | ||
| 168 | + | #[test] | |
| 169 | + | fn parses_file_url() { | |
| 170 | + | let s = parse_source("file:///opt/backups/latest.sql.gz").unwrap(); | |
| 171 | + | assert_eq!(s, BackupSource::File { path: "/opt/backups/latest.sql.gz".into() }); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | #[test] | |
| 175 | + | fn file_url_without_path_errors() { | |
| 176 | + | assert!(parse_source("file://").is_err()); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | #[test] | |
| 180 | + | fn parses_rsync_daemon_url() { | |
| 181 | + | let s = parse_source("rsync://astra/mnw/latest.sql.gz").unwrap(); | |
| 182 | + | assert_eq!(s, BackupSource::RsyncDaemon { url: "rsync://astra/mnw/latest.sql.gz".into() }); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | #[test] | |
| 186 | + | fn parses_ssh_url_with_port() { | |
| 187 | + | let s = parse_source("ssh://backup-puller@alpha-west-1:2200/latest.sql.gz").unwrap(); | |
| 188 | + | assert_eq!( | |
| 189 | + | s, | |
| 190 | + | BackupSource::Ssh { | |
| 191 | + | user_host: "backup-puller@alpha-west-1".into(), | |
| 192 | + | port: Some(2200), | |
| 193 | + | path: "/latest.sql.gz".into(), | |
| 194 | + | } | |
| 195 | + | ); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | #[test] | |
| 199 | + | fn parses_ssh_url_without_port() { | |
| 200 | + | let s = parse_source("ssh://max@astra/opt/backups/mnw/latest.sql.gz").unwrap(); | |
| 201 | + | assert_eq!( | |
| 202 | + | s, | |
| 203 | + | BackupSource::Ssh { | |
| 204 | + | user_host: "max@astra".into(), | |
| 205 | + | port: None, | |
| 206 | + | path: "/opt/backups/mnw/latest.sql.gz".into(), | |
| 207 | + | } | |
| 208 | + | ); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | #[test] | |
| 212 | + | fn ssh_url_without_path_errors() { | |
| 213 | + | // `split_once('/')` — `ssh://user@host` has no `/` after the scheme. | |
| 214 | + | assert!(parse_source("ssh://backup-puller@alpha-west-1").is_err()); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 218 | + | fn ssh_url_without_user_host_errors() { | |
| 219 | + | // Empty user@host: `ssh:///foo`. Caught by the empty-prefix check. | |
| 220 | + | assert!(parse_source("ssh:///latest.sql.gz").is_err()); | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[test] | |
| 224 | + | fn ssh_url_with_non_numeric_after_colon_treats_as_part_of_host() { | |
| 225 | + | // `host:notaport` should NOT parse `notaport` as a port. Leave the | |
| 226 | + | // colon part of user_host; libssh/rsync will reject if truly wrong. | |
| 227 | + | let s = parse_source("ssh://user@host:notaport/path").unwrap(); | |
| 228 | + | assert_eq!( | |
| 229 | + | s, | |
| 230 | + | BackupSource::Ssh { | |
| 231 | + | user_host: "user@host:notaport".into(), | |
| 232 | + | port: None, | |
| 233 | + | path: "/path".into(), | |
| 234 | + | } | |
| 235 | + | ); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | #[test] | |
| 239 | + | fn rejects_unknown_scheme() { | |
| 240 | + | assert!(parse_source("ftp://example.com/file").is_err()); | |
| 241 | + | assert!(parse_source("just-a-path.sql.gz").is_err()); | |
| 242 | + | assert!(parse_source("").is_err()); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn ssh_url_preserves_multi_segment_path() { | |
| 247 | + | let s = parse_source("ssh://a@b:22/opt/foo/bar/baz.sql.gz").unwrap(); | |
| 248 | + | match s { | |
| 249 | + | BackupSource::Ssh { path, .. } => assert_eq!(path, "/opt/foo/bar/baz.sql.gz"), | |
| 250 | + | _ => panic!("wrong variant"), | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } |
| @@ -21,7 +21,10 @@ | |||
| 21 | 21 | pub version: String, | |
| 22 | 22 | pub git_sha: String, | |
| 23 | 23 | pub worktree: PathBuf, | |
| 24 | - | pub binary_path: PathBuf, | |
| 24 | + | /// One entry per `cfg.bin_names` in declared order. First is the primary | |
| 25 | + | /// (referenced by the systemd unit's ExecStart). Paths are inside the | |
| 26 | + | /// worktree's `target/release/`. | |
| 27 | + | pub binary_paths: Vec<PathBuf>, | |
| 25 | 28 | } | |
| 26 | 29 | ||
| 27 | 30 | pub async fn run( | |
| @@ -29,6 +32,7 @@ | |||
| 29 | 32 | cfg: Arc<Config>, | |
| 30 | 33 | topo: Arc<Topology>, | |
| 31 | 34 | sha: String, | |
| 35 | + | events: crate::events::EventTx, | |
| 32 | 36 | ) -> Result<BuildArtifact> { | |
| 33 | 37 | let worktree = cfg.workdir.join(&sha); | |
| 34 | 38 | let bare = PathBuf::from(&topo.repo.bare_path); | |
| @@ -38,20 +42,47 @@ | |||
| 38 | 42 | let version = read_pkg_version(&server_dir.join("Cargo.toml")).await | |
| 39 | 43 | .with_context(|| format!("reading version from {}/Cargo.toml", server_dir.display()))?; | |
| 40 | 44 | ||
| 41 | - | tracing::info!(sha = %sha, version = %version, dir = %server_dir.display(), "cargo build --release start"); | |
| 42 | - | let started = std::time::Instant::now(); | |
| 43 | - | let out = Command::new("cargo") | |
| 45 | + | // sqlx compile-time query checking needs a live DB with the current schema. | |
| 46 | + | // We point cargo at the scratch DB and prep it (drop public, re-migrate) | |
| 47 | + | // before invoking cargo build. The same DB is reset again by | |
| 48 | + | // `migration_dry_run` later if it runs as a gate. | |
| 49 | + | let mut cargo_cmd = Command::new("cargo"); | |
| 50 | + | cargo_cmd | |
| 44 | 51 | .arg("build") | |
| 45 | 52 | .arg("--release") | |
| 46 | 53 | .current_dir(&server_dir) | |
| 54 | + | .kill_on_drop(true); | |
| 55 | + | if let Some(scratch_url) = cfg.scratch_db_url.as_deref() { | |
| 56 | + | tracing::info!(sha = %sha, "preparing scratch DB schema for sqlx compile-time checks"); | |
| 57 | + | crate::gates::reset_scratch(scratch_url).await | |
| 58 | + | .context("scratch DB reset before build")?; | |
| 59 | + | crate::gates::run_migrator(scratch_url, &server_dir.join("migrations")).await | |
| 60 | + | .context("applying MNW migrations to scratch DB before build")?; | |
| 61 | + | cargo_cmd.env("DATABASE_URL", scratch_url); | |
| 62 | + | } else { | |
| 63 | + | tracing::warn!("scratch_db_url unset; sqlx will fall back to offline mode and may fail"); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | tracing::info!(sha = %sha, version = %version, dir = %server_dir.display(), "cargo build --release start"); | |
| 67 | + | crate::events::emit(&events, crate::events::Event::BuildStart { | |
| 68 | + | sha: sha.clone(), version: version.clone(), | |
| 69 | + | }); | |
| 70 | + | let started = std::time::Instant::now(); | |
| 71 | + | let out = cargo_cmd | |
| 47 | 72 | .output() | |
| 48 | 73 | .await | |
| 49 | 74 | .context("spawning cargo build")?; | |
| 50 | 75 | let elapsed_s = started.elapsed().as_secs(); | |
| 51 | 76 | if !out.status.success() { | |
| 52 | 77 | tracing::error!(sha = %sha, version = %version, elapsed_s, "cargo build --release failed"); | |
| 78 | + | crate::events::emit(&events, crate::events::Event::BuildFailed { | |
| 79 | + | sha: sha.clone(), version: version.clone(), elapsed_s, | |
| 80 | + | }); | |
| 53 | 81 | } else { | |
| 54 | 82 | tracing::info!(sha = %sha, version = %version, elapsed_s, "cargo build --release ok"); | |
| 83 | + | crate::events::emit(&events, crate::events::Event::BuildOk { | |
| 84 | + | sha: sha.clone(), version: version.clone(), elapsed_s, | |
| 85 | + | }); | |
| 55 | 86 | } | |
| 56 | 87 | anyhow::ensure!( | |
| 57 | 88 | out.status.success(), | |
| @@ -59,12 +90,16 @@ | |||
| 59 | 90 | tail(&out.stderr, 4_000), | |
| 60 | 91 | ); | |
| 61 | 92 | ||
| 62 | - | let binary_path = server_dir.join("target/release/server"); | |
| 63 | - | anyhow::ensure!( | |
| 64 | - | binary_path.exists(), | |
| 65 | - | "expected binary at {} after build", | |
| 66 | - | binary_path.display(), | |
| 67 | - | ); | |
| 93 | + | let release_dir = server_dir.join("target/release"); | |
| 94 | + | let mut binary_paths = Vec::with_capacity(cfg.bin_names.len()); | |
| 95 | + | for name in &cfg.bin_names { | |
| 96 | + | let p = release_dir.join(name); | |
| 97 | + | anyhow::ensure!(p.exists(), "expected binary at {} after build", p.display()); | |
| 98 | + | binary_paths.push(p); | |
| 99 | + | } | |
| 100 | + | // Primary binary path is the one we record in `versions.artifact_path` | |
| 101 | + | // (everything downstream — promote, rollback — looks it up by version). | |
| 102 | + | let primary = binary_paths[0].clone(); | |
| 68 | 103 | ||
| 69 | 104 | sqlx::query( | |
| 70 | 105 | "INSERT OR IGNORE INTO versions (version, git_sha, built_at, artifact_path) | |
| @@ -73,11 +108,11 @@ | |||
| 73 | 108 | .bind(&version) | |
| 74 | 109 | .bind(&sha) | |
| 75 | 110 | .bind(Utc::now().to_rfc3339()) | |
| 76 | - | .bind(binary_path.to_string_lossy().as_ref()) | |
| 111 | + | .bind(primary.to_string_lossy().as_ref()) | |
| 77 | 112 | .execute(&pool) | |
| 78 | 113 | .await?; | |
| 79 | 114 | ||
| 80 | - | Ok(BuildArtifact { version, git_sha: sha, worktree, binary_path }) | |
| 115 | + | Ok(BuildArtifact { version, git_sha: sha, worktree, binary_paths }) | |
| 81 | 116 | } | |
| 82 | 117 | ||
| 83 | 118 | /// Full MM-tier pipeline: build, deploy the binary into MM's release_root, | |
| @@ -88,14 +123,36 @@ | |||
| 88 | 123 | cfg: Arc<Config>, | |
| 89 | 124 | topo: Arc<Topology>, | |
| 90 | 125 | sha: String, | |
| 126 | + | events: crate::events::EventTx, | |
| 91 | 127 | ) -> Result<()> { | |
| 92 | - | let art = run(pool.clone(), cfg.clone(), topo.clone(), sha).await?; | |
| 128 | + | let art = run(pool.clone(), cfg.clone(), topo.clone(), sha, events.clone()).await?; | |
| 93 | 129 | ||
| 94 | 130 | // Stage the binary in MM's release_root so future gates and the MM | |
| 95 | 131 | // self-deploy point at a stable path, not the worktree's target/. | |
| 96 | 132 | let mm_release_root = &cfg.release_root; | |
| 97 | - | let staged = deploy::deploy_local(mm_release_root, &art.version, &art.binary_path).await?; | |
| 98 | - | let staged_bin = staged.join("server"); | |
| 133 | + | let staged = deploy::deploy_local(mm_release_root, &art.version, &art.binary_paths).await?; | |
| 134 | + | ||
| 135 | + | // Bring error-pages alongside the binaries so the deploy rsync ships the | |
| 136 | + | // static HTML to every node. Caddy on each node references | |
| 137 | + | // <release_root>/current/error-pages/. Skipped silently if the worktree | |
| 138 | + | // doesn't have them (older shas, or non-MNW projects using this daemon). | |
| 139 | + | let error_pages_src = art.worktree.join("server/deploy/error-pages"); | |
| 140 | + | if error_pages_src.exists() { | |
| 141 | + | let out = Command::new("cp") | |
| 142 | + | .arg("-a") | |
| 143 | + | .arg(&error_pages_src) | |
| 144 | + | .arg(staged.join("error-pages")) | |
| 145 | + | .output() | |
| 146 | + | .await | |
| 147 | + | .context("spawning cp for error-pages")?; | |
| 148 | + | anyhow::ensure!( | |
| 149 | + | out.status.success(), | |
| 150 | + | "copying error-pages into staged dir: {}", | |
| 151 | + | String::from_utf8_lossy(&out.stderr), | |
| 152 | + | ); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | let staged_bin = staged.join(cfg.primary_bin()); | |
| 99 | 156 | sqlx::query("UPDATE versions SET artifact_path = ? WHERE version = ?") | |
| 100 | 157 | .bind(staged_bin.to_string_lossy().as_ref()) | |
| 101 | 158 | .bind(&art.version) | |
| @@ -112,6 +169,7 @@ | |||
| 112 | 169 | tier: "mm".to_string(), | |
| 113 | 170 | version: art.version.clone(), | |
| 114 | 171 | worktree: art.worktree.clone(), | |
| 172 | + | events: events.clone(), | |
| 115 | 173 | }; | |
| 116 | 174 | let ok = gates::run_all(&ctx, &mm.gates).await?; | |
| 117 | 175 |
| @@ -16,9 +16,22 @@ | |||
| 16 | 16 | /// you care about. | |
| 17 | 17 | #[serde(default)] | |
| 18 | 18 | pub scratch_db_url: Option<String>, | |
| 19 | + | /// Names of cargo bin targets the server crate produces (files under | |
| 20 | + | /// `target/release/`). First entry is the primary unit (referenced from | |
| 21 | + | /// the systemd unit's ExecStart). Defaults to `["server"]`; MNW ships | |
| 22 | + | /// `["makenotwork", "mnw-admin"]`. | |
| 23 | + | #[serde(default = "default_bin_names")] | |
| 24 | + | pub bin_names: Vec<String>, | |
| 19 | 25 | } | |
| 20 | 26 | ||
| 27 | + | fn default_bin_names() -> Vec<String> { vec!["server".into()] } | |
| 28 | + | ||
| 21 | 29 | impl Config { | |
| 30 | + | /// Primary binary — the one the systemd unit's ExecStart points at. | |
| 31 | + | pub fn primary_bin(&self) -> &str { | |
| 32 | + | self.bin_names.first().map(|s| s.as_str()).unwrap_or("server") | |
| 33 | + | } | |
| 34 | + | ||
| 22 | 35 | pub fn load() -> Result<Self> { | |
| 23 | 36 | let path = std::env::var("SANDO_CONFIG").unwrap_or_else(|_| "sando-daemon.toml".into()); | |
| 24 | 37 | let raw = std::fs::read_to_string(&path) |
| @@ -1,40 +1,58 @@ | |||
| 1 | 1 | //! Atomic symlink-swap deploys. | |
| 2 | 2 | //! | |
| 3 | - | //! Layout on every target (MM, A nodes, B nodes, ...): | |
| 3 | + | //! Layout on every target (local host, A nodes, B nodes, ...): | |
| 4 | 4 | //! | |
| 5 | 5 | //! <release_root>/ | |
| 6 | 6 | //! releases/ | |
| 7 | 7 | //! 0.8.1/ | |
| 8 | - | //! server <- the binary | |
| 8 | + | //! <bin_name> | |
| 9 | 9 | //! 0.8.2/ | |
| 10 | - | //! server | |
| 10 | + | //! <bin_name> | |
| 11 | 11 | //! current -> releases/0.8.2 | |
| 12 | 12 | //! | |
| 13 | - | //! `ln -sfn` makes the swap atomic on Linux. systemd units should point at | |
| 14 | - | //! `<release_root>/current/server` so a swap + reload picks up the new binary | |
| 15 | - | //! without a window where the unit references a missing path. | |
| 13 | + | //! `ln -sfn` swaps the symlink. systemd units point at | |
| 14 | + | //! `<release_root>/current/<bin_name>` so reload-or-restart picks up the new | |
| 15 | + | //! binary without ever pointing at a missing path. | |
| 16 | 16 | //! | |
| 17 | - | //! v0 only implements local deploys (used for MM and for localhost-dev | |
| 18 | - | //! "remote" nodes whose ssh_target is `local`). Real SSH/rsync deploys are | |
| 19 | - | //! follow-up work — see the `remote_deploy_stub` branch. | |
| 17 | + | //! For nodes with `ssh_target` set to anything other than `"local"`, deploy | |
| 18 | + | //! goes via rsync + ssh; the bootstrap (creating release_root, installing the | |
| 19 | + | //! service unit, granting sudo for systemctl) is out of scope here — it | |
| 20 | + | //! happens once per node, not per deploy. | |
| 20 | 21 | ||
| 21 | 22 | use crate::topology::Node; | |
| 22 | 23 | use anyhow::{Context, Result}; | |
| 23 | 24 | use std::path::{Path, PathBuf}; | |
| 24 | 25 | use tokio::process::Command; | |
| 25 | 26 | ||
| 26 | - | pub async fn deploy_local(release_root: &Path, version: &str, binary: &Path) -> Result<PathBuf> { | |
| 27 | + | /// SSH options used everywhere we shell out to ssh — fail fast, no prompts. | |
| 28 | + | const SSH_FLAGS: &[&str] = &[ | |
| 29 | + | "-o", "BatchMode=yes", | |
| 30 | + | "-o", "ConnectTimeout=10", | |
| 31 | + | "-o", "StrictHostKeyChecking=accept-new", | |
| 32 | + | ]; | |
| 33 | + | ||
| 34 | + | /// Keep this many release dirs per node; older ones get gc'd after a | |
| 35 | + | /// successful deploy. Fixed for now; promote to config if the constant ever | |
| 36 | + | /// needs to vary by tier. | |
| 37 | + | const RELEASES_TO_KEEP: usize = 5; | |
| 38 | + | ||
| 39 | + | pub async fn deploy_local( | |
| 40 | + | release_root: &Path, | |
| 41 | + | version: &str, | |
| 42 | + | binaries: &[PathBuf], | |
| 43 | + | ) -> Result<PathBuf> { | |
| 27 | 44 | let release_dir = release_root.join("releases").join(version); | |
| 28 | 45 | tokio::fs::create_dir_all(&release_dir).await?; | |
| 29 | - | let dest = release_dir.join("server"); | |
| 30 | - | tokio::fs::copy(binary, &dest) | |
| 31 | - | .await | |
| 32 | - | .with_context(|| format!("copy {} -> {}", binary.display(), dest.display()))?; | |
| 46 | + | for binary in binaries { | |
| 47 | + | let name = binary.file_name() | |
| 48 | + | .context("binary path has no file name")?; | |
| 49 | + | let dest = release_dir.join(name); | |
| 50 | + | tokio::fs::copy(binary, &dest) | |
| 51 | + | .await | |
| 52 | + | .with_context(|| format!("copy {} -> {}", binary.display(), dest.display()))?; | |
| 53 | + | } | |
| 33 | 54 | ||
| 34 | 55 | let current = release_root.join("current"); | |
| 35 | - | // ln -sfn is atomic on Linux; on macOS the dev path is non-prod so the | |
| 36 | - | // race is irrelevant. We shell out rather than using std::os::unix::fs | |
| 37 | - | // symlink + rename because the rename-over-symlink pattern is platform-fussy. | |
| 38 | 56 | let target = format!("releases/{version}"); | |
| 39 | 57 | let out = Command::new("ln") | |
| 40 | 58 | .args(["-sfn", &target]) | |
| @@ -46,26 +64,372 @@ | |||
| 46 | 64 | "symlink swap failed: {}", | |
| 47 | 65 | String::from_utf8_lossy(&out.stderr), | |
| 48 | 66 | ); | |
| 67 | + | ||
| 68 | + | if let Err(e) = gc_local_releases(release_root).await { | |
| 69 | + | tracing::warn!(error = %e, "local release GC failed (non-fatal)"); | |
| 70 | + | } | |
| 49 | 71 | Ok(release_dir) | |
| 50 | 72 | } | |
| 51 | 73 | ||
| 52 | - | pub async fn deploy_node(node: &Node, version: &str, binary: &Path) -> Result<PathBuf> { | |
| 74 | + | /// Deploy `staged_release_dir` (a directory built on the Sando host by | |
| 75 | + | /// `deploy_local`) to `node`. For `ssh_target=local`, this is just symlink | |
| 76 | + | /// swap + restart; for remote nodes, we rsync the whole dir. | |
| 77 | + | /// | |
| 78 | + | /// `primary_bin` is only used for logging — every file present in the staged | |
| 79 | + | /// dir gets shipped. | |
| 80 | + | pub async fn deploy_node( | |
| 81 | + | node: &Node, | |
| 82 | + | version: &str, | |
| 83 | + | staged_release_dir: &Path, | |
| 84 | + | primary_bin: &str, | |
| 85 | + | ) -> Result<PathBuf> { | |
| 53 | 86 | if node.ssh_target == "local" || node.ssh_target.is_empty() { | |
| 54 | - | return deploy_local(Path::new(&node.release_root), version, binary).await; | |
| 87 | + | // Local deploy already happened when we staged on the Sando host. | |
| 88 | + | // Just re-point `current` at the staged dir. | |
| 89 | + | return reset_local_current(Path::new(&node.release_root), version).await; | |
| 55 | 90 | } | |
| 56 | - | remote_deploy_stub(node, version, binary).await | |
| 91 | + | deploy_remote(node, version, staged_release_dir, primary_bin).await | |
| 57 | 92 | } | |
| 58 | 93 | ||
| 59 | - | async fn remote_deploy_stub(node: &Node, version: &str, _binary: &Path) -> Result<PathBuf> { | |
| 60 | - | // Real implementation: rsync the binary to <ssh_target>:<release_root>/releases/<version>/server, | |
| 61 | - | // then ssh <ssh_target> "ln -sfn releases/<version> current && systemctl reload-or-restart <unit>". | |
| 62 | - | // Wiring this up needs a story for systemd unit naming and ssh key/auth conventions; deferring | |
| 63 | - | // until the localhost smoke loop is settled and we know which knobs matter. | |
| 64 | - | anyhow::bail!( | |
| 65 | - | "remote deploy not yet implemented (node {} -> {}); use ssh_target=local for dev", | |
| 66 | - | node.name, | |
| 67 | - | node.ssh_target, | |
| 94 | + | async fn reset_local_current(release_root: &Path, version: &str) -> Result<PathBuf> { | |
| 95 | + | let current = release_root.join("current"); | |
| 96 | + | let target = format!("releases/{version}"); | |
| 97 | + | let out = Command::new("ln") | |
| 98 | + | .args(["-sfn", &target]) | |
| 99 | + | .arg(¤t) | |
| 100 | + | .output() | |
| 101 | + | .await?; | |
| 102 | + | anyhow::ensure!( | |
| 103 | + | out.status.success(), | |
| 104 | + | "symlink swap failed: {}", | |
| 105 | + | String::from_utf8_lossy(&out.stderr), | |
| 68 | 106 | ); | |
| 69 | - | #[allow(unreachable_code)] | |
| 70 | - | Ok(PathBuf::from(&node.release_root).join("releases").join(version)) | |
| 107 | + | Ok(release_root.join("releases").join(version)) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | async fn deploy_remote( | |
| 111 | + | node: &Node, | |
| 112 | + | version: &str, | |
| 113 | + | staged_release_dir: &Path, | |
| 114 | + | primary_bin: &str, | |
| 115 | + | ) -> Result<PathBuf> { | |
| 116 | + | let release_root = &node.release_root; | |
| 117 | + | let ssh_target = &node.ssh_target; | |
| 118 | + | let service = &node.service_name; | |
| 119 | + | let release_dir = format!("{release_root}/releases/{version}"); | |
| 120 | + | ||
| 121 | + | tracing::info!(node = %node.name, version, "deploy: mkdir release dir"); | |
| 122 | + | ssh(ssh_target, &format!("set -e; mkdir -p {q}", q = sh_quote(&release_dir))) | |
| 123 | + | .await | |
| 124 | + | .context("creating remote release dir")?; | |
| 125 | + | ||
| 126 | + | tracing::info!(node = %node.name, version, primary = %primary_bin, "deploy: rsync release dir"); | |
| 127 | + | // Rsync the whole staged dir (all binaries + any sibling artifacts like | |
| 128 | + | // error-pages). Trailing slash on source = contents of dir, not the dir | |
| 129 | + | // itself. --chmod ensures binaries land executable; the regular-file | |
| 130 | + | // mask leaves data files at 0644. | |
| 131 | + | let rsync_src = format!("{}/", staged_release_dir.display()); | |
| 132 | + | let rsync_dest = format!("{ssh_target}:{release_dir}/"); | |
| 133 | + | let mut rsync = Command::new("rsync"); | |
| 134 | + | rsync | |
| 135 | + | .arg("-az") | |
| 136 | + | .arg("--partial") | |
| 137 | + | .arg("--chmod=F0755,D0755") | |
| 138 | + | .arg("-e") | |
| 139 | + | .arg(format!( | |
| 140 | + | "ssh {}", | |
| 141 | + | SSH_FLAGS.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" ") | |
| 142 | + | )) | |
| 143 | + | .arg(&rsync_src) | |
| 144 | + | .arg(&rsync_dest); | |
| 145 | + | let out = rsync.output().await.context("spawning rsync")?; | |
| 146 | + | anyhow::ensure!( | |
| 147 | + | out.status.success(), | |
| 148 | + | "rsync failed (current symlink left intact): {}", | |
| 149 | + | String::from_utf8_lossy(&out.stderr), | |
| 150 | + | ); | |
| 151 | + | ||
| 152 | + | tracing::info!(node = %node.name, version, "deploy: symlink swap + service reload"); | |
| 153 | + | // Symlink swap is atomic via `mv -T` of a freshly-created symlink over | |
| 154 | + | // the old one (the rename(2) is the atomic step; `ln -sfn` does | |
| 155 | + | // unlink+symlink which has a window). | |
| 156 | + | let swap_and_restart = format!( | |
| 157 | + | "set -e; \ | |
| 158 | + | cd {root}; \ | |
| 159 | + | ln -sfn releases/{ver} current.new; \ | |
| 160 | + | mv -Tf current.new current; \ | |
| 161 | + | sudo /bin/systemctl reload-or-restart {svc}", | |
| 162 | + | root = sh_quote(release_root), | |
| 163 | + | ver = sh_quote(version), | |
| 164 | + | svc = sh_quote(service), | |
| 165 | + | ); | |
| 166 | + | ssh(ssh_target, &swap_and_restart) | |
| 167 | + | .await | |
| 168 | + | .context("symlink swap + systemctl reload-or-restart")?; | |
| 169 | + | ||
| 170 | + | if let Err(e) = gc_remote_releases(ssh_target, release_root).await { | |
| 171 | + | tracing::warn!(error = %e, "remote release GC failed (non-fatal)"); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | Ok(PathBuf::from(release_root).join("releases").join(version)) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | async fn ssh(target: &str, script: &str) -> Result<()> { | |
| 178 | + | let mut cmd = Command::new("ssh"); | |
| 179 | + | cmd.args(SSH_FLAGS).arg(target).arg(script); | |
| 180 | + | let out = cmd.output().await.context("spawning ssh")?; | |
| 181 | + | anyhow::ensure!( | |
| 182 | + | out.status.success(), | |
| 183 | + | "ssh {target} failed: {}", | |
| 184 | + | String::from_utf8_lossy(&out.stderr), | |
| 185 | + | ); | |
| 186 | + | Ok(()) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | async fn gc_local_releases(release_root: &Path) -> Result<()> { | |
| 190 | + | let releases = release_root.join("releases"); | |
| 191 | + | if !releases.exists() { | |
| 192 | + | return Ok(()); | |
| 193 | + | } | |
| 194 | + | let mut entries = Vec::new(); | |
| 195 | + | let mut rd = tokio::fs::read_dir(&releases).await?; | |
| 196 | + | while let Some(entry) = rd.next_entry().await? { | |
| 197 | + | if !entry.file_type().await?.is_dir() { | |
| 198 | + | continue; | |
| 199 | + | } | |
| 200 | + | let meta = entry.metadata().await?; | |
| 201 | + | entries.push((entry.path(), meta.modified()?)); | |
| 202 | + | } | |
| 203 | + | entries.sort_by(|a, b| b.1.cmp(&a.1)); | |
| 204 | + | for (path, _) in entries.into_iter().skip(RELEASES_TO_KEEP) { | |
| 205 | + | if let Err(e) = tokio::fs::remove_dir_all(&path).await { | |
| 206 | + | tracing::warn!(path = %path.display(), error = %e, "gc: rm failed"); | |
| 207 | + | } else { | |
| 208 | + | tracing::debug!(path = %path.display(), "gc: removed old release"); | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | Ok(()) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | async fn gc_remote_releases(ssh_target: &str, release_root: &str) -> Result<()> { | |
| 215 | + | // `ls -t` orders by mtime desc. Skip the first N, rm the rest. `xargs -r` | |
| 216 | + | // is a no-op when stdin is empty (avoids `rm` complaining). | |
| 217 | + | let script = format!( | |
| 218 | + | "set -e; cd {root}/releases 2>/dev/null || exit 0; \ | |
| 219 | + | ls -1t | tail -n +{keep_plus_one} | xargs -r -I{{}} rm -rf -- {{}}", | |
| 220 | + | root = sh_quote(release_root), | |
| 221 | + | keep_plus_one = RELEASES_TO_KEEP + 1, | |
| 222 | + | ); | |
| 223 | + | ssh(ssh_target, &script).await | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | /// Single-quote a string for safe inclusion in a /bin/sh command, escaping | |
| 227 | + | /// any single quote inside. Not bulletproof for adversarial input, but every | |
| 228 | + | /// path here comes from our own config files. | |
| 229 | + | fn sh_quote(s: &str) -> String { | |
| 230 | + | let escaped = s.replace('\'', r"'\''"); | |
| 231 | + | format!("'{escaped}'") | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | #[cfg(test)] | |
| 235 | + | mod tests { | |
| 236 | + | use super::*; | |
| 237 | + | use std::time::SystemTime; | |
| 238 | + | ||
| 239 | + | #[test] | |
| 240 | + | fn sh_quote_no_quote() { | |
| 241 | + | assert_eq!(sh_quote("hello"), "'hello'"); | |
| 242 | + | assert_eq!(sh_quote("/opt/mnw/releases/0.8.12"), "'/opt/mnw/releases/0.8.12'"); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn sh_quote_with_quote() { | |
| 247 | + | // The string `it's` becomes `'it'\''s'` — close, escape, open. | |
| 248 | + | assert_eq!(sh_quote("it's"), r"'it'\''s'"); | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | #[tokio::test] | |
| 252 | + | async fn deploy_local_copies_multiple_binaries_and_swaps_symlink() { | |
| 253 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 254 | + | let root = tmp.path(); | |
| 255 | + | ||
| 256 | + | // Source binaries (worktree's target/release/) | |
| 257 | + | let src_dir = root.join("src"); | |
| 258 | + | tokio::fs::create_dir_all(&src_dir).await.unwrap(); | |
| 259 | + | let primary = src_dir.join("makenotwork"); | |
| 260 | + | let admin = src_dir.join("mnw-admin"); | |
| 261 | + | tokio::fs::write(&primary, b"PRIMARY").await.unwrap(); | |
| 262 | + | tokio::fs::write(&admin, b"ADMIN").await.unwrap(); | |
| 263 | + | ||
| 264 | + | // Release root (where staged versions live) | |
| 265 | + | let release_root = root.join("releases-root"); | |
| 266 | + | tokio::fs::create_dir_all(&release_root).await.unwrap(); | |
| 267 | + | ||
| 268 | + | let staged = deploy_local( | |
| 269 | + | &release_root, | |
| 270 | + | "0.8.12", | |
| 271 | + | &[primary.clone(), admin.clone()], | |
| 272 | + | ) | |
| 273 | + | .await | |
| 274 | + | .expect("deploy_local should succeed"); | |
| 275 | + | ||
| 276 | + | assert_eq!(staged, release_root.join("releases").join("0.8.12")); | |
| 277 | + | assert_eq!(tokio::fs::read(staged.join("makenotwork")).await.unwrap(), b"PRIMARY"); | |
| 278 | + | assert_eq!(tokio::fs::read(staged.join("mnw-admin")).await.unwrap(), b"ADMIN"); | |
| 279 | + | ||
| 280 | + | // current symlink should resolve to staged | |
| 281 | + | let current = release_root.join("current"); | |
| 282 | + | let target = tokio::fs::read_link(¤t).await.unwrap(); | |
| 283 | + | assert_eq!(target.to_string_lossy(), "releases/0.8.12"); | |
| 284 | + | // And reading through `current/` should give the new content. | |
| 285 | + | let via_current = tokio::fs::read(current.join("makenotwork")).await.unwrap(); | |
| 286 | + | assert_eq!(via_current, b"PRIMARY"); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | #[tokio::test] | |
| 290 | + | async fn deploy_local_second_release_swaps_symlink_and_keeps_old_dir() { | |
| 291 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 292 | + | let root = tmp.path(); | |
| 293 | + | let src_dir = root.join("src"); | |
| 294 | + | tokio::fs::create_dir_all(&src_dir).await.unwrap(); | |
| 295 | + | let bin = src_dir.join("server"); | |
| 296 | + | tokio::fs::write(&bin, b"V1").await.unwrap(); | |
| 297 | + | ||
| 298 | + | let release_root = root.join("rr"); | |
| 299 | + | tokio::fs::create_dir_all(&release_root).await.unwrap(); | |
| 300 | + | ||
| 301 | + | deploy_local(&release_root, "0.1.0", &[bin.clone()]).await.unwrap(); | |
| 302 | + | // Rewrite source then deploy 0.2.0. | |
| 303 | + | tokio::fs::write(&bin, b"V2").await.unwrap(); | |
| 304 | + | deploy_local(&release_root, "0.2.0", &[bin.clone()]).await.unwrap(); | |
| 305 | + | ||
| 306 | + | // Both versions present on disk. | |
| 307 | + | assert!(release_root.join("releases/0.1.0/server").exists()); | |
| 308 | + | assert!(release_root.join("releases/0.2.0/server").exists()); | |
| 309 | + | // current points at the new one. | |
| 310 | + | let target = tokio::fs::read_link(release_root.join("current")).await.unwrap(); | |
| 311 | + | assert_eq!(target.to_string_lossy(), "releases/0.2.0"); | |
| 312 | + | let via_current = tokio::fs::read(release_root.join("current/server")).await.unwrap(); | |
| 313 | + | assert_eq!(via_current, b"V2"); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | #[tokio::test] | |
| 317 | + | async fn gc_local_releases_keeps_last_n_by_mtime() { | |
| 318 | + | // Build > RELEASES_TO_KEEP fake release dirs with distinct mtimes, | |
| 319 | + | // then run gc and check which survived. | |
| 320 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 321 | + | let root = tmp.path(); | |
| 322 | + | let releases = root.join("releases"); | |
| 323 | + | tokio::fs::create_dir_all(&releases).await.unwrap(); | |
| 324 | + | ||
| 325 | + | let total = RELEASES_TO_KEEP + 3; | |
| 326 | + | let mut names = Vec::new(); | |
| 327 | + | for i in 0..total { | |
| 328 | + | let name = format!("v{i:02}"); | |
| 329 | + | let dir = releases.join(&name); | |
| 330 | + | tokio::fs::create_dir(&dir).await.unwrap(); | |
| 331 | + | // Stagger mtimes deterministically. tokio's File doesn't expose | |
| 332 | + | // set_times, so reach for std::fs::File + std::fs::FileTimes | |
| 333 | + | // (stable since 1.75). Synchronous is fine here — this is test | |
| 334 | + | // setup, not the hot path. | |
| 335 | + | let f = std::fs::File::open(&dir).unwrap(); | |
| 336 | + | let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000 + i as u64); | |
| 337 | + | let times = std::fs::FileTimes::new().set_modified(when); | |
| 338 | + | f.set_times(times).unwrap(); | |
| 339 | + | names.push(name); | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | gc_local_releases(root).await.unwrap(); | |
| 343 | + | ||
| 344 | + | // The last RELEASES_TO_KEEP by mtime (i.e. highest i) survive. | |
| 345 | + | let surviving_expected: Vec<_> = names | |
| 346 | + | .iter() | |
| 347 | + | .skip(total - RELEASES_TO_KEEP) | |
| 348 | + | .cloned() | |
| 349 | + | .collect(); | |
| 350 | + | for name in &surviving_expected { | |
| 351 | + | assert!( | |
| 352 | + | releases.join(name).exists(), | |
| 353 | + | "expected to survive: {name}" | |
| 354 | + | ); | |
| 355 | + | } | |
| 356 | + | for name in names.iter().take(total - RELEASES_TO_KEEP) { | |
| 357 | + | assert!( | |
| 358 | + | !releases.join(name).exists(), | |
| 359 | + | "expected to be pruned: {name}" | |
| 360 | + | ); | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | #[tokio::test] | |
| 365 | + | async fn gc_local_releases_noop_when_below_threshold() { | |
| 366 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 367 | + | let root = tmp.path(); | |
| 368 | + | let releases = root.join("releases"); | |
| 369 | + | tokio::fs::create_dir_all(&releases).await.unwrap(); | |
| 370 | + | for i in 0..3 { | |
| 371 | + | tokio::fs::create_dir(releases.join(format!("v{i}"))).await.unwrap(); | |
| 372 | + | } | |
| 373 | + | gc_local_releases(root).await.unwrap(); | |
| 374 | + | for i in 0..3 { | |
| 375 | + | assert!(releases.join(format!("v{i}")).exists()); | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | #[tokio::test] | |
| 380 | + | async fn gc_local_releases_noop_when_releases_dir_missing() { | |
| 381 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 382 | + | gc_local_releases(tmp.path()).await.unwrap(); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | #[tokio::test] | |
| 386 | + | async fn deploy_remote_fails_cleanly_when_host_unreachable() { | |
| 387 | + | // 192.0.2.0/24 is reserved for documentation and routes nowhere. | |
| 388 | + | // ConnectTimeout=10 limits the test wallclock to ~10s worst case. | |
| 389 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 390 | + | let staged = tmp.path().join("releases").join("0.0.1"); | |
| 391 | + | tokio::fs::create_dir_all(&staged).await.unwrap(); | |
| 392 | + | tokio::fs::write(staged.join("server"), b"x").await.unwrap(); | |
| 393 | + | ||
| 394 | + | let node = crate::topology::Node { | |
| 395 | + | name: "unreachable".into(), | |
| 396 | + | ssh_target: "deploy@192.0.2.1".into(), | |
| 397 | + | release_root: "/opt/never".into(), | |
| 398 | + | service_name: "makenotwork.service".into(), | |
| 399 | + | }; | |
| 400 | + | ||
| 401 | + | let result = deploy_node(&node, "0.0.1", &staged, "server").await; | |
| 402 | + | let err = result.expect_err("deploy to unreachable host should fail"); | |
| 403 | + | let msg = format!("{err:#}"); | |
| 404 | + | // The ssh helper returns `ssh <target> failed: ...`. Don't pin the | |
| 405 | + | // exact wording, just that the failure is attributed and that no | |
| 406 | + | // panic / hang happened. | |
| 407 | + | assert!( | |
| 408 | + | msg.contains("ssh") || msg.contains("rsync") || msg.contains("connection"), | |
| 409 | + | "unexpected error: {msg}" | |
| 410 | + | ); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | #[tokio::test] | |
| 414 | + | async fn deploy_node_with_local_ssh_target_swaps_symlink() { | |
| 415 | + | // ssh_target="local" should route to the local fast-path: just a | |
| 416 | + | // symlink swap, no remote calls. Helpful for dev loops. | |
| 417 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 418 | + | let release_root = tmp.path().to_path_buf(); | |
| 419 | + | let staged = release_root.join("releases").join("0.0.1"); | |
| 420 | + | tokio::fs::create_dir_all(&staged).await.unwrap(); | |
| 421 | + | tokio::fs::write(staged.join("server"), b"x").await.unwrap(); | |
| 422 | + | ||
| 423 | + | let node = crate::topology::Node { | |
| 424 | + | name: "local-dev".into(), | |
| 425 | + | ssh_target: "local".into(), | |
| 426 | + | release_root: release_root.to_string_lossy().into_owned(), | |
| 427 | + | service_name: "makenotwork.service".into(), | |
| 428 | + | }; | |
| 429 | + | ||
| 430 | + | let out = deploy_node(&node, "0.0.1", &staged, "server").await.unwrap(); | |
| 431 | + | assert_eq!(out, staged); | |
| 432 | + | let target = tokio::fs::read_link(release_root.join("current")).await.unwrap(); | |
| 433 | + | assert_eq!(target.to_string_lossy(), "releases/0.0.1"); | |
| 434 | + | } | |
| 71 | 435 | } |