| 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 |
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.
|