Skip to main content

max / makenotwork

Mount a router per product, keeping the root as the default Products are addressed by mounting a whole router each rather than by an {app} path parameter every handler has to remember to read. AppState already carries one product's view — its config, topology and node executors — so each mount gets its own state and no handler can address the wrong product by forgetting a parameter. The mistake is unavailable rather than guarded against, and not one of the ~40 handlers changed. The unprefixed paths stay and mean the default product. /promote/b is what the runbook says, what the TUI calls, and what an operator types under pressure; making that conditional on a rename would be a cost paid by the wrong people. GET /apps says what is mounted and which one the root addresses, behind the same bearer gate as everything else, since it describes the deploy surface. Verified against a real two-app config: each product loads, validates and reports its own tiers, and the cross-file checks fire per product rather than pooling.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 23:59 UTC
Signed with PGP, not checked
Commit: 00793b45c087d0590ac5cb61503e2f4ba2aefabe
Parent: 14bd66b
2 files changed, +177 insertions, -1 deletion
@@ -193,7 +193,7 @@
193 193 executors,
194 194 api_token,
195 195 };
196 - let app = routes::router(app_state);
196 + let app = routes::router_for_apps(app_state);
197 197 tracing::info!(%addr, "sando daemon listening");
198 198 let listener = tokio::net::TcpListener::bind(addr).await?;
199 199 // `into_make_service_with_connect_info` surfaces the peer `SocketAddr` in
@@ -43,6 +43,64 @@
43 43 Router::new().merge(protected).with_state(state)
44 44 }
45 45
46 + /// The daemon's whole HTTP surface: one product's routes at the root, and every
47 + /// product's under `/apps/<id>`.
48 + ///
49 + /// A product is addressed by mounting a whole router per product rather than by
50 + /// a `{app}` path parameter every handler has to read. [`AppState`] already
51 + /// carries one product's view — its config, topology and node executors — so
52 + /// giving each mount its own state means no handler can address the wrong
53 + /// product by forgetting to look at a parameter. The mistake is unavailable
54 + /// rather than guarded against.
55 + ///
56 + /// The unprefixed paths stay, meaning the default product. `/promote/b` is what
57 + /// the deploy runbook says, what the TUI calls, and what an operator types under
58 + /// pressure; making all of that conditional on a rename would be a cost paid by
59 + /// the wrong people.
60 + pub fn router_for_apps(state: AppState) -> Router {
61 + // The root mount. `state` already holds the default product's view.
62 + let mut r = router(state.clone());
63 + for (id, app) in state.apps.iter() {
64 + let scoped = AppState {
65 + cfg: app.cfg.clone(),
66 + topo: app.topo.clone(),
67 + executors: app.executors.clone(),
68 + ..state.clone()
69 + };
70 + r = r.nest(&format!("/apps/{id}"), router(scoped));
71 + }
72 + r.merge(apps_index(state))
73 + }
74 +
75 + /// `GET /apps` — which products this daemon ships, and which one the unprefixed
76 + /// routes address. Behind the same bearer gate as everything else: it describes
77 + /// the deploy surface.
78 + fn apps_index(state: AppState) -> Router {
79 + let token = state.api_token.clone();
80 + let protected =
81 + Router::new()
82 + .route("/apps", get(list_apps))
83 + .route_layer(axum::middleware::from_fn(move |req, next| {
84 + require_bearer(token.clone(), req, next)
85 + }));
86 + Router::new().merge(protected).with_state(state)
87 + }
88 +
89 + #[derive(serde::Serialize)]
90 + pub struct AppsView {
91 + /// Every product, in configured order.
92 + pub apps: Vec<String>,
93 + /// The one the unprefixed routes act on.
94 + pub default_app: String,
95 + }
96 +
97 + async fn list_apps(State(s): State<AppState>) -> Json<AppsView> {
98 + Json(AppsView {
99 + apps: s.app_ids().iter().map(ToString::to_string).collect(),
100 + default_app: s.default_app.to_string(),
101 + })
102 + }
103 +
46 104 /// Bearer-token gate for the deploy mutators. When no token is configured the
47 105 /// request passes (main() only allows that on a loopback bind). Comparison is
48 106 /// constant-time to avoid leaking the token via timing.
@@ -984,6 +1042,124 @@
984 1042 }
985 1043 }
986 1044
1045 + /// Two products mounted on one daemon address different state.
1046 + ///
1047 + /// The mount-per-product shape is what makes this true: each router carries
1048 + /// its own product's config, so `/apps/pom/state` cannot answer from MNW's
1049 + /// tiers even if a handler forgets the product exists. The root mount keeps
1050 + /// meaning the default product, which is what the runbook and the TUI call.
1051 + #[tokio::test]
1052 + async fn each_app_is_addressable_and_the_root_stays_the_default() {
1053 + let pool = fresh_pool().await;
1054 + // MNW ships host + a; pom ships one tier of its own, named differently
1055 + // so the response says which product answered.
1056 + for (app, tiers) in [("mnw", vec!["host", "a"]), ("pom", vec!["pom-host"])] {
1057 + for (i, name) in tiers.iter().enumerate() {
1058 + sqlx::query("INSERT INTO tiers (app, name, ord, provisioned) VALUES (?, ?, ?, 1)")
1059 + .bind(app)
1060 + .bind(name)
1061 + .bind(i as i64)
1062 + .execute(&pool)
1063 + .await
1064 + .unwrap();
1065 + sqlx::query("INSERT INTO tier_state (app, tier) VALUES (?, ?)")
1066 + .bind(app)
1067 + .bind(name)
1068 + .execute(&pool)
1069 + .await
1070 + .unwrap();
1071 + }
1072 + }
1073 +
1074 + let mnw_topo = Arc::new(test_topo());
1075 + let mut pom_topo = test_topo();
1076 + pom_topo.tiers = vec![crate::topology::Tier {
1077 + name: "pom-host".into(),
1078 + provisioned: true,
1079 + gates: vec![],
1080 + canary: crate::topology::CanaryPolicy::Sequential,
1081 + nodes: vec![],
1082 + }];
1083 + let pom_topo = Arc::new(pom_topo);
1084 +
1085 + let mnw_cfg = Arc::new(test_cfg());
1086 + let mut pom = test_cfg();
1087 + pom.id = crate::domain::AppId::new("pom");
1088 + let pom_cfg = Arc::new(pom);
1089 +
1090 + let mut apps = crate::state::AppMap::new();
1091 + for (id, cfg, topo) in [
1092 + (mnw_cfg.id.clone(), mnw_cfg.clone(), mnw_topo.clone()),
1093 + (pom_cfg.id.clone(), pom_cfg.clone(), pom_topo.clone()),
1094 + ] {
1095 + let executors = Arc::new(crate::state::build_executors(&topo));
1096 + apps.insert(
1097 + id,
1098 + Arc::new(crate::state::App {
1099 + cfg,
1100 + topo,
1101 + executors,
1102 + }),
1103 + );
1104 + }
1105 + let state = AppState {
1106 + pool,
1107 + apps: Arc::new(apps),
1108 + default_app: mnw_cfg.id.clone(),
1109 + topo: mnw_topo,
1110 + cfg: mnw_cfg,
1111 + active_build: Arc::new(tokio::sync::Mutex::new(None)),
1112 + deploy_lock: Arc::new(tokio::sync::Mutex::new(())),
1113 + events: crate::events::channel(),
1114 + executors: Arc::new(std::collections::HashMap::new()),
1115 + api_token: None,
1116 + };
1117 +
1118 + let get = async |uri: &str| -> String {
1119 + let resp = router_for_apps(state.clone())
1120 + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1121 + .await
1122 + .unwrap();
1123 + assert_eq!(resp.status(), StatusCode::OK, "GET {uri}");
1124 + body_string(resp).await
1125 + };
1126 +
1127 + // The root is the default product.
1128 + let root = get("/state").await;
1129 + assert!(root.contains("\"host\""), "root /state: {root}");
1130 + assert!(!root.contains("pom-host"), "root must not show pom: {root}");
1131 +
1132 + // Each product answers under its own mount.
1133 + let mnw = get("/apps/mnw/state").await;
1134 + assert_eq!(mnw, root, "the default mount and the root are one product");
1135 + let pom = get("/apps/pom/state").await;
1136 + assert!(pom.contains("pom-host"), "/apps/pom/state: {pom}");
1137 + assert!(
1138 + !pom.contains("\"host\""),
1139 + "pom must not see mnw's tiers: {pom}"
1140 + );
1141 +
1142 + // And the index says what is mounted.
1143 + let index = get("/apps").await;
1144 + assert!(
1145 + index.contains("\"mnw\"") && index.contains("\"pom\""),
1146 + "{index}"
1147 + );
1148 + assert!(index.contains("\"default_app\":\"mnw\""), "{index}");
1149 +
1150 + // An unconfigured product is not a route.
1151 + let resp = router_for_apps(state.clone())
1152 + .oneshot(
1153 + Request::builder()
1154 + .uri("/apps/nope/state")
1155 + .body(Body::empty())
1156 + .unwrap(),
1157 + )
1158 + .await
1159 + .unwrap();
1160 + assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1161 + }
1162 +
987 1163 async fn test_state() -> AppState {
988 1164 let pool = fresh_pool().await;
989 1165 // Seed tier rows so FKs on tier_state / gate_runs are satisfied.