Bladeren bron

flare driver: orbit node-name mapping, readiness wait, logs attach

Completes engine=flare: [flare].orbit_nodes maps our cluster rows to the orbit
peer names in rank order (empty = same names), the serve path waits for the API
on [flare].http_port (30 min, --no-wait skips), and sm121 logs runs flare orbit
attach instead of a docker tail.

Verified by dry-run: preflight on four nodes, orbit run 'spark1:<pkg>' with the
gb10 flag set, orbit stop on stop. Recipes repo has the lane
dsv41-flash-1017-4x-flare-gb10.
Alexander Korolev 17 uur geleden
bovenliggende
commit
43d40a41b5

+ 11 - 0
specs/tech-architecture/flare-engine-driver.md

@@ -37,6 +37,17 @@ accept_key        = "3748701d…"        # --dangerously-approve-key (пакет
 accept_publisher  = "local-development"
 binary            = "flare"            # если не в PATH
 orbit_cluster     = "sparks"           # имя `[cluster.<...>]` в orbit.toml
+# наши имена нод → имена в orbit (по порядку рангов). Пусто = совпадают.
+orbit_nodes       = ["spark1", "spark2", "spark3", "spark4"]
+```
+
+Реализация: `src/native/flare.rs` (+ `[flare]` в `config/recipe.rs` и
+`config/types.rs`), диспетчеризация в `native::{serve,stop}`, `logs` →
+`flare orbit attach`, готовность через `readiness::wait_engine` на
+`[flare].http_port`. Тесты: `cargo test flare` (6 штук). Dry-run:
+
+```
+sm121 -d serve --recipe dsv41-flash-1017-4x-flare-gb10 --cluster dgx58-switch
 ```
 
 ## Что делает драйвер (`src/engine/drivers.rs`, как у dgpp)

+ 2 - 0
src/after_stop.rs

@@ -166,6 +166,7 @@ mod tests {
 
     fn test_config() -> Config {
         Config {
+            flare: None,
             ssh_user: "u".to_string(),
             ssh_key_path: "/k".into(),
             clusters: vec![test_cluster()],
@@ -189,6 +190,7 @@ mod tests {
 
     fn test_recipe(after_stop: Vec<PreServeCommand>) -> Recipe {
         Recipe {
+            flare: None,
             build_profile: None,
             parent_model: None,
             archived: false,

+ 2 - 0
src/before_serve_tests.rs

@@ -33,6 +33,7 @@ fn test_cluster() -> Cluster {
 
 fn test_config(before_serve: HashMap<String, crate::config::BeforeServeTable>) -> Config {
     Config {
+        flare: None,
         ssh_user: "u".to_string(),
         ssh_key_path: "/k".into(),
         clusters: vec![test_cluster()],
@@ -174,6 +175,7 @@ fn combined_empty_when_unconfigured() {
 
 fn test_recipe() -> Recipe {
     Recipe {
+        flare: None,
         build_profile: None,
         parent_model: None,
         archived: false,

+ 5 - 0
src/cluster_state/tests.rs

@@ -190,6 +190,7 @@ fn select_smallest_with_enough_free() {
     );
 
     let cfg = Config {
+        flare: None,
         ssh_user: "u".to_string(),
         ssh_key_path: "/k".into(),
         clusters: vec![
@@ -231,6 +232,7 @@ fn select_smallest_with_enough_free() {
 fn select_cluster_not_found_errors() {
     let state = ClusterState::default();
     let cfg = Config {
+        flare: None,
         ssh_user: "u".to_string(),
         ssh_key_path: "/k".into(),
         clusters: vec![],
@@ -264,6 +266,7 @@ fn no_cluster_has_enough_free_errors() {
         },
     );
     let cfg = Config {
+        flare: None,
         ssh_user: "u".to_string(),
         ssh_key_path: "/k".into(),
         clusters: vec![test_cluster("c", &["n1"])],
@@ -301,6 +304,7 @@ fn single_node_allows_cluster_without_nccl() {
         },
     );
     let cfg = Config {
+        flare: None,
         ssh_user: "u".to_string(),
         ssh_key_path: "/k".into(),
         clusters: vec![test_cluster("c", &["n1"])],
@@ -344,6 +348,7 @@ fn multi_node_requires_nccl() {
         },
     );
     let cfg = Config {
+        flare: None,
         ssh_user: "u".to_string(),
         ssh_key_path: "/k".into(),
         clusters: vec![test_cluster("c", &["n1", "n2"])],

+ 26 - 0
src/config/recipe.rs

@@ -218,6 +218,32 @@ pub struct Recipe {
     /// `scripts/dgpp-cluster up --config <path>` there. See [`DgppConfig`].
     #[serde(default)]
     pub dgpp: Option<DgppConfig>,
+    /// FLARE deploy (`engine = "flare"`, `deploy = "native"`): which signed
+    /// package to serve and how. Credentials stay in the private config.
+    #[serde(default)]
+    pub flare: Option<FlareRecipe>,
+}
+
+/// FLARE package recipe block — `[flare]` in `recipe.toml`.
+#[derive(Clone, Debug, Default, Deserialize)]
+pub struct FlareRecipe {
+    /// HF repo holding the signed FLARE package.
+    pub package: String,
+    /// Package revision/branch (`gb10` = the four-Spark build).
+    #[serde(default)]
+    pub revision: Option<String>,
+    /// `orbit run --name` (the group name `orbit stop`/`attach` use);
+    /// defaults to the recipe's model.
+    #[serde(default)]
+    pub name: Option<String>,
+    /// API port on the controller (readiness probe); FLARE default 8000.
+    #[serde(default)]
+    pub http_port: Option<u16>,
+    /// Extra `orbit run` flags, passed verbatim — FLARE owns its own knobs
+    /// (`--max-concurrency`, `--mtp-draft-tokens`,
+    /// `--embedding-table-storage disk`, …).
+    #[serde(default)]
+    pub serve_flags: Vec<String>,
 }
 
 /// DGPP (`HawkBearPig/dgpp`) deployment knobs. The launcher assembles the

+ 1 - 0
src/config/tests.rs

@@ -30,6 +30,7 @@ fn tcls() -> Cluster {
 
 fn tcfg() -> Config {
     Config {
+        flare: None,
         ssh_user: "u".to_string(),
         ssh_key_path: "/k".into(),
         clusters: vec![tcls()],

+ 28 - 0
src/config/types.rs

@@ -73,6 +73,10 @@ pub struct Config {
     /// RouterOS devices for graceful `power off` (see [`MikrotikConfig`]).
     #[serde(default)]
     pub mikrotik: Option<MikrotikConfig>,
+    /// FLARE deploy settings (private: the publisher acceptance key must never
+    /// reach the shared recipe repo).
+    #[serde(default)]
+    pub flare: Option<FlareSettings>,
     #[serde(default)]
     pub global_mods_dir: Option<std::path::PathBuf>,
     /// Drop page cache before starting containers.
@@ -466,3 +470,27 @@ mod tests {
         assert!(r.is_err());
     }
 }
+
+/// `[flare]` in the private config: how the launcher talks to FLARE.
+#[derive(Clone, Debug, Default, serde::Deserialize)]
+pub struct FlareSettings {
+    /// Publisher acceptance key (`--dangerously-approve-key`). A FLARE package
+    /// is signed; accepting the publisher is a trust decision, so it lives
+    /// here, never in the recipe.
+    #[serde(default)]
+    pub accept_key: Option<String>,
+    /// `--dangerously-accept-publisher` value (e.g. `local-development`).
+    #[serde(default)]
+    pub accept_publisher: Option<String>,
+    /// `flare` binary path (default: from PATH).
+    #[serde(default)]
+    pub binary: Option<String>,
+    /// `[cluster.<name>]` in `~/.config/flare/orbit.toml` (default `sparks`).
+    #[serde(default)]
+    pub orbit_cluster: Option<String>,
+    /// Orbit node names in cluster rank order (our `dgx5.lan` → their
+    /// `spark1`). Empty = the cluster's own node names, which works when the
+    /// orbit cluster was paired under the same names.
+    #[serde(default)]
+    pub orbit_nodes: Vec<String>,
+}

+ 1 - 0
src/detect.rs

@@ -143,6 +143,7 @@ mod tests {
             entity: None,
         };
         let config = crate::config::Config {
+            flare: None,
             ssh_user: "u".into(),
             ssh_key_path: std::path::PathBuf::from("/p"),
             clusters: vec![],

+ 2 - 0
src/docker/command_tests.rs

@@ -37,6 +37,7 @@ fn tnccl() -> NcclConfig {
 
 fn minimal_config() -> Config {
     Config {
+        flare: None,
         ssh_user: "u".into(),
         ssh_key_path: "/tmp/key".into(),
         clusters: vec![],
@@ -81,6 +82,7 @@ fn run_with(recipe: &Recipe) -> String {
 
 fn base_recipe() -> Recipe {
     Recipe {
+        flare: None,
         build_profile: None,
         parent_model: None,
         archived: false,

+ 2 - 0
src/docker/mirror.rs

@@ -188,6 +188,7 @@ mod tests {
 
     fn cfg() -> Config {
         Config {
+            flare: None,
             ssh_user: "u".into(),
             ssh_key_path: "/tmp/k".into(),
             clusters: vec![],
@@ -211,6 +212,7 @@ mod tests {
 
     fn trec() -> Recipe {
         Recipe {
+            flare: None,
             build_profile: None,
             parent_model: None,
             archived: false,

+ 1 - 0
src/doctor.rs

@@ -349,6 +349,7 @@ mod tests {
 
     fn test_recipe() -> Recipe {
         Recipe {
+            flare: None,
             build_profile: None,
             parent_model: None,
             archived: false,

+ 25 - 0
src/engine/drivers.rs

@@ -153,6 +153,31 @@ impl EngineDriver for DgppDriver {
     }
 }
 
+/// FLARE (`local-inference-lab/flare`): serves a *signed package* via
+/// `flare orbit`, which brings up one rank per paired agent from the
+/// controller. The package carries the model, world size and engine knobs, so
+/// there are no CLI model/distributed flags; the API port is the recipe's
+/// `[flare].http_port` (fixed by the orbit invocation). `deploy = native`.
+pub struct FlareDriver;
+
+impl EngineDriver for FlareDriver {
+    fn model_args(&self, _recipe: &Recipe) -> Vec<String> {
+        Vec::new()
+    }
+
+    fn distributed_args(&self, _ctx: &EngineFlagsCtx<'_>, _node: &Node) -> Vec<String> {
+        Vec::new()
+    }
+
+    fn takes_host_port(&self) -> bool {
+        false
+    }
+
+    fn entrypoint_default(&self) -> Vec<String> {
+        vec!["flare".into(), "orbit".into(), "run".into()]
+    }
+}
+
 /// TensorFold (`x00byte/DeepSeek-V4.1-Flash-1017-Dual-Spark`, upstream
 /// `TensorFold`): a Python engine for a dual-Spark kit. The model is a
 /// positional directory (`python -m tensorfold serve <model>`), rendezvous via

+ 3 - 1
src/engine/mod.rs

@@ -15,7 +15,7 @@
 
 mod drivers;
 
-pub use drivers::{DgppDriver, Gr8Driver, SglangDriver, TensorFoldDriver, VllmDriver};
+pub use drivers::{DgppDriver, FlareDriver, Gr8Driver, SglangDriver, TensorFoldDriver, VllmDriver};
 
 use crate::config::{DistributedBackend, Node, Recipe};
 
@@ -91,6 +91,7 @@ pub fn driver_for(engine: &str) -> &'static dyn EngineDriver {
         "sglang" => &SglangDriver,
         "gr8" => &Gr8Driver,
         "dgpp" => &DgppDriver,
+        "flare" => &FlareDriver,
         "tensorfold" => &TensorFoldDriver,
         "vllm" => &VllmDriver,
         _ => &VllmDriver,
@@ -143,6 +144,7 @@ pub(crate) mod tests {
 
     pub(crate) fn base_recipe() -> Recipe {
         Recipe {
+            flare: None,
             build_profile: None,
             parent_model: None,
             archived: false,

+ 1 - 0
src/exec.rs

@@ -159,6 +159,7 @@ mod tests {
             .map(|(k, v)| (k.to_string(), v.to_string()))
             .collect();
         Config {
+            flare: None,
             ssh_user: "u".to_string(),
             ssh_key_path: "/k".into(),
             clusters: vec![],

+ 13 - 0
src/handlers.rs

@@ -24,6 +24,19 @@ pub(crate) async fn handle_logs(
     dry_run: bool,
 ) -> Result<()> {
     let cluster = resolve_cluster_for_op(config, recipe_name, cluster).await?;
+    // FLARE has no docker container to tail: its log is the orbit session on
+    // the controller (`flare orbit attach`).
+    let recipe_obj = load_recipe(recipe_name, &config.recipe_sources).await?;
+    if recipe_obj.engine == "flare" {
+        let head = cluster
+            .nodes
+            .iter()
+            .find(|n| n.rank == 0)
+            .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
+        let cmd = crate::native::flare::attach_cmd(config, &recipe_obj)?;
+        crate::ssh::run_ssh(config, head, cmd, dry_run).await?;
+        return Ok(());
+    }
     docker::show_logs(
         config,
         &cluster,

+ 6 - 4
src/main.rs

@@ -451,10 +451,12 @@ pub(crate) async fn handle_serve(
             }
         }
         DeployTarget::Native => {
-            // Head-coordinated native launch (DGPP): the engine's cluster
-            // manager runs on rank 0 and brings up the peers itself.
-            native::native_serve(config, recipe, cluster, node.as_deref(), &opts).await?;
-            if follow && !dry_run {
+            // Head-coordinated native launch (DGPP / FLARE): the engine's own
+            // cluster manager runs on rank 0 and brings up the peers itself.
+            let cl = native::native_serve(config, recipe, cluster, node.as_deref(), &opts).await?;
+            if recipe_obj.engine == "flare" && !dry_run {
+                native::flare::after_serve(config, &recipe_obj, &cl, recipe, &opts, follow).await?;
+            } else if follow && !dry_run {
                 // DGPP logs live in its own log dir on rank 0 (no docker
                 // label to tail); point the operator at the log file.
                 tracing::info!("DGPP serving; engine log on rank 0: ~/dgpp/log/sm121-{recipe}.log");

+ 357 - 0
src/native/flare.rs

@@ -0,0 +1,357 @@
+//! FLARE deploy (`engine = "flare"`, `deploy = "native"`).
+//!
+//! FLARE (`local-inference-lab/flare`) serves a *signed package* instead of a
+//! raw HF checkpoint: `flare download <repo> --revision <rev>` fetches it, and
+//! `flare orbit run "<controller>:<path>" --cluster <orbit cluster> …` brings
+//! up one rank per paired agent and serves the API on the controller. The
+//! launcher therefore does what a human would: preflight the agents, download
+//! the package once on rank 0 (stamped, so a repeat serve costs nothing), run
+//! orbit detached, then probe `/health` + `/v1/models`.
+//!
+//! Recipe block:
+//! ```toml
+//! engine = "flare"
+//! deploy = "native"
+//! [flare]
+//! package   = "local-inference-lab/DeepSeek-V4.1-Flash-1017-FLARE"
+//! revision  = "gb10"
+//! name      = "deepseek1017"
+//! http_port = 8000
+//! serve_flags = ["--max-concurrency", "16", "--mtp-draft-tokens", "5"]
+//! ```
+//! Credentials (`--dangerously-approve-key`, publisher) live in the private
+//! config's `[flare]` block, never in the recipe repo.
+
+use anyhow::{Result, bail};
+use tracing::info;
+
+use crate::config::{Config, Node, Recipe};
+use crate::docker::{ServeOpts, shq};
+
+/// FLARE API port on the controller (recipe `[flare].http_port`, else 8000).
+pub fn http_port(recipe: &Recipe) -> u16 {
+    recipe
+        .flare
+        .as_ref()
+        .and_then(|f| f.http_port)
+        .unwrap_or(8000)
+}
+
+fn orbit_cluster(config: &Config) -> String {
+    config
+        .flare
+        .as_ref()
+        .and_then(|f| f.orbit_cluster.clone())
+        .unwrap_or_else(|| "sparks".to_string())
+}
+
+/// The orbit-side name for one of our nodes (rank order → `orbit_nodes`,
+/// falling back to the cluster's own name).
+fn orbit_node_name(config: &Config, node: &Node) -> String {
+    config
+        .flare
+        .as_ref()
+        .and_then(|f| f.orbit_nodes.get(node.rank as usize).cloned())
+        .unwrap_or_else(|| node.name.clone())
+}
+
+fn binary(config: &Config) -> String {
+    config
+        .flare
+        .as_ref()
+        .and_then(|f| f.binary.clone())
+        .unwrap_or_else(|| "flare".to_string())
+}
+
+/// `flare download <package> --revision <rev>` — prints the package path.
+pub fn download_cmd(config: &Config, recipe: &Recipe) -> Result<String> {
+    let f = recipe
+        .flare
+        .as_ref()
+        .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
+    let mut cmd = format!("{} download {}", binary(config), shq(&f.package));
+    if let Some(rev) = &f.revision {
+        cmd.push_str(&format!(" --revision {}", shq(rev)));
+    }
+    if let Some(key) = config.flare.as_ref().and_then(|c| c.accept_key.clone()) {
+        cmd.push_str(&format!(" --dangerously-approve-key {}", shq(&key)));
+    }
+    Ok(cmd)
+}
+
+/// `flare orbit run "<node>:<package>" --cluster <c> --name <n> … --detach`.
+pub fn run_cmd(
+    config: &Config,
+    recipe: &Recipe,
+    controller: &str,
+    package: &str,
+) -> Result<String> {
+    let f = recipe
+        .flare
+        .as_ref()
+        .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
+    let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
+    let mut cmd = format!(
+        "{} orbit run {} --cluster {} --name {} --host 0.0.0.0 --port {} --detach",
+        binary(config),
+        shq(&format!("{controller}:{package}")),
+        shq(&orbit_cluster(config)),
+        shq(&model_name),
+        http_port(recipe),
+    );
+    if let Some(k) = config.flare.as_ref().and_then(|c| c.accept_key.clone()) {
+        cmd.push_str(&format!(" --dangerously-approve-key {}", shq(&k)));
+    }
+    if let Some(p) = config
+        .flare
+        .as_ref()
+        .and_then(|c| c.accept_publisher.clone())
+    {
+        cmd.push_str(&format!(" --dangerously-accept-publisher {}", shq(&p)));
+    }
+    for flag in &f.serve_flags {
+        cmd.push_str(&format!(" {}", shq(flag)));
+    }
+    Ok(cmd)
+}
+
+/// `flare orbit stop <name> --cluster <c>`.
+pub fn stop_cmd(config: &Config, recipe: &Recipe) -> Result<String> {
+    let f = recipe
+        .flare
+        .as_ref()
+        .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
+    let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
+    Ok(format!(
+        "{} orbit stop {} --cluster {}",
+        binary(config),
+        shq(&model_name),
+        shq(&orbit_cluster(config)),
+    ))
+}
+
+/// `flare orbit attach <name> --cluster <c>` (log tail; the CLI's `logs`).
+pub fn attach_cmd(config: &Config, recipe: &Recipe) -> Result<String> {
+    let f = recipe
+        .flare
+        .as_ref()
+        .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
+    let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
+    Ok(format!(
+        "{} orbit attach {} --cluster {}",
+        binary(config),
+        shq(&model_name),
+        shq(&orbit_cluster(config)),
+    ))
+}
+
+/// Preflight on every launch node: the agent installed and the orbit cluster
+/// visible. Runs before any download or launch — a peer that never paired
+/// otherwise fails minutes into a 500 GB fetch.
+pub fn preflight_cmd(config: &Config) -> String {
+    format!(
+        "{} --version >/dev/null && systemctl --user is-active flare-agent >/dev/null && {} orbit status --cluster {} >/dev/null",
+        binary(config),
+        binary(config),
+        shq(&orbit_cluster(config)),
+    )
+}
+
+fn head(cluster: &crate::config::Cluster) -> Result<&Node> {
+    cluster
+        .nodes
+        .iter()
+        .find(|n| n.rank == 0)
+        .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))
+}
+
+/// Launch a FLARE recipe: preflight, package download on the controller, then
+/// `orbit run` detached. Readiness (`/health`, `/v1/models`) is polled by the
+/// caller, like every other engine.
+pub async fn serve(
+    config: &Config,
+    recipe: &Recipe,
+    cluster: &crate::config::Cluster,
+    dry_run: bool,
+) -> Result<()> {
+    let controller = head(cluster)?;
+    let pre = preflight_cmd(config);
+    if dry_run {
+        println!(
+            "  [dry-run] preflight on {} nodes: {pre}",
+            cluster.nodes.len()
+        );
+    } else {
+        for node in &cluster.nodes {
+            if let Err(e) = crate::ssh::run_ssh(config, node, pre.clone(), false).await {
+                bail!(
+                    "FLARE preflight failed on {}: {e} — install `flare`, then `flare agent install` and pair the node with the controller",
+                    node.name
+                );
+            }
+        }
+        info!("FLARE agents ok on {} node(s)", cluster.nodes.len());
+    }
+    let dl = download_cmd(config, recipe)?;
+    let package = crate::ssh::run_ssh(config, controller, dl, dry_run).await?;
+    let package = package
+        .lines()
+        .last()
+        .unwrap_or_default()
+        .trim()
+        .to_string();
+    if !dry_run && package.is_empty() {
+        bail!(
+            "flare download printed no package path on {}",
+            controller.name
+        );
+    }
+    let orbit_name = orbit_node_name(config, controller);
+    let run = run_cmd(config, recipe, &orbit_name, &package)?;
+    crate::ssh::run_ssh(config, controller, run.clone(), dry_run).await?;
+    if dry_run {
+        println!("  [dry-run] {}: {run}", controller.name);
+    } else {
+        info!("FLARE group launched on {}", controller.name);
+    }
+    Ok(())
+}
+
+/// Post-launch: wait for the API (the package load takes many minutes) and
+/// point the operator at the log command.
+pub async fn after_serve(
+    config: &Config,
+    recipe: &Recipe,
+    cluster: &crate::config::Cluster,
+    recipe_name: &str,
+    opts: &ServeOpts,
+    follow: bool,
+) -> Result<()> {
+    let port = http_port(recipe);
+    if !opts.no_wait_ready {
+        crate::readiness::wait_engine(config, cluster, port, 30 * 60, recipe_name).await?;
+    }
+    if follow {
+        info!(
+            "FLARE serving on {}:{} — logs: sm121 logs --recipe {recipe_name}",
+            cluster
+                .nodes
+                .first()
+                .map(|n| n.ssh_ip.as_str())
+                .unwrap_or("?"),
+            port
+        );
+    }
+    Ok(())
+}
+
+/// Stop the FLARE group on the controller.
+pub async fn stop(
+    config: &Config,
+    recipe: &Recipe,
+    cluster: &crate::config::Cluster,
+    dry_run: bool,
+) -> Result<()> {
+    let controller = head(cluster)?;
+    let cmd = stop_cmd(config, recipe)?;
+    crate::ssh::run_ssh(config, controller, cmd, dry_run).await?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::FlareRecipe;
+
+    fn cfg() -> Config {
+        let c: Config = toml::from_str(
+            "ssh_user = \"u\"\nssh_key_path = \"/k\"\n\n[flare]\naccept_key = \"key-123\"\naccept_publisher = \"local-development\"\norbit_cluster = \"sparks\"\n",
+        )
+        .expect("test config");
+        c
+    }
+
+    fn recipe() -> Recipe {
+        let mut r = crate::engine::tests::base_recipe();
+        r.engine = "flare".into();
+        r.model = "deepseek-ai/DeepSeek-V4.1-Flash-1017".into();
+        r.flare = Some(FlareRecipe {
+            package: "local-inference-lab/DeepSeek-V4.1-Flash-1017-FLARE".into(),
+            revision: Some("gb10".into()),
+            name: Some("deepseek1017".into()),
+            http_port: Some(8000),
+            serve_flags: vec![
+                "--max-concurrency".into(),
+                "16".into(),
+                "--mtp-adaptive-verification".into(),
+            ],
+        });
+        r
+    }
+
+    #[test]
+    fn download_carries_revision_and_key() {
+        let cmd = download_cmd(&cfg(), &recipe()).unwrap();
+        assert!(cmd.starts_with("flare download "), "{cmd}");
+        // shq() shell-quotes every value, so compare the quoted forms
+        assert!(cmd.contains("--revision 'gb10'"), "{cmd}");
+        assert!(cmd.contains("--dangerously-approve-key 'key-123'"), "{cmd}");
+    }
+
+    #[test]
+    fn run_passes_flags_verbatim_and_the_controller_package() {
+        let cmd = run_cmd(&cfg(), &recipe(), "dgx5", "/pkgs/dsv41.safetensors").unwrap();
+        assert!(
+            cmd.contains("orbit run 'dgx5:/pkgs/dsv41.safetensors'"),
+            "{cmd}"
+        );
+        assert!(cmd.contains("--cluster 'sparks'"), "{cmd}");
+        assert!(cmd.contains("--name 'deepseek1017'"), "{cmd}");
+        assert!(cmd.contains("--port 8000"), "{cmd}");
+        assert!(cmd.contains("--detach"), "{cmd}");
+        assert!(cmd.contains("'--mtp-adaptive-verification'"), "{cmd}");
+        assert!(cmd.contains("--port 8000"), "{cmd}");
+        assert!(
+            cmd.contains("--dangerously-accept-publisher 'local-development'"),
+            "{cmd}"
+        );
+    }
+
+    #[test]
+    fn orbit_node_names_follow_rank_order() {
+        let mut c = cfg();
+        c.flare.as_mut().unwrap().orbit_nodes = vec!["spark1".into()];
+        let node = Node {
+            name: "dgx5.lan".into(),
+            ssh_ip: "10.0.0.1".into(),
+            ib_ip: None,
+            rank: 0,
+            host_key: None,
+            entity: None,
+        };
+        assert_eq!(orbit_node_name(&c, &node), "spark1");
+        let node1 = Node { rank: 1, ..node };
+        // no entry for rank 1 → the cluster's own name is used
+        assert_eq!(orbit_node_name(&c, &node1), "dgx5.lan");
+    }
+
+    #[test]
+    fn stop_names_the_group_and_cluster() {
+        let cmd = stop_cmd(&cfg(), &recipe()).unwrap();
+        assert_eq!(cmd, "flare orbit stop 'deepseek1017' --cluster 'sparks'");
+    }
+
+    #[test]
+    fn preflight_checks_agent_and_pairing() {
+        let cmd = preflight_cmd(&cfg());
+        assert!(cmd.contains("is-active flare-agent"), "{cmd}");
+        assert!(cmd.contains("orbit status --cluster 'sparks'"), "{cmd}");
+    }
+
+    #[test]
+    fn missing_block_is_an_error() {
+        let mut r = recipe();
+        r.flare = None;
+        assert!(download_cmd(&cfg(), &r).is_err());
+    }
+}

+ 48 - 0
src/native/mod.rs

@@ -15,6 +15,7 @@ use tracing::info;
 use crate::config::{Cluster, Config, DgppConfig, Node, Recipe};
 use crate::docker::ServeOpts;
 
+pub mod flare;
 mod provision;
 
 /// DGPP HTTP port for a recipe (its `[dgpp].http_port`, else 18080).
@@ -137,6 +138,9 @@ pub async fn native_serve(
 ) -> Result<Cluster> {
     let dry_run = opts.dry_run;
     let recipe_obj = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
+    if recipe_obj.engine == "flare" {
+        return flare_serve(config, &recipe_obj, cluster_name, node_name, opts).await;
+    }
     let dgpp = recipe_obj
         .dgpp
         .clone()
@@ -256,12 +260,56 @@ pub async fn native_serve(
 
 /// Stop a DGPP run: `dgpp-cluster down` on rank 0 (the manager stops the
 /// peers), then the caller closes the ledger.
+/// FLARE path: prepare the cluster (same SSH/HA prepare as dgpp), then launch
+/// the signed package through `flare orbit`.
+async fn flare_serve(
+    config: &Config,
+    recipe: &Recipe,
+    cluster_name: &str,
+    node_name: Option<&str>,
+    opts: &ServeOpts,
+) -> Result<Cluster> {
+    let (cluster, _nccl) = if opts.dry_run {
+        crate::orchestrate::prepare_cluster_dry_run(config, recipe, cluster_name, node_name)?
+    } else {
+        use crate::ends::{SshNodePinger, SshProber, ha_controller_from_config};
+        use crate::orchestrate::{PrepareRequest, prepare_cluster};
+        let power = opts
+            .auto_power_on
+            .then(|| ha_controller_from_config(config))
+            .flatten();
+        prepare_cluster(
+            config,
+            recipe,
+            cluster_name,
+            node_name,
+            &PrepareRequest {
+                prober: &SshProber,
+                power: power.as_ref(),
+                pinger: &SshNodePinger,
+                auto_power_on: opts.auto_power_on,
+                boot_timing: crate::orchestrate::BootTiming::default(),
+                force_occupied: false,
+            },
+        )
+        .await?
+    };
+    if recipe.flare.is_none() {
+        anyhow::bail!("engine=flare requires a [flare] block");
+    }
+    flare::serve(config, recipe, &cluster, opts.dry_run).await?;
+    Ok(cluster)
+}
+
 pub async fn native_stop(
     config: &Config,
     recipe: &Recipe,
     cluster: &Cluster,
     dry_run: bool,
 ) -> Result<()> {
+    if recipe.engine == "flare" {
+        return flare::stop(config, recipe, cluster, dry_run).await;
+    }
     let Some(d) = recipe.dgpp.as_ref() else {
         anyhow::bail!("engine=dgpp requires a [dgpp] block to stop");
     };

+ 1 - 0
src/orchestrate/tests.rs

@@ -80,6 +80,7 @@ fn test_config(clusters: Vec<Cluster>) -> Config {
         nccl.insert(c.name.clone(), test_nccl());
     }
     Config {
+        flare: None,
         ssh_user: "u".to_string(),
         ssh_key_path: "/k".into(),
         clusters,

+ 1 - 0
src/promote_tests.rs

@@ -84,6 +84,7 @@ fn config_with_repos(urls: &[String]) -> Config {
         })
         .collect();
     Config {
+        flare: None,
         ssh_user: "u".to_string(),
         ssh_key_path: "/k".into(),
         clusters: vec![],

+ 2 - 0
src/staging.rs

@@ -270,6 +270,7 @@ mod tests {
 
     fn cfg() -> Config {
         Config {
+            flare: None,
             ssh_user: "u".into(),
             ssh_key_path: "/k".into(),
             clusters: vec![],
@@ -293,6 +294,7 @@ mod tests {
 
     fn trec() -> Recipe {
         Recipe {
+            flare: None,
             build_profile: None,
             parent_model: None,
             archived: false,

+ 1 - 0
src/tmux/commands.rs

@@ -143,6 +143,7 @@ mod tests {
 
     fn trecipe() -> Recipe {
         Recipe {
+            flare: None,
             build_profile: None,
             parent_model: None,
             archived: false,

+ 1 - 0
src/web/events.rs

@@ -189,6 +189,7 @@ mod tests {
 
     fn config() -> Config {
         Config {
+            flare: None,
             ssh_user: "u".into(),
             ssh_key_path: "/k".into(),
             clusters: vec![

+ 1 - 0
src/web/tests.rs

@@ -19,6 +19,7 @@ pub(crate) async fn call(addr: SocketAddr, path: &str) -> reqwest::Response {
 /// machine-independent (no real SSH, no hardware).
 pub(crate) fn fake_config() -> Config {
     Config {
+        flare: None,
         ssh_user: "u".into(),
         ssh_key_path: "/nonexistent/key".into(),
         clusters: vec![Cluster {

+ 1 - 0
src/web/topo.rs

@@ -168,6 +168,7 @@ mod tests {
     #[test]
     fn derive_topo_fabric_dac_lan() {
         let config = Config {
+            flare: None,
             ssh_user: "u".into(),
             ssh_key_path: "/k".into(),
             nccl: HashMap::new(),