api.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. //! REST endpoints. P1: `GET /api/state` (latest snapshot; probes once if
  2. //! the supervisor hasn't ticked yet).
  3. use axum::{extract::State, http::StatusCode, response::Json};
  4. use serde::Deserialize;
  5. use super::AppState;
  6. use super::events::Snapshot;
  7. pub(crate) async fn state(app: State<AppState>) -> Json<Snapshot> {
  8. if app.last.lock().unwrap().is_none() {
  9. // First request before the first supervisor tick: probe now so the
  10. // UI gets data immediately (probe is read-only `docker ps` per node
  11. // and degrades to `reachable: false` on failure). Two racing first
  12. // requests may both probe — harmless (idempotent, last write wins).
  13. super::push_state(&app).await;
  14. }
  15. app.last
  16. .lock()
  17. .unwrap()
  18. .as_ref()
  19. .map(|s| Json(s.clone()))
  20. .unwrap()
  21. }
  22. // ---------------------------------------------------------------------------
  23. // P2: actions (serve/stop/power run as tracked jobs via the same handlers
  24. // the CLI uses) + recipe list.
  25. type ApiError = (StatusCode, String);
  26. #[derive(Deserialize)]
  27. pub(crate) struct ServeReq {
  28. pub cluster: String,
  29. pub recipe: String,
  30. #[serde(default)]
  31. pub node: Option<String>,
  32. #[serde(default)]
  33. pub solo: Option<bool>,
  34. #[serde(default)]
  35. pub force: bool,
  36. #[serde(default)]
  37. pub fit: Option<String>,
  38. #[serde(default)]
  39. pub clear: bool,
  40. #[serde(default)]
  41. pub mirror: Option<String>,
  42. #[serde(default)]
  43. pub keep: bool,
  44. #[serde(default)]
  45. pub dry_run: bool,
  46. }
  47. /// `POST /api/serve` — same dispatch as the CLI (docker vs tmux by
  48. /// `recipe.deploy`), run as a tracked job.
  49. pub(crate) async fn serve(
  50. app: State<AppState>,
  51. Json(req): Json<ServeReq>,
  52. ) -> Result<Json<serde_json::Value>, ApiError> {
  53. let id = super::jobs::new_job(&app, "serve", &req.cluster, Some(&req.recipe))?;
  54. let config = app.config.clone();
  55. let app2 = (*app).clone();
  56. let opts = crate::docker::ServeOpts {
  57. keep: req.keep,
  58. skip_before_serve: false,
  59. dry_run: req.dry_run,
  60. auto_power_on: false,
  61. sync_image: true,
  62. sync_image_source: None,
  63. build_node: None,
  64. force_build: false,
  65. force: req.force,
  66. solo: req.solo,
  67. mirror: req.mirror,
  68. fit: req.fit,
  69. clear: req.clear,
  70. };
  71. let recipe = req.recipe.clone();
  72. let cluster = req.cluster.clone();
  73. let node = req.node.clone();
  74. super::jobs::spawn(app2, id.clone(), async move {
  75. crate::handle_serve(&config, &recipe, &cluster, node, opts, false, false).await
  76. });
  77. Ok(Json(serde_json::json!({ "job": id })))
  78. }
  79. #[derive(Deserialize)]
  80. pub(crate) struct StopReq {
  81. pub cluster: String,
  82. pub recipe: String,
  83. #[serde(default)]
  84. pub node: Option<String>,
  85. }
  86. /// `POST /api/stop` — stops the recipe's containers (auto-discovers the
  87. /// cluster when the recipe is running exactly once), as a tracked job.
  88. pub(crate) async fn stop(
  89. app: State<AppState>,
  90. Json(req): Json<StopReq>,
  91. ) -> Result<Json<serde_json::Value>, ApiError> {
  92. let id = super::jobs::new_job(&app, "stop", &req.cluster, Some(&req.recipe))?;
  93. let config = app.config.clone();
  94. let app2 = (*app).clone();
  95. let recipe = req.recipe.clone();
  96. let cluster = req.cluster.clone();
  97. let node = req.node.clone();
  98. super::jobs::spawn(app2, id.clone(), async move {
  99. let nodes = vec![node.unwrap_or_default()];
  100. let nodes = if nodes[0].is_empty() { vec![] } else { nodes };
  101. crate::docker::stop::handle(&config, &recipe, &cluster, &nodes, true, false, false).await
  102. });
  103. Ok(Json(serde_json::json!({ "job": id })))
  104. }
  105. #[derive(Deserialize)]
  106. pub(crate) struct PowerReq {
  107. /// "off" | "on" | "switch"
  108. pub action: String,
  109. pub cluster: String,
  110. /// switch target (required only for `switch`)
  111. #[serde(default)]
  112. pub to: Option<String>,
  113. }
  114. /// `POST /api/power` — HA power actions as a tracked job. The UI enforces
  115. /// the type-the-name confirm; this endpoint trusts the caller (it is
  116. /// token-gated / loopback-bound).
  117. pub(crate) async fn power(
  118. app: State<AppState>,
  119. Json(req): Json<PowerReq>,
  120. ) -> Result<Json<serde_json::Value>, ApiError> {
  121. let id = super::jobs::new_job(&app, "power", &req.cluster, None)?;
  122. let config = app.config.clone();
  123. let app2 = (*app).clone();
  124. let action = req.action.clone();
  125. let cluster = req.cluster.clone();
  126. let to = req.to.clone();
  127. super::jobs::spawn(app2, id.clone(), async move {
  128. match action.as_str() {
  129. "off" => {
  130. let c = crate::config::resolve_cluster_name(&config.clusters, &cluster)?;
  131. crate::power::power_off(&config, c, &[], false).await
  132. }
  133. "on" => {
  134. let c = crate::config::resolve_cluster_name(&config.clusters, &cluster)?;
  135. crate::power::power_on(&config, c, &[], false).await
  136. }
  137. "switch" => {
  138. let to = to
  139. .as_deref()
  140. .ok_or_else(|| anyhow::anyhow!("`to` cluster is required for power switch"))?;
  141. let from = crate::config::resolve_cluster_name(&config.clusters, &cluster)?;
  142. let to = crate::config::resolve_cluster_name(&config.clusters, to)?;
  143. crate::power::power_switch(&config, from, to, false).await
  144. }
  145. other => Err(anyhow::anyhow!(
  146. "unknown power action '{other}' (off|on|switch)"
  147. )),
  148. }
  149. });
  150. Ok(Json(serde_json::json!({ "job": id })))
  151. }
  152. /// `GET /api/recipes` — all discoverable recipes (cwd + `recipe_sources` +
  153. /// the global config dir), with deploy target + node requirements.
  154. pub(crate) async fn recipes(app: State<AppState>) -> Json<serde_json::Value> {
  155. let dirs = crate::config::recipe_dirs(&app.config);
  156. let mut out = Vec::new();
  157. for dir in dirs {
  158. let mut entries = match tokio::fs::read_dir(&dir).await {
  159. Ok(e) => e,
  160. Err(_) => continue,
  161. };
  162. loop {
  163. let Ok(Some(entry)) = entries.next_entry().await else {
  164. break;
  165. };
  166. let path = entry.path();
  167. if !path.is_dir() {
  168. continue;
  169. }
  170. let Some(name) = entry.file_name().to_str().map(String::from) else {
  171. continue;
  172. };
  173. let file = path.join("recipe.toml");
  174. let Ok(toml_str) = tokio::fs::read_to_string(&file).await else {
  175. continue;
  176. };
  177. match toml::from_str::<crate::config::Recipe>(&toml_str) {
  178. Ok(r) => out.push(serde_json::json!({
  179. "name": name,
  180. "engine": r.engine,
  181. "deploy": format!("{:?}", r.deploy).to_lowercase(),
  182. "required_nodes": r.required_nodes,
  183. })),
  184. Err(e) => tracing::debug!("skipping recipe {}: {e}", name),
  185. }
  186. }
  187. }
  188. Json(serde_json::json!({ "recipes": out }))
  189. }
  190. // ---------------------------------------------------------------------------
  191. // P3: log windows — attach a streaming `docker logs -f` per node+recipe+rank
  192. // (idempotent), detach, and fetch ring history for a fresh window.
  193. #[derive(Deserialize)]
  194. pub(crate) struct LogsReq {
  195. pub cluster: String,
  196. pub node: String,
  197. pub recipe: String,
  198. #[serde(default)]
  199. pub rank: u32,
  200. #[serde(default)]
  201. pub lines: usize,
  202. }
  203. /// `POST /api/logs` — attach (reuses a live stream for the same key).
  204. pub(crate) async fn logs_attach(
  205. app: State<AppState>,
  206. Json(req): Json<LogsReq>,
  207. ) -> Result<Json<serde_json::Value>, ApiError> {
  208. let (id, history) = super::logs::attach(
  209. &app,
  210. &app.log_attaches,
  211. &req.cluster,
  212. &req.node,
  213. &req.recipe,
  214. req.rank,
  215. )
  216. .map_err(|e| {
  217. (
  218. if e.starts_with("max") {
  219. StatusCode::CONFLICT
  220. } else {
  221. StatusCode::BAD_REQUEST
  222. },
  223. e,
  224. )
  225. })?;
  226. Ok(Json(
  227. serde_json::json!({ "attach_id": id, "history": history }),
  228. ))
  229. }
  230. /// `DELETE /api/logs` — stop the stream and drop the attach.
  231. #[derive(Deserialize)]
  232. pub(crate) struct DetachReq {
  233. pub attach_id: String,
  234. }
  235. pub(crate) async fn logs_detach(
  236. app: State<AppState>,
  237. Json(req): Json<DetachReq>,
  238. ) -> Result<StatusCode, ApiError> {
  239. let id = req.attach_id;
  240. if super::logs::detach(&app.log_attaches, &id) {
  241. Ok(StatusCode::NO_CONTENT)
  242. } else {
  243. Err((StatusCode::NOT_FOUND, format!("no log attach '{id}'")))
  244. }
  245. }
  246. /// `GET /api/logs?cluster=&node=&recipe=&rank=&lines=` — ring history for a
  247. /// fresh window (0 lines = `logs::TAIL_LINES`).
  248. pub(crate) async fn logs_history(
  249. app: State<AppState>,
  250. axum::extract::Query(q): axum::extract::Query<LogsReq>,
  251. ) -> Result<Json<serde_json::Value>, ApiError> {
  252. let lines = if q.lines == 0 {
  253. super::logs::TAIL_LINES
  254. } else {
  255. q.lines
  256. };
  257. let (id, history) = super::logs::by_key(
  258. &app.log_attaches,
  259. &q.cluster,
  260. &q.node,
  261. &q.recipe,
  262. q.rank,
  263. lines,
  264. )
  265. .ok_or_else(|| {
  266. (
  267. StatusCode::NOT_FOUND,
  268. "no log attach for that node/recipe".to_string(),
  269. )
  270. })?;
  271. Ok(Json(
  272. serde_json::json!({ "attach_id": id, "history": history }),
  273. ))
  274. }
  275. // ---------------------------------------------------------------------------
  276. // P4: raw content — the active config file, a recipe verbatim, and the
  277. // merged effective config (post cluster-source merge) as JSON.
  278. /// `GET /api/raw/config` — the active config file, verbatim.
  279. pub(crate) async fn raw_config() -> Result<axum::response::Response, ApiError> {
  280. let path = crate::gconfig::resolve_config_path("config.toml");
  281. let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
  282. (
  283. StatusCode::NOT_FOUND,
  284. format!("no config file at {}: {e}", path.display()),
  285. )
  286. })?;
  287. Ok(axum::response::Response::builder()
  288. .status(200)
  289. .header("content-type", "text/plain; charset=utf-8")
  290. .body(axum::body::Body::from(content))
  291. .unwrap())
  292. }
  293. /// `GET /api/raw/recipe/<name>` — recipe.toml verbatim (search order as
  294. /// `load_recipe`: cwd, recipe_sources, global dir).
  295. pub(crate) async fn raw_recipe(
  296. app: State<AppState>,
  297. axum::extract::Path(name): axum::extract::Path<String>,
  298. ) -> Result<axum::response::Response, ApiError> {
  299. if name.is_empty() || name.contains('/') || name.contains("..") {
  300. return Err((StatusCode::BAD_REQUEST, "invalid recipe name".to_string()));
  301. }
  302. let dirs = crate::config::recipe_dirs(&app.config);
  303. for dir in &dirs {
  304. let p = dir.join(&name).join("recipe.toml");
  305. if let Ok(content) = tokio::fs::read_to_string(&p).await {
  306. return Ok(axum::response::Response::builder()
  307. .status(200)
  308. .header("content-type", "text/plain; charset=utf-8")
  309. .body(axum::body::Body::from(content))
  310. .unwrap());
  311. }
  312. }
  313. Err((StatusCode::NOT_FOUND, format!("recipe '{name}' not found")))
  314. }
  315. /// `GET /api/raw/effective` — the merged `Config` as JSON (post
  316. /// cluster-source merge), HA token redacted.
  317. pub(crate) async fn raw_effective(app: State<AppState>) -> Json<serde_json::Value> {
  318. use crate::config::NcclConfig;
  319. let ha = app.config.home_assistant.as_ref().map(|h| {
  320. let masked: String = h.token.chars().take(12).collect();
  321. let token = if h.token.len() > 12 {
  322. format!("{masked}…<redacted>")
  323. } else {
  324. "<redacted>".to_string()
  325. };
  326. serde_json::json!({
  327. "url": h.url,
  328. "token": token,
  329. "shutdown_delay_seconds": h.shutdown_delay_seconds,
  330. })
  331. });
  332. Json(serde_json::json!({
  333. "config_path": crate::gconfig::resolve_config_path("config.toml").display().to_string(),
  334. "ssh_user": app.config.ssh_user,
  335. "ssh_key_path": app.config.ssh_key_path.display().to_string(),
  336. "clusters": app.config.clusters.iter().map(|c| serde_json::json!({
  337. "name": c.name,
  338. "nodes": c.nodes.iter().map(|n| serde_json::json!({
  339. "name": n.name,
  340. "ssh_ip": n.ssh_ip,
  341. "rank": n.rank,
  342. "entity": n.entity,
  343. "host_key_set": n.host_key.is_some(),
  344. })).collect::<Vec<_>>(),
  345. "extra_entities": c.extra_entities.iter().map(|e| serde_json::json!({
  346. "entity": e.entity(),
  347. "blocks": e.blocks(),
  348. })).collect::<Vec<_>>(),
  349. "nccl": app.config.nccl.get(&c.name).map(|n: &NcclConfig| serde_json::json!({
  350. "ib_hca": n.ib_hca,
  351. "socket_ifname": n.socket_ifname,
  352. "ib_gid_index": n.ib_gid_index,
  353. "ib_disable": n.ib_disable,
  354. })),
  355. "before_serve": app.config.before_serve.get(&c.name).map(|b| b.commands.len()),
  356. })).collect::<Vec<_>>(),
  357. "home_assistant": ha,
  358. "init_port": app.config.init_port,
  359. "mount_aliases": app.config.mount_aliases,
  360. "recipe_sources": app.config.recipe_sources,
  361. "cluster_sources": app.config.cluster_sources,
  362. "drop_caches": app.config.drop_caches,
  363. }))
  364. }