flare.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. //! FLARE deploy (`engine = "flare"`, `deploy = "native"`).
  2. //!
  3. //! FLARE (`local-inference-lab/flare`) serves a *signed package* instead of a
  4. //! raw HF checkpoint: `flare download <repo> --revision <rev>` fetches it, and
  5. //! `flare orbit run "<controller>:<path>" --cluster <orbit cluster> …` brings
  6. //! up one rank per paired agent and serves the API on the controller. The
  7. //! launcher therefore does what a human would: preflight the agents, download
  8. //! the package once on rank 0 (stamped, so a repeat serve costs nothing), run
  9. //! orbit detached, then probe `/health` + `/v1/models`.
  10. //!
  11. //! Recipe block:
  12. //! ```toml
  13. //! engine = "flare"
  14. //! deploy = "native"
  15. //! [flare]
  16. //! package = "local-inference-lab/DeepSeek-V4.1-Flash-1017-FLARE"
  17. //! revision = "gb10"
  18. //! name = "deepseek1017"
  19. //! http_port = 8000
  20. //! serve_flags = ["--max-concurrency", "16", "--mtp-draft-tokens", "5"]
  21. //! ```
  22. //! Credentials (`--dangerously-approve-key`, publisher) live in the private
  23. //! config's `[flare]` block, never in the recipe repo.
  24. use anyhow::{Result, bail};
  25. use tracing::info;
  26. use crate::config::{Config, Node, Recipe};
  27. use crate::docker::{ServeOpts, shq};
  28. /// FLARE API port on the controller (recipe `[flare].http_port`, else the
  29. /// cluster default 30000 — the same port every other lane serves on).
  30. pub fn http_port(recipe: &Recipe) -> u16 {
  31. recipe
  32. .flare
  33. .as_ref()
  34. .and_then(|f| f.http_port)
  35. .unwrap_or(30000)
  36. }
  37. /// Orbit cluster name for one of OUR clusters: the per-cluster mapping wins,
  38. /// then the flat `[flare].orbit_cluster`, then the default. This is what makes
  39. /// one launcher config work on `dgx58-switch` and `prod4` alike.
  40. pub fn orbit_cluster_for(config: &Config, cluster: &str) -> String {
  41. let f = config.flare.as_ref();
  42. f.and_then(|f| f.clusters.get(cluster))
  43. .and_then(|c| c.orbit_cluster.clone())
  44. .or_else(|| f.and_then(|f| f.orbit_cluster.clone()))
  45. .unwrap_or_else(|| "sparks".to_string())
  46. }
  47. /// Local package mirror dir for a cluster (per-cluster map first).
  48. fn local_dir_for(config: &Config, cluster: &str) -> Option<String> {
  49. let f = config.flare.as_ref()?;
  50. f.clusters
  51. .get(cluster)
  52. .and_then(|c| c.local_dir.clone())
  53. .or_else(|| f.local_dir.clone())
  54. }
  55. /// The orbit-side name for one of our nodes (rank order → `orbit_nodes`,
  56. /// falling back to the cluster's own name).
  57. fn orbit_node_name(config: &Config, cluster: &str, node: &Node) -> String {
  58. let f = config.flare.as_ref();
  59. let per_cluster = f
  60. .and_then(|f| f.clusters.get(cluster))
  61. .map(|c| c.orbit_nodes.as_slice())
  62. .filter(|v| !v.is_empty());
  63. per_cluster
  64. .or_else(|| f.map(|f| f.orbit_nodes.as_slice()))
  65. .and_then(|v| v.get(node.rank as usize).cloned())
  66. .unwrap_or_else(|| node.name.clone())
  67. }
  68. fn binary(config: &Config) -> String {
  69. config
  70. .flare
  71. .as_ref()
  72. .and_then(|f| f.binary.clone())
  73. .unwrap_or_else(|| "flare".to_string())
  74. }
  75. /// `flare download <package> --revision <rev>` — prints the package path.
  76. pub fn download_cmd(config: &Config, recipe: &Recipe) -> Result<String> {
  77. let f = recipe
  78. .flare
  79. .as_ref()
  80. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  81. let mut cmd = format!("{} download {}", binary(config), shq(&f.package));
  82. if let Some(rev) = &f.revision {
  83. cmd.push_str(&format!(" --revision {}", shq(rev)));
  84. }
  85. if let Some(key) = config.flare.as_ref().and_then(|c| c.accept_key.clone()) {
  86. cmd.push_str(&format!(" --dangerously-approve-key {}", shq(&key)));
  87. }
  88. Ok(cmd)
  89. }
  90. /// `flare orbit run "<node>:<package>" --cluster <c> --name <n> … --detach`.
  91. pub fn run_cmd(
  92. config: &Config,
  93. recipe: &Recipe,
  94. orbit_cluster: &str,
  95. controller: &str,
  96. package: &str,
  97. ) -> Result<String> {
  98. let f = recipe
  99. .flare
  100. .as_ref()
  101. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  102. let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
  103. let mut cmd = format!(
  104. "{} orbit run {} --cluster {} --name {} --host 0.0.0.0 --port {} --detach",
  105. binary(config),
  106. shq(&format!("{controller}:{package}")),
  107. shq(orbit_cluster),
  108. shq(&model_name),
  109. http_port(recipe),
  110. );
  111. if let Some(k) = config.flare.as_ref().and_then(|c| c.accept_key.clone()) {
  112. cmd.push_str(&format!(" --dangerously-approve-key {}", shq(&k)));
  113. }
  114. if let Some(p) = config
  115. .flare
  116. .as_ref()
  117. .and_then(|c| c.accept_publisher.clone())
  118. {
  119. cmd.push_str(&format!(" --dangerously-accept-publisher {}", shq(&p)));
  120. }
  121. for flag in &f.serve_flags {
  122. cmd.push_str(&format!(" {}", shq(flag)));
  123. }
  124. Ok(cmd)
  125. }
  126. /// `flare orbit stop <name> --cluster <c>`.
  127. pub fn stop_cmd(config: &Config, recipe: &Recipe, orbit_cluster: &str) -> Result<String> {
  128. let f = recipe
  129. .flare
  130. .as_ref()
  131. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  132. let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
  133. Ok(format!(
  134. "{} orbit stop {} --cluster {}",
  135. binary(config),
  136. shq(&model_name),
  137. shq(orbit_cluster),
  138. ))
  139. }
  140. /// `flare orbit attach <name> --cluster <c>` (log tail; the CLI's `logs`).
  141. pub fn attach_cmd(config: &Config, recipe: &Recipe, orbit_cluster: &str) -> Result<String> {
  142. let f = recipe
  143. .flare
  144. .as_ref()
  145. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  146. let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
  147. Ok(format!(
  148. "{} orbit attach {} --cluster {}",
  149. binary(config),
  150. shq(&model_name),
  151. shq(orbit_cluster),
  152. ))
  153. }
  154. /// Preflight on every launch node: the agent installed and the orbit cluster
  155. /// visible. Runs before any download or launch — a peer that never paired
  156. /// otherwise fails minutes into a 500 GB fetch.
  157. ///
  158. /// Only the controller has the orbit file with `[cluster.<name>]` — a peer
  159. /// just needs the binary and a live agent, so the cluster check is added
  160. /// there and nowhere else.
  161. pub fn preflight_cmd(config: &Config, orbit_cluster: &str, controller: bool) -> String {
  162. let base = format!(
  163. "{} --version >/dev/null && systemctl --user is-active flare-agent >/dev/null",
  164. binary(config)
  165. );
  166. if controller {
  167. format!(
  168. "{base} && {} orbit status --cluster {} >/dev/null",
  169. binary(config),
  170. shq(orbit_cluster),
  171. )
  172. } else {
  173. base
  174. }
  175. }
  176. /// Mirror a package's files into the controller's local dir and return the
  177. /// local primary path.
  178. ///
  179. /// FLARE names every companion by its sha256, so "present and the same size"
  180. /// is a sound consistency check — a truncated or stale file re-copies, and an
  181. /// unchanged package costs a `stat` per file. The primary (metadata) is tiny
  182. /// and always refreshed.
  183. pub fn mirror_cmd(local_dir: &str, remote_pkg: &str) -> String {
  184. let dir = shq(local_dir);
  185. let pkg = shq(remote_pkg);
  186. format!(
  187. "set -e; src=$(dirname {pkg}); dst={dir}; base=$(basename {pkg}); \
  188. mkdir -p \"$dst\"; \
  189. cp -f {pkg} \"$dst/$base\"; \
  190. for f in \"$src\"/*; do b=$(basename \"$f\"); [ \"$b\" = \"$base\" ] && continue; \
  191. if [ ! -f \"$dst/$b\" ] || [ \"$(stat -c%s \"$f\")\" != \"$(stat -c%s \"$dst/$b\")\" ]; then \
  192. cp -f \"$f\" \"$dst/$b\" || exit 1; fi; done; \
  193. echo \"$dst/$base\""
  194. )
  195. }
  196. /// Pick the package path out of `flare download` output: it prints progress
  197. /// lines ("Downloading model file: …") and finally the resolved package path
  198. /// ending in `.safetensors`. Taking the last line blindly handed `orbit run`
  199. /// a progress line and the group died silently — so scan backwards for a path.
  200. pub fn parse_package_path(stdout: &str) -> Option<String> {
  201. stdout
  202. .lines()
  203. .rev()
  204. .map(str::trim)
  205. .find(|l| l.ends_with(".safetensors") && l.contains('/'))
  206. .map(str::to_string)
  207. }
  208. fn head(cluster: &crate::config::Cluster) -> Result<&Node> {
  209. cluster
  210. .nodes
  211. .iter()
  212. .find(|n| n.rank == 0)
  213. .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))
  214. }
  215. /// Launch a FLARE recipe: preflight, package download on the controller, then
  216. /// `orbit run` detached. Readiness (`/health`, `/v1/models`) is polled by the
  217. /// caller, like every other engine.
  218. pub async fn serve(
  219. config: &Config,
  220. recipe: &Recipe,
  221. cluster: &crate::config::Cluster,
  222. dry_run: bool,
  223. ) -> Result<()> {
  224. let controller = head(cluster)?;
  225. let orbit = orbit_cluster_for(config, &cluster.name);
  226. if dry_run {
  227. println!(
  228. " [dry-run] preflight on {} nodes (cluster check on the controller only): {}",
  229. cluster.nodes.len(),
  230. preflight_cmd(config, &orbit, true)
  231. );
  232. } else {
  233. for node in &cluster.nodes {
  234. let pre = preflight_cmd(config, &orbit, node.rank == 0);
  235. if let Err(e) = crate::ssh::run_ssh(config, node, pre, false).await {
  236. bail!(
  237. "FLARE preflight failed on {}: {e} — install `flare`, then `flare agent install` and pair the node with the controller",
  238. node.name
  239. );
  240. }
  241. }
  242. info!("FLARE agents ok on {} node(s)", cluster.nodes.len());
  243. }
  244. let dl = download_cmd(config, recipe)?;
  245. let package = crate::ssh::run_ssh(config, controller, dl, dry_run).await?;
  246. let package = parse_package_path(&package).unwrap_or_default();
  247. if !dry_run && package.is_empty() {
  248. bail!(
  249. "flare download printed no package path on {}",
  250. controller.name
  251. );
  252. }
  253. // Local mirror: launch from the controller's own disk when configured —
  254. // engram rows are read at request time, so a shared mount is the wrong
  255. // place for the package even with a RAM row cache.
  256. let mut package = package;
  257. if let Some(dir) = local_dir_for(config, &cluster.name) {
  258. let dir = dir.replace('~', "$HOME");
  259. let cmd = mirror_cmd(&dir, &package);
  260. let out = crate::ssh::run_ssh(config, controller, cmd, dry_run).await?;
  261. if !dry_run {
  262. let local = out.lines().last().unwrap_or_default().trim().to_string();
  263. if local.is_empty() {
  264. bail!("flare local mirror produced no path on {}", controller.name);
  265. }
  266. package = local;
  267. }
  268. }
  269. let orbit_name = orbit_node_name(config, &cluster.name, controller);
  270. let run = run_cmd(config, recipe, &orbit, &orbit_name, &package)?;
  271. crate::ssh::run_ssh(config, controller, run.clone(), dry_run).await?;
  272. if dry_run {
  273. println!(" [dry-run] {}: {run}", controller.name);
  274. } else {
  275. info!("FLARE group launched on {}", controller.name);
  276. }
  277. Ok(())
  278. }
  279. /// Post-launch: wait for the API (the package load takes many minutes) and
  280. /// point the operator at the log command.
  281. pub async fn after_serve(
  282. config: &Config,
  283. recipe: &Recipe,
  284. cluster: &crate::config::Cluster,
  285. recipe_name: &str,
  286. opts: &ServeOpts,
  287. follow: bool,
  288. ) -> Result<()> {
  289. let port = http_port(recipe);
  290. if !opts.no_wait_ready {
  291. crate::readiness::wait_engine(config, cluster, port, 30 * 60, recipe_name).await?;
  292. }
  293. if follow {
  294. info!(
  295. "FLARE serving on {}:{} — logs: sm121 logs --recipe {recipe_name}",
  296. cluster
  297. .nodes
  298. .first()
  299. .map(|n| n.ssh_ip.as_str())
  300. .unwrap_or("?"),
  301. port
  302. );
  303. }
  304. Ok(())
  305. }
  306. /// Stop the FLARE group on the controller.
  307. pub async fn stop(
  308. config: &Config,
  309. recipe: &Recipe,
  310. cluster: &crate::config::Cluster,
  311. dry_run: bool,
  312. ) -> Result<()> {
  313. let controller = head(cluster)?;
  314. let cmd = stop_cmd(config, recipe, &orbit_cluster_for(config, &cluster.name))?;
  315. crate::ssh::run_ssh(config, controller, cmd, dry_run).await?;
  316. Ok(())
  317. }
  318. #[cfg(test)]
  319. mod tests {
  320. use super::*;
  321. use crate::config::FlareRecipe;
  322. fn cfg() -> Config {
  323. let c: Config = toml::from_str(
  324. "ssh_user = \"u\"\nssh_key_path = \"/k\"\n\n[flare]\naccept_key = \"key-123\"\naccept_publisher = \"local-development\"\norbit_cluster = \"sparks\"\n",
  325. )
  326. .expect("test config");
  327. c
  328. }
  329. fn recipe() -> Recipe {
  330. let mut r = crate::engine::tests::base_recipe();
  331. r.engine = "flare".into();
  332. r.model = "deepseek-ai/DeepSeek-V4.1-Flash-1017".into();
  333. r.flare = Some(FlareRecipe {
  334. package: "local-inference-lab/DeepSeek-V4.1-Flash-1017-FLARE".into(),
  335. revision: Some("gb10".into()),
  336. name: Some("deepseek1017".into()),
  337. http_port: Some(8000),
  338. serve_flags: vec![
  339. "--max-concurrency".into(),
  340. "16".into(),
  341. "--mtp-adaptive-verification".into(),
  342. ],
  343. });
  344. r
  345. }
  346. #[test]
  347. fn download_carries_revision_and_key() {
  348. let cmd = download_cmd(&cfg(), &recipe()).unwrap();
  349. assert!(cmd.starts_with("flare download "), "{cmd}");
  350. // shq() shell-quotes every value, so compare the quoted forms
  351. assert!(cmd.contains("--revision 'gb10'"), "{cmd}");
  352. assert!(cmd.contains("--dangerously-approve-key 'key-123'"), "{cmd}");
  353. }
  354. #[test]
  355. fn run_passes_flags_verbatim_and_the_controller_package() {
  356. let cmd = run_cmd(
  357. &cfg(),
  358. &recipe(),
  359. "sparks",
  360. "dgx5",
  361. "/pkgs/dsv41.safetensors",
  362. )
  363. .unwrap();
  364. assert!(
  365. cmd.contains("orbit run 'dgx5:/pkgs/dsv41.safetensors'"),
  366. "{cmd}"
  367. );
  368. assert!(cmd.contains("--cluster 'sparks'"), "{cmd}");
  369. assert!(cmd.contains("--name 'deepseek1017'"), "{cmd}");
  370. assert!(cmd.contains("--port 8000"), "{cmd}"); // recipe value wins
  371. assert!(cmd.contains("--detach"), "{cmd}");
  372. assert!(cmd.contains("'--mtp-adaptive-verification'"), "{cmd}");
  373. assert!(cmd.contains("--port 8000"), "{cmd}"); // recipe value wins
  374. assert!(
  375. cmd.contains("--dangerously-accept-publisher 'local-development'"),
  376. "{cmd}"
  377. );
  378. }
  379. #[test]
  380. fn orbit_node_names_follow_rank_order() {
  381. let mut c = cfg();
  382. c.flare.as_mut().unwrap().orbit_nodes = vec!["spark1".into()];
  383. let node = Node {
  384. name: "dgx5.lan".into(),
  385. ssh_ip: "10.0.0.1".into(),
  386. ib_ip: None,
  387. rank: 0,
  388. host_key: None,
  389. entity: None,
  390. };
  391. assert_eq!(orbit_node_name(&c, "dgx58-switch", &node), "spark1");
  392. let node1 = Node { rank: 1, ..node };
  393. // no entry for rank 1 → the cluster's own name is used
  394. assert_eq!(orbit_node_name(&c, "dgx58-switch", &node1), "dgx5.lan");
  395. }
  396. #[test]
  397. fn per_cluster_mapping_wins_over_the_flat_block() {
  398. // The same launcher config must serve FLARE on another cluster: the
  399. // per-cluster map supplies that cluster's orbit name and node list.
  400. let mut c = cfg();
  401. c.flare.as_mut().unwrap().clusters.insert(
  402. "prod4".into(),
  403. crate::config::flare_cluster::FlareCluster {
  404. orbit_cluster: Some("prod4".into()),
  405. orbit_nodes: vec!["gx10-dgx1".into()],
  406. controller_node: None,
  407. local_dir: Some("/data/flare".into()),
  408. },
  409. );
  410. assert_eq!(orbit_cluster_for(&c, "prod4"), "prod4");
  411. assert_eq!(orbit_cluster_for(&c, "dgx58-switch"), "sparks");
  412. assert_eq!(local_dir_for(&c, "prod4").as_deref(), Some("/data/flare"));
  413. let node = Node {
  414. name: "dgx1.lan".into(),
  415. ssh_ip: "10.0.0.1".into(),
  416. ib_ip: None,
  417. rank: 0,
  418. host_key: None,
  419. entity: None,
  420. };
  421. assert_eq!(orbit_node_name(&c, "prod4", &node), "gx10-dgx1");
  422. let cmd = stop_cmd(&c, &recipe(), &orbit_cluster_for(&c, "prod4")).unwrap();
  423. assert!(cmd.contains("--cluster 'prod4'"), "{cmd}");
  424. }
  425. #[test]
  426. fn stop_names_the_group_and_cluster() {
  427. let cmd = stop_cmd(&cfg(), &recipe(), "sparks").unwrap();
  428. assert_eq!(cmd, "flare orbit stop 'deepseek1017' --cluster 'sparks'");
  429. }
  430. #[test]
  431. fn preflight_checks_agent_and_pairing() {
  432. let cmd = preflight_cmd(&cfg(), "sparks", true);
  433. assert!(cmd.contains("is-active flare-agent"), "{cmd}");
  434. assert!(cmd.contains("orbit status --cluster 'sparks'"), "{cmd}");
  435. // a peer has no orbit file: binary + agent only
  436. assert!(!preflight_cmd(&cfg(), "sparks", false).contains("orbit status"));
  437. }
  438. #[test]
  439. fn package_path_is_taken_from_the_download_output() {
  440. let out = "Resolved package: org/repo@abc / pkg.safetensors\n\
  441. Downloading model file: aa.safetensors...\n\
  442. Downloading model file: bb.safetensors...\n\
  443. /mnt/nas/hub/snapshots/abc/pkg.safetensors\n";
  444. assert_eq!(
  445. parse_package_path(out).as_deref(),
  446. Some("/mnt/nas/hub/snapshots/abc/pkg.safetensors")
  447. );
  448. assert_eq!(
  449. parse_package_path("Downloading model file: x.safetensors..."),
  450. None
  451. );
  452. }
  453. #[test]
  454. fn missing_block_is_an_error() {
  455. let mut r = recipe();
  456. r.flare = None;
  457. assert!(download_cmd(&cfg(), &r).is_err());
  458. }
  459. }