Skip to main content

max / makenotwork

sando: scaffold deploy controller (daemon + TUI) Home-rolled MNW server CI/CD controller. Axum daemon (sandod) + ratatui TUI (sando) under sando/. Tiers/nodes/gates declared in sando.toml so topology changes are config edits rather than schema migrations. SQLite state on the MakeMachine. Route table stubbed with todo!() bodies; both crates cargo-check clean. MIT licensed.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-23 01:47 UTC
Commit: b90ce2361cb8c46253c1b4c6a7f36c0833dba881
Parent: 9764587
18 files changed, +2815 insertions, -0 deletions
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Max Jacobson
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
@@ -1,0 +1,2554 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "aho-corasick"
7 + version = "1.1.4"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10 + dependencies = [
11 + "memchr",
12 + ]
13 +
14 + [[package]]
15 + name = "allocator-api2"
16 + version = "0.2.21"
17 + source = "registry+https://github.com/rust-lang/crates.io-index"
18 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19 +
20 + [[package]]
21 + name = "android_system_properties"
22 + version = "0.1.5"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
25 + dependencies = [
26 + "libc",
27 + ]
28 +
29 + [[package]]
30 + name = "anyhow"
31 + version = "1.0.102"
32 + source = "registry+https://github.com/rust-lang/crates.io-index"
33 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
34 +
35 + [[package]]
36 + name = "atoi"
37 + version = "2.0.0"
38 + source = "registry+https://github.com/rust-lang/crates.io-index"
39 + checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
40 + dependencies = [
41 + "num-traits",
42 + ]
43 +
44 + [[package]]
45 + name = "atomic-waker"
46 + version = "1.1.2"
47 + source = "registry+https://github.com/rust-lang/crates.io-index"
48 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
49 +
50 + [[package]]
51 + name = "autocfg"
52 + version = "1.5.1"
53 + source = "registry+https://github.com/rust-lang/crates.io-index"
54 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
55 +
56 + [[package]]
57 + name = "axum"
58 + version = "0.8.9"
59 + source = "registry+https://github.com/rust-lang/crates.io-index"
60 + checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
61 + dependencies = [
62 + "axum-core",
63 + "axum-macros",
64 + "base64",
65 + "bytes",
66 + "form_urlencoded",
67 + "futures-util",
68 + "http",
69 + "http-body",
70 + "http-body-util",
71 + "hyper",
72 + "hyper-util",
73 + "itoa",
74 + "matchit",
75 + "memchr",
76 + "mime",
77 + "percent-encoding",
78 + "pin-project-lite",
79 + "serde_core",
80 + "serde_json",
81 + "serde_path_to_error",
82 + "serde_urlencoded",
83 + "sha1",
84 + "sync_wrapper",
85 + "tokio",
86 + "tokio-tungstenite",
87 + "tower",
88 + "tower-layer",
89 + "tower-service",
90 + "tracing",
91 + ]
92 +
93 + [[package]]
94 + name = "axum-core"
95 + version = "0.5.6"
96 + source = "registry+https://github.com/rust-lang/crates.io-index"
97 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
98 + dependencies = [
99 + "bytes",
100 + "futures-core",
101 + "http",
102 + "http-body",
103 + "http-body-util",
104 + "mime",
105 + "pin-project-lite",
106 + "sync_wrapper",
107 + "tower-layer",
108 + "tower-service",
109 + "tracing",
110 + ]
111 +
112 + [[package]]
113 + name = "axum-macros"
114 + version = "0.5.1"
115 + source = "registry+https://github.com/rust-lang/crates.io-index"
116 + checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
117 + dependencies = [
118 + "proc-macro2",
119 + "quote",
120 + "syn",
121 + ]
122 +
123 + [[package]]
124 + name = "base64"
125 + version = "0.22.1"
126 + source = "registry+https://github.com/rust-lang/crates.io-index"
127 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
128 +
129 + [[package]]
130 + name = "base64ct"
131 + version = "1.8.3"
132 + source = "registry+https://github.com/rust-lang/crates.io-index"
133 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
134 +
135 + [[package]]
136 + name = "bitflags"
137 + version = "2.11.1"
138 + source = "registry+https://github.com/rust-lang/crates.io-index"
139 + checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
140 + dependencies = [
141 + "serde_core",
142 + ]
143 +
144 + [[package]]
145 + name = "block-buffer"
146 + version = "0.10.4"
147 + source = "registry+https://github.com/rust-lang/crates.io-index"
148 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
149 + dependencies = [
150 + "generic-array",
151 + ]
152 +
153 + [[package]]
154 + name = "bumpalo"
155 + version = "3.20.3"
156 + source = "registry+https://github.com/rust-lang/crates.io-index"
157 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
158 +
159 + [[package]]
160 + name = "byteorder"
161 + version = "1.5.0"
162 + source = "registry+https://github.com/rust-lang/crates.io-index"
163 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
164 +
165 + [[package]]
166 + name = "bytes"
167 + version = "1.11.1"
168 + source = "registry+https://github.com/rust-lang/crates.io-index"
169 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
170 +
171 + [[package]]
172 + name = "cc"
173 + version = "1.2.62"
174 + source = "registry+https://github.com/rust-lang/crates.io-index"
175 + checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
176 + dependencies = [
177 + "find-msvc-tools",
178 + "shlex",
179 + ]
180 +
181 + [[package]]
182 + name = "cfg-if"
183 + version = "1.0.4"
184 + source = "registry+https://github.com/rust-lang/crates.io-index"
185 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
186 +
187 + [[package]]
188 + name = "chrono"
189 + version = "0.4.44"
190 + source = "registry+https://github.com/rust-lang/crates.io-index"
191 + checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
192 + dependencies = [
193 + "iana-time-zone",
194 + "js-sys",
195 + "num-traits",
196 + "serde",
197 + "wasm-bindgen",
198 + "windows-link",
199 + ]
200 +
201 + [[package]]
202 + name = "concurrent-queue"
203 + version = "2.5.0"
204 + source = "registry+https://github.com/rust-lang/crates.io-index"
205 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
206 + dependencies = [
207 + "crossbeam-utils",
208 + ]
209 +
210 + [[package]]
211 + name = "const-oid"
212 + version = "0.9.6"
213 + source = "registry+https://github.com/rust-lang/crates.io-index"
214 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
215 +
216 + [[package]]
217 + name = "core-foundation-sys"
218 + version = "0.8.7"
219 + source = "registry+https://github.com/rust-lang/crates.io-index"
220 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
221 +
222 + [[package]]
223 + name = "cpufeatures"
224 + version = "0.2.17"
225 + source = "registry+https://github.com/rust-lang/crates.io-index"
226 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
227 + dependencies = [
228 + "libc",
229 + ]
230 +
231 + [[package]]
232 + name = "crc"
233 + version = "3.4.0"
234 + source = "registry+https://github.com/rust-lang/crates.io-index"
235 + checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
236 + dependencies = [
237 + "crc-catalog",
238 + ]
239 +
240 + [[package]]
241 + name = "crc-catalog"
242 + version = "2.5.0"
243 + source = "registry+https://github.com/rust-lang/crates.io-index"
244 + checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
245 +
246 + [[package]]
247 + name = "crossbeam-epoch"
248 + version = "0.9.18"
249 + source = "registry+https://github.com/rust-lang/crates.io-index"
250 + checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
251 + dependencies = [
252 + "crossbeam-utils",
253 + ]
254 +
255 + [[package]]
256 + name = "crossbeam-queue"
257 + version = "0.3.12"
258 + source = "registry+https://github.com/rust-lang/crates.io-index"
259 + checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
260 + dependencies = [
261 + "crossbeam-utils",
262 + ]
263 +
264 + [[package]]
265 + name = "crossbeam-utils"
266 + version = "0.8.21"
267 + source = "registry+https://github.com/rust-lang/crates.io-index"
268 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
269 +
270 + [[package]]
271 + name = "crypto-common"
272 + version = "0.1.7"
273 + source = "registry+https://github.com/rust-lang/crates.io-index"
274 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
275 + dependencies = [
276 + "generic-array",
277 + "typenum",
278 + ]
279 +
280 + [[package]]
281 + name = "data-encoding"
282 + version = "2.11.0"
283 + source = "registry+https://github.com/rust-lang/crates.io-index"
284 + checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
285 +
286 + [[package]]
287 + name = "der"
288 + version = "0.7.10"
289 + source = "registry+https://github.com/rust-lang/crates.io-index"
290 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
291 + dependencies = [
292 + "const-oid",
293 + "pem-rfc7468",
294 + "zeroize",
295 + ]
296 +
297 + [[package]]
298 + name = "digest"
299 + version = "0.10.7"
300 + source = "registry+https://github.com/rust-lang/crates.io-index"
301 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
302 + dependencies = [
303 + "block-buffer",
304 + "const-oid",
305 + "crypto-common",
306 + "subtle",
307 + ]
308 +
309 + [[package]]
310 + name = "displaydoc"
311 + version = "0.2.5"
312 + source = "registry+https://github.com/rust-lang/crates.io-index"
313 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
314 + dependencies = [
315 + "proc-macro2",
316 + "quote",
317 + "syn",
318 + ]
319 +
320 + [[package]]
321 + name = "dotenvy"
322 + version = "0.15.7"
323 + source = "registry+https://github.com/rust-lang/crates.io-index"
324 + checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
325 +
326 + [[package]]
327 + name = "either"
328 + version = "1.16.0"
329 + source = "registry+https://github.com/rust-lang/crates.io-index"
330 + checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
331 + dependencies = [
332 + "serde",
333 + ]
334 +
335 + [[package]]
336 + name = "equivalent"
337 + version = "1.0.2"
338 + source = "registry+https://github.com/rust-lang/crates.io-index"
339 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
340 +
341 + [[package]]
342 + name = "errno"
343 + version = "0.3.14"
344 + source = "registry+https://github.com/rust-lang/crates.io-index"
345 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
346 + dependencies = [
347 + "libc",
348 + "windows-sys 0.61.2",
349 + ]
350 +
351 + [[package]]
352 + name = "etcetera"
353 + version = "0.8.0"
354 + source = "registry+https://github.com/rust-lang/crates.io-index"
355 + checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
356 + dependencies = [
357 + "cfg-if",
358 + "home",
359 + "windows-sys 0.48.0",
360 + ]
361 +
362 + [[package]]
363 + name = "event-listener"
364 + version = "5.4.1"
365 + source = "registry+https://github.com/rust-lang/crates.io-index"
366 + checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
367 + dependencies = [
368 + "concurrent-queue",
369 + "parking",
370 + "pin-project-lite",
371 + ]
372 +
373 + [[package]]
374 + name = "evmap"
375 + version = "11.0.0"
376 + source = "registry+https://github.com/rust-lang/crates.io-index"
377 + checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
378 + dependencies = [
379 + "hashbag",
380 + "left-right",
381 + "smallvec",
382 + ]
383 +
384 + [[package]]
385 + name = "find-msvc-tools"
386 + version = "0.1.9"
387 + source = "registry+https://github.com/rust-lang/crates.io-index"
388 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
389 +
390 + [[package]]
391 + name = "flume"
392 + version = "0.11.1"
393 + source = "registry+https://github.com/rust-lang/crates.io-index"
394 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
395 + dependencies = [
396 + "futures-core",
397 + "futures-sink",
398 + "spin",
399 + ]
400 +
401 + [[package]]
402 + name = "foldhash"
403 + version = "0.1.5"
404 + source = "registry+https://github.com/rust-lang/crates.io-index"
405 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
406 +
407 + [[package]]
408 + name = "foldhash"
409 + version = "0.2.0"
410 + source = "registry+https://github.com/rust-lang/crates.io-index"
411 + checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
412 +
413 + [[package]]
414 + name = "form_urlencoded"
415 + version = "1.2.2"
416 + source = "registry+https://github.com/rust-lang/crates.io-index"
417 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
418 + dependencies = [
419 + "percent-encoding",
420 + ]
421 +
422 + [[package]]
423 + name = "futures-channel"
424 + version = "0.3.32"
425 + source = "registry+https://github.com/rust-lang/crates.io-index"
426 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
427 + dependencies = [
428 + "futures-core",
429 + "futures-sink",
430 + ]
431 +
432 + [[package]]
433 + name = "futures-core"
434 + version = "0.3.32"
435 + source = "registry+https://github.com/rust-lang/crates.io-index"
436 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
437 +
438 + [[package]]
439 + name = "futures-executor"
440 + version = "0.3.32"
441 + source = "registry+https://github.com/rust-lang/crates.io-index"
442 + checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
443 + dependencies = [
444 + "futures-core",
445 + "futures-task",
446 + "futures-util",
447 + ]
448 +
449 + [[package]]
450 + name = "futures-intrusive"
451 + version = "0.5.0"
452 + source = "registry+https://github.com/rust-lang/crates.io-index"
453 + checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
454 + dependencies = [
455 + "futures-core",
456 + "lock_api",
457 + "parking_lot",
458 + ]
459 +
460 + [[package]]
461 + name = "futures-io"
462 + version = "0.3.32"
463 + source = "registry+https://github.com/rust-lang/crates.io-index"
464 + checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
465 +
466 + [[package]]
467 + name = "futures-sink"
468 + version = "0.3.32"
469 + source = "registry+https://github.com/rust-lang/crates.io-index"
470 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
471 +
472 + [[package]]
473 + name = "futures-task"
474 + version = "0.3.32"
475 + source = "registry+https://github.com/rust-lang/crates.io-index"
476 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
477 +
478 + [[package]]
479 + name = "futures-util"
480 + version = "0.3.32"
481 + source = "registry+https://github.com/rust-lang/crates.io-index"
482 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
483 + dependencies = [
484 + "futures-core",
485 + "futures-io",
486 + "futures-sink",
487 + "futures-task",
488 + "memchr",
489 + "pin-project-lite",
490 + "slab",
491 + ]
492 +
493 + [[package]]
494 + name = "generator"
495 + version = "0.8.8"
496 + source = "registry+https://github.com/rust-lang/crates.io-index"
497 + checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9"
498 + dependencies = [
499 + "cc",
500 + "cfg-if",
Lines truncated
@@ -1,0 +1,24 @@
1 + [package]
2 + name = "sando-daemon"
3 + version = "0.1.0"
4 + edition = "2024"
5 + license = "MIT"
6 +
7 + [[bin]]
8 + name = "sandod"
9 + path = "src/main.rs"
10 +
11 + [dependencies]
12 + axum = { version = "0.8.8", features = ["macros", "ws"] }
13 + tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "fs", "process"] }
14 + serde = { version = "1.0.228", features = ["derive"] }
15 + serde_json = "1"
16 + toml = "0.8"
17 + sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] }
18 + tracing = "0.1.44"
19 + tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
20 + metrics = "0.24"
21 + metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
22 + anyhow = "1.0.102"
23 + thiserror = "2.0.18"
24 + chrono = { version = "0.4", features = ["serde"] }
@@ -1,0 +1,70 @@
1 + -- Sando v0 schema.
2 + -- Tiers and nodes are modeled as rows so topology changes are config edits,
3 + -- not schema migrations. The daemon syncs (tiers, nodes) from sando.toml on
4 + -- startup; mutable per-tier state (current_version, burn_in_started_at) lives
5 + -- in tier_state and survives restarts.
6 +
7 + CREATE TABLE tiers (
8 + name TEXT PRIMARY KEY,
9 + ord INTEGER NOT NULL,
10 + provisioned INTEGER NOT NULL DEFAULT 0,
11 + canary TEXT NOT NULL DEFAULT 'sequential'
12 + );
13 +
14 + CREATE TABLE nodes (
15 + name TEXT PRIMARY KEY,
16 + tier TEXT NOT NULL REFERENCES tiers(name),
17 + ssh_target TEXT NOT NULL,
18 + release_root TEXT NOT NULL
19 + );
20 +
21 + CREATE INDEX nodes_by_tier ON nodes(tier);
22 +
23 + CREATE TABLE versions (
24 + version TEXT PRIMARY KEY,
25 + git_sha TEXT NOT NULL,
26 + built_at TEXT NOT NULL,
27 + artifact_path TEXT NOT NULL
28 + );
29 +
30 + CREATE TABLE deploys (
31 + id INTEGER PRIMARY KEY AUTOINCREMENT,
32 + version TEXT NOT NULL REFERENCES versions(version),
33 + tier TEXT NOT NULL REFERENCES tiers(name),
34 + node TEXT REFERENCES nodes(name),
35 + started_at TEXT NOT NULL,
36 + finished_at TEXT,
37 + outcome TEXT NOT NULL DEFAULT 'in_progress',
38 + hotfix INTEGER NOT NULL DEFAULT 0,
39 + reset_burn_in INTEGER NOT NULL DEFAULT 0
40 + );
41 +
42 + CREATE INDEX deploys_by_tier_version ON deploys(tier, version);
43 +
44 + CREATE TABLE gate_runs (
45 + id INTEGER PRIMARY KEY AUTOINCREMENT,
46 + version TEXT NOT NULL REFERENCES versions(version),
47 + tier TEXT NOT NULL REFERENCES tiers(name),
48 + gate_kind TEXT NOT NULL,
49 + started_at TEXT NOT NULL,
50 + finished_at TEXT,
51 + passed INTEGER,
52 + detail TEXT
53 + );
54 +
55 + CREATE INDEX gate_runs_lookup ON gate_runs(tier, version, gate_kind);
56 +
57 + CREATE TABLE tier_state (
58 + tier TEXT PRIMARY KEY REFERENCES tiers(name),
59 + current_version TEXT REFERENCES versions(version),
60 + previous_version TEXT REFERENCES versions(version),
61 + burn_in_started_at TEXT
62 + );
63 +
64 + CREATE TABLE backups (
65 + id INTEGER PRIMARY KEY AUTOINCREMENT,
66 + fetched_at TEXT NOT NULL,
67 + source TEXT NOT NULL,
68 + local_path TEXT NOT NULL,
69 + byte_size INTEGER
70 + );
@@ -1,0 +1,4 @@
1 + # Local dev defaults. Override path with SANDO_CONFIG.
2 + listen = "127.0.0.1:7766"
3 + db_path = "./sando.db"
4 + topology_path = "../sando.toml"
@@ -1,0 +1,19 @@
1 + use anyhow::{Context, Result};
2 + use serde::Deserialize;
3 + use std::path::PathBuf;
4 +
5 + #[derive(Debug, Deserialize)]
6 + pub struct Config {
7 + pub listen: String,
8 + pub db_path: PathBuf,
9 + pub topology_path: PathBuf,
10 + }
11 +
12 + impl Config {
13 + pub fn load() -> Result<Self> {
14 + let path = std::env::var("SANDO_CONFIG").unwrap_or_else(|_| "sando-daemon.toml".into());
15 + let raw = std::fs::read_to_string(&path)
16 + .with_context(|| format!("reading daemon config at {path}"))?;
17 + Ok(toml::from_str(&raw)?)
18 + }
19 + }
@@ -1,0 +1,19 @@
1 + use anyhow::Result;
2 + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
3 + use sqlx::SqlitePool;
4 + use std::path::Path;
5 + use std::str::FromStr;
6 +
7 + pub async fn connect(path: &Path) -> Result<SqlitePool> {
8 + let url = format!("sqlite://{}?mode=rwc", path.display());
9 + let opts = SqliteConnectOptions::from_str(&url)?
10 + .create_if_missing(true)
11 + .foreign_keys(true);
12 + let pool = SqlitePoolOptions::new().max_connections(4).connect_with(opts).await?;
13 + Ok(pool)
14 + }
15 +
16 + pub async fn migrate(pool: &SqlitePool) -> Result<()> {
17 + sqlx::migrate!("./migrations").run(pool).await?;
18 + Ok(())
19 + }
@@ -1,0 +1,27 @@
1 + use axum::http::StatusCode;
2 + use axum::response::{IntoResponse, Response};
3 +
4 + #[derive(Debug, thiserror::Error)]
5 + pub enum Error {
6 + #[error("not found")]
7 + NotFound,
8 + #[error("gate not satisfied: {0}")]
9 + GateBlocked(String),
10 + #[error(transparent)]
11 + Db(#[from] sqlx::Error),
12 + #[error(transparent)]
13 + Other(#[from] anyhow::Error),
14 + }
15 +
16 + impl IntoResponse for Error {
17 + fn into_response(self) -> Response {
18 + let status = match &self {
19 + Error::NotFound => StatusCode::NOT_FOUND,
20 + Error::GateBlocked(_) => StatusCode::CONFLICT,
21 + _ => StatusCode::INTERNAL_SERVER_ERROR,
22 + };
23 + (status, self.to_string()).into_response()
24 + }
25 + }
26 +
27 + pub type Result<T> = std::result::Result<T, Error>;
@@ -1,0 +1,12 @@
1 + use crate::topology::Gate;
2 +
3 + #[derive(Debug, Clone)]
4 + pub struct GateOutcome {
5 + pub gate: String,
6 + pub passed: bool,
7 + pub detail: Option<String>,
8 + }
9 +
10 + pub async fn run(_gate: &Gate) -> GateOutcome {
11 + todo!("gate execution: cargo test, migration dry-run, boot smoke, burn-in check, manual confirm")
12 + }
@@ -1,0 +1,36 @@
1 + use anyhow::Result;
2 + use std::net::SocketAddr;
3 +
4 + mod config;
5 + mod db;
6 + mod error;
7 + mod gates;
8 + mod metrics;
9 + mod routes;
10 + mod state;
11 + mod topology;
12 +
13 + #[tokio::main]
14 + async fn main() -> Result<()> {
15 + tracing_subscriber::fmt()
16 + .with_env_filter(
17 + tracing_subscriber::EnvFilter::try_from_default_env()
18 + .unwrap_or_else(|_| "sando_daemon=info,tower_http=info".into()),
19 + )
20 + .init();
21 +
22 + let cfg = config::Config::load()?;
23 + let topo = topology::Topology::load(&cfg.topology_path)?;
24 + let pool = db::connect(&cfg.db_path).await?;
25 + db::migrate(&pool).await?;
26 +
27 + let prom = metrics::init();
28 + let app_state = state::AppState { pool, topo, prom };
29 + let app = routes::router(app_state);
30 +
31 + let addr: SocketAddr = cfg.listen.parse()?;
32 + tracing::info!(%addr, "sando daemon listening");
33 + let listener = tokio::net::TcpListener::bind(addr).await?;
34 + axum::serve(listener, app).await?;
35 + Ok(())
36 + }
@@ -1,0 +1,12 @@
1 + use axum::{extract::State, response::IntoResponse};
2 + use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
3 +
4 + pub fn init() -> PrometheusHandle {
5 + PrometheusBuilder::new()
6 + .install_recorder()
7 + .expect("install prometheus recorder")
8 + }
9 +
10 + pub async fn render(State(handle): State<PrometheusHandle>) -> impl IntoResponse {
11 + handle.render()
12 + }
@@ -1,0 +1,75 @@
1 + use crate::error::Result;
2 + use crate::state::AppState;
3 + use axum::extract::{Path, State, WebSocketUpgrade};
4 + use axum::response::IntoResponse;
5 + use axum::routing::{get, post};
6 + use axum::{Json, Router};
7 + use serde::{Deserialize, Serialize};
8 +
9 + pub fn router(state: AppState) -> Router {
10 + let prom = state.prom.clone();
11 + Router::new()
12 + .route("/state", get(get_state))
13 + .route("/promote/{tier}", post(promote))
14 + .route("/rollback/{tier}", post(rollback))
15 + .route("/rebuild", post(rebuild))
16 + .route("/backup/fetch", post(backup_fetch))
17 + .route("/events", get(events_ws))
18 + .with_state(state)
19 + .route("/metrics", get(crate::metrics::render).with_state(prom))
20 + }
21 +
22 + #[derive(Serialize)]
23 + struct StateView {
24 + tiers: Vec<TierView>,
25 + }
26 +
27 + #[derive(Serialize)]
28 + struct TierView {
29 + name: String,
30 + provisioned: bool,
31 + current_version: Option<String>,
32 + gates_passed: Vec<String>,
33 + }
34 +
35 + async fn get_state(State(_s): State<AppState>) -> Result<Json<StateView>> {
36 + todo!("read tier/version/gate state from sqlite")
37 + }
38 +
39 + #[derive(Deserialize)]
40 + struct PromoteBody {
41 + version: String,
42 + #[serde(default)]
43 + hotfix: bool,
44 + #[serde(default)]
45 + reset_burn_in: bool,
46 + }
47 +
48 + async fn promote(
49 + State(_s): State<AppState>,
50 + Path(_tier): Path<String>,
51 + Json(_body): Json<PromoteBody>,
52 + ) -> Result<Json<serde_json::Value>> {
53 + todo!("verify gates, atomic symlink swap on each node, sequential canary per topology")
54 + }
55 +
56 + async fn rollback(
57 + State(_s): State<AppState>,
58 + Path(_tier): Path<String>,
59 + ) -> Result<Json<serde_json::Value>> {
60 + todo!("flip symlink to previous release, restart unit")
61 + }
62 +
63 + async fn rebuild(State(_s): State<AppState>) -> Result<Json<serde_json::Value>> {
64 + todo!("triggered by post-receive hook; build + self-deploy + run MM gates")
65 + }
66 +
67 + async fn backup_fetch(State(_s): State<AppState>) -> Result<Json<serde_json::Value>> {
68 + todo!("pull latest prod backup from configured source to local_path")
69 + }
70 +
71 + async fn events_ws(ws: WebSocketUpgrade, State(_s): State<AppState>) -> impl IntoResponse {
72 + ws.on_upgrade(|_socket| async move {
73 + // tail of deploy/gate events for the TUI
74 + })
75 + }
@@ -1,0 +1,10 @@
1 + use crate::topology::Topology;
2 + use metrics_exporter_prometheus::PrometheusHandle;
3 + use sqlx::SqlitePool;
4 +
5 + #[derive(Clone)]
6 + pub struct AppState {
7 + pub pool: SqlitePool,
8 + pub topo: Topology,
9 + pub prom: PrometheusHandle,
10 + }
@@ -1,0 +1,80 @@
1 + use anyhow::{Context, Result};
2 + use serde::{Deserialize, Serialize};
3 + use std::path::Path;
4 +
5 + #[derive(Debug, Clone, Serialize, Deserialize)]
6 + pub struct Topology {
7 + pub repo: RepoConfig,
8 + pub backup: BackupConfig,
9 + #[serde(rename = "tier")]
10 + pub tiers: Vec<Tier>,
11 + }
12 +
13 + #[derive(Debug, Clone, Serialize, Deserialize)]
14 + pub struct RepoConfig {
15 + pub bare_path: String,
16 + pub branch: String,
17 + }
18 +
19 + #[derive(Debug, Clone, Serialize, Deserialize)]
20 + pub struct BackupConfig {
21 + pub source: String,
22 + pub local_path: String,
23 + }
24 +
25 + #[derive(Debug, Clone, Serialize, Deserialize)]
26 + pub struct Tier {
27 + pub name: String,
28 + #[serde(default)]
29 + pub provisioned: bool,
30 + pub gates: Vec<Gate>,
31 + #[serde(default)]
32 + pub canary: CanaryPolicy,
33 + #[serde(default, rename = "node")]
34 + pub nodes: Vec<Node>,
35 + }
36 +
37 + #[derive(Debug, Clone, Serialize, Deserialize)]
38 + pub struct Node {
39 + pub name: String,
40 + pub ssh_target: String,
41 + pub release_root: String,
42 + }
43 +
44 + #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
45 + #[serde(rename_all = "snake_case")]
46 + pub enum CanaryPolicy {
47 + #[default]
48 + Sequential,
49 + Parallel,
50 + }
51 +
52 + #[derive(Debug, Clone, Serialize, Deserialize)]
53 + #[serde(tag = "kind", rename_all = "snake_case")]
54 + pub enum Gate {
55 + CargoTest,
56 + MigrationDryRun,
57 + BootSmoke,
58 + BurnIn { hours: u32 },
59 + ManualConfirm,
60 + }
61 +
62 + impl Topology {
63 + pub fn load(path: &Path) -> Result<Self> {
64 + let raw = std::fs::read_to_string(path)
65 + .with_context(|| format!("reading topology at {}", path.display()))?;
66 + let topo: Topology = toml::from_str(&raw)?;
67 + topo.validate()?;
68 + Ok(topo)
69 + }
70 +
71 + fn validate(&self) -> Result<()> {
72 + anyhow::ensure!(!self.tiers.is_empty(), "topology must declare at least one tier");
73 + for t in &self.tiers {
74 + if t.provisioned && t.nodes.is_empty() && t.name != "mm" {
75 + anyhow::bail!("tier {} is provisioned but has no nodes", t.name);
76 + }
77 + }
78 + Ok(())
79 + }
80 + }
@@ -1,0 +1,69 @@
1 + # Sando topology config.
2 + #
3 + # Tiers run in declaration order. Each tier lists the gates that must pass to
4 + # unlock promotion *to* the next tier, the nodes it ships to, and the canary
5 + # policy for shipping within the tier.
6 + #
7 + # Day-one wiring: MM (local) -> A (testnot.work) -> B (prod-1). C is declared
8 + # but not provisioned; adding the second prod node later is a config edit
9 + # (set provisioned = true, fill in [[tier.node]]).
10 +
11 + [repo]
12 + bare_path = "/srv/sando/mnw.git"
13 + branch = "main"
14 +
15 + [backup]
16 + # Source of the prod-backup clone used by migration_dry_run on MM.
17 + # For localhost dev this can be a file:// path to a fixture dump.
18 + source = "rsync://astra/var/backups/mnw/latest.sql.gz"
19 + local_path = "/srv/sando/backups/latest.sql.gz"
20 +
21 + # ---- MM: local pre-staging gate ----
22 + [[tier]]
23 + name = "mm"
24 + provisioned = true
25 + canary = "sequential"
26 + gates = [
27 + { kind = "cargo_test" },
28 + { kind = "migration_dry_run" },
29 + { kind = "boot_smoke" },
30 + ]
31 + # MM is the daemon's own host; no remote node row.
32 +
33 + # ---- A: testnot.work staging ----
34 + [[tier]]
35 + name = "a"
36 + provisioned = true
37 + canary = "sequential"
38 + gates = [
39 + { kind = "boot_smoke" },
40 + { kind = "burn_in", hours = 48 },
41 + ]
42 + [[tier.node]]
43 + name = "testnot-1"
44 + ssh_target = "deploy@testnot.work"
45 + release_root = "/opt/mnw"
46 +
47 + # ---- B: prod-1 ----
48 + [[tier]]
49 + name = "b"
50 + provisioned = true
51 + canary = "sequential"
52 + gates = [
53 + { kind = "boot_smoke" },
54 + { kind = "manual_confirm" },
55 + ]
56 + [[tier.node]]
57 + name = "prod-1"
58 + ssh_target = "deploy@prod-1.makenot.work"
59 + release_root = "/opt/mnw"
60 +
61 + # ---- C: prod-2 (declared, not yet provisioned) ----
62 + [[tier]]
63 + name = "c"
64 + provisioned = false
65 + canary = "sequential"
66 + gates = [
67 + { kind = "boot_smoke" },
68 + ]
69 + # [[tier.node]] entries to be added when the second prod node ships.