| 1 |
# sando |
| 2 |
|
| 3 |
Home-rolled CI/CD controller for the MNW server. Axum daemon (`sandod`) + |
| 4 |
ratatui TUI (`sando`). Gates a tiered deploy flow: |
| 5 |
|
| 6 |
``` |
| 7 |
git push mm -> MakeMachine (build + tests + migration dry-run + boot smoke) |
| 8 |
-> A (testnot.work) |
| 9 |
-> B (prod-1) |
| 10 |
-> C (prod-2) |
| 11 |
``` |
| 12 |
|
| 13 |
Each tier's progression gates are declared in `sando.toml`. Tiers and nodes |
| 14 |
live in the TOML, not in code — adding a node or a new tier is a config edit. |
| 15 |
|
| 16 |
## Crates |
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
| `daemon/` | `sandod` | Axum daemon. Runs on the MakeMachine. Owns SQLite state, the bare git repo, and all build/gate/deploy logic. | |
| 21 |
| `tui/` | `sando` | ratatui front-end. Runs on the laptop. Talks to `sandod` over the tailnet. | |
| 22 |
|
| 23 |
## Quickstart: localhost dev loop |
| 24 |
|
| 25 |
The MakeMachine hardware does not exist yet, so v0 runs entirely on a single |
| 26 |
host. Bare repo, releases dir, "remote" A node — everything is a local |
| 27 |
directory. |
| 28 |
|
| 29 |
```bash |
| 30 |
# 1. Build both binaries. |
| 31 |
cd MNW/sando/daemon && cargo build |
| 32 |
cd ../tui && cargo build |
| 33 |
|
| 34 |
# 2. Create a workspace + config. |
| 35 |
mkdir -p /tmp/sando-dev |
| 36 |
cat > /tmp/sando-dev/daemon.toml <<EOF |
| 37 |
listen = "127.0.0.1:7766" |
| 38 |
db_path = "/tmp/sando-dev/sando.db" |
| 39 |
topology_path = "/tmp/sando-dev/sando.toml" |
| 40 |
workdir = "/tmp/sando-dev/work" |
| 41 |
release_root = "/tmp/sando-dev/releases" |
| 42 |
# scratch_db_url = "postgres://you@127.0.0.1/sando_scratch" |
| 43 |
EOF |
| 44 |
|
| 45 |
cat > /tmp/sando-dev/sando.toml <<EOF |
| 46 |
[repo] |
| 47 |
bare_path = "/tmp/sando-dev/mnw.git" |
| 48 |
branch = "main" |
| 49 |
[backup] |
| 50 |
source = "file:///tmp/sando-dev/fake-backup.sql" |
| 51 |
local_path = "/tmp/sando-dev/backup.sql" |
| 52 |
|
| 53 |
[[tier]] |
| 54 |
name = "mm" |
| 55 |
provisioned = true |
| 56 |
gates = [ |
| 57 |
{ kind = "cargo_test" }, |
| 58 |
{ kind = "migration_dry_run" }, |
| 59 |
{ kind = "boot_smoke" }, |
| 60 |
] |
| 61 |
|
| 62 |
[[tier]] |
| 63 |
name = "a" |
| 64 |
provisioned = true |
| 65 |
canary = "sequential" |
| 66 |
gates = [ |
| 67 |
{ kind = "boot_smoke" }, |
| 68 |
{ kind = "manual_confirm" }, |
| 69 |
] |
| 70 |
[[tier.node]] |
| 71 |
name = "a-local" |
| 72 |
ssh_target = "local" |
| 73 |
release_root = "/tmp/sando-dev/a-node" |
| 74 |
EOF |
| 75 |
|
| 76 |
# 3. Run the daemon. |
| 77 |
SANDO_CONFIG=/tmp/sando-dev/daemon.toml \ |
| 78 |
./MNW/sando/daemon/target/debug/sandod |
| 79 |
|
| 80 |
# 4. In another shell: point a clone at the bare repo and push. |
| 81 |
git clone /tmp/sando-dev/mnw.git /tmp/sando-dev/checkout |
| 82 |
# ... add a `server/Cargo.toml` + source so the build can run ... |
| 83 |
cd /tmp/sando-dev/checkout && git push origin main |
| 84 |
|
| 85 |
# 5. Watch the TUI. |
| 86 |
SANDO_DAEMON=http://127.0.0.1:7766 ./MNW/sando/tui/target/debug/sando |
| 87 |
``` |
| 88 |
|
| 89 |
When you push, the bare repo's `post-receive` hook (installed automatically |
| 90 |
by `sandod` on startup) calls `POST /rebuild`. The daemon checks out the |
| 91 |
sha, runs `cargo build --release` against `server/`, stages the binary in |
| 92 |
`releases/<version>/server`, then runs the MM tier's gates. On green, MM's |
| 93 |
`tier_state` advances. Promote with: |
| 94 |
|
| 95 |
```bash |
| 96 |
curl -X POST http://127.0.0.1:7766/promote/a \ |
| 97 |
-H 'Content-Type: application/json' \ |
| 98 |
-d '{"version":"0.8.2"}' |
| 99 |
``` |
| 100 |
|
| 101 |
## API |
| 102 |
|
| 103 |
|
| 104 |
|
| 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 | |
| 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://) | |
| 110 |
| GET | `/metrics` | — | Prometheus exposition | |
| 111 |
| GET | `/events` | — | WebSocket stream of deploy + gate events (not yet implemented) | |
| 112 |
|
| 113 |
## Hotfix flow |
| 114 |
|
| 115 |
`POST /promote/{tier}` accepts: |
| 116 |
|
| 117 |
- `hotfix: true` — skips the `burn_in` gate on the predecessor tier only. All |
| 118 |
other gates still apply. |
| 119 |
- `reset_burn_in: true` (default `false`) — additionally nulls |
| 120 |
`tier_state.burn_in_started_at` on the source tier, restarting the clock |
| 121 |
for whatever else is still burning in there. Use this only when the hotfix |
| 122 |
meaningfully changes the surface area under burn-in. |
| 123 |
|
| 124 |
## v0 limitations |
| 125 |
|
| 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 |
- `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 |
| 130 |
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 |
|
| 135 |
## License |
| 136 |
|
| 137 |
MIT. The surrounding MNW monorepo is PolyForm-Noncommercial — sando is |
| 138 |
deliberately MIT'd because it's deploy infra, not the product. |
| 139 |
|