Browse Source

flare: resolve the pinned controller across clusters; prod-no4 lane config

controller_node may legitimately sit outside the launch cluster: the
prod-no4 lane serves on dgx1+dgx2+dgx3+dgx8 while the controller (dgx4,
holding the package store) is not a serving node. controller_for now
resolves the pin across every configured cluster instead of refusing;
returns an owned Node (the cluster borrow could not span clusters).

Also: the orbit-file preflight check follows the resolved controller, not
our rank 0 (a reordered orbit put the check on a node with no orbit file,
which failed every serve with 'no cluster named prod4b' - hit in
production on 2026-11-04). flare_tests split below the 500-line cap:
shared fixtures in flare_test_support.rs, orbit/controller tests in
flare_orbit_tests.rs.

config: [flare.clusters.prod-no4] -> orbit prod5 (dgx1+dgx2+dgx3+dgx8),
plus prod-no4 cluster definition, nccl profile and before_serve hooks;
4nodes-switch/prod4b + executable stays configured for after the dgx4
NVMe swap. flare-roce-poison.md updated: the reader patch rescues a warm
stand but not the first long request after a reboot (seq 93, prefill=1,
the drive stalls mid-burst) - excluding dgx4 is the production decision.
Alexander Korolev 12 hours ago
parent
commit
7d0dc801cd

+ 57 - 14
specs/tech-architecture/flare-roce-poison.md

@@ -36,8 +36,16 @@ measured, on the production package and flags.
   (4 threads, O_DIRECT 4 K, 10 s) vs ≈33 K on a healthy dgx5 — while a
   first-touch prefill demanded **≈24 K reads/s**, i.e. 80–90 % of the drive.
 - **Mitigation that works today**: the unmerged upstream io_uring reader
-  (PR #184) on top of current `main`. Same hardware, same package, same
-  prompt: 3/3 wedges before, clean run after (numbers below).
+  (PR #184) on top of current `main` — **partially**. Same hardware, same
+  package, same prompt: 3/3 wedges before, clean run after **on a warm stand**
+  (numbers below). On a **freshly rebooted** node the cold-boot wedge persists:
+  2026-11-04, 07:12 UTC boot, first long request ~9 min later died at
+  `sequence 93` with prefill frozen at **token 1** — dgx4's drive could not
+  deliver the first chunk's ≈350 MB of rows within the ≈8 s spin window even
+  with the parallel reader (its reads ran hot at ≈87 K reads/s for ~4 s and
+  then stalled mid-burst). Removing dgx4 remains the only complete fix on
+  this hardware; the reader patch is strictly beneficial but not sufficient
+  on a cold stand.
 
 ## Evidence
 
@@ -49,14 +57,17 @@ measured, on the production package and flags.
 | 2 | orbit `prod4`, rank 0 = dgx4 | packaged `flare` | **poison** @ seq 2109 (frozen ~93 K) |
 | 3 | orbit `prod4`, rank 0 = dgx4 | packaged `flare` | **poison** @ seq 1284 (frozen ~61 K) |
 | 4 | orbit `prod4b`, rank 0 = **dgx1**, dgx4 = rank 3 | packaged `flare` | **poison** @ seq 1557 (`rank 1 waiting for rank 3`) |
-| 5 | orbit `prod5` = dgx1+dgx2+dgx3+**dgx8**, no dgx4 | packaged `flare` | **clean**, 0→157,086 in ~61 s |
-| 6 | orbit `prod4b`, dgx4 = rank 3 | **main + PR #184** | **clean**, 0→157,086 in ~60 s (~2,600 t/s) |
+| 5 | orbit `prod5` = dgx1+dgx2+dgx3+**dgx8**, no dgx4 | packaged `flare` | **clean**, 0→157,086 in ~61 s (×2) |
+| 6 | orbit `prod4b`, dgx4 = rank 3 | **main + PR #184** | **clean**, 0→157,086 in ~60 s (~2,600 t/s) — warm stand |
+| 8 | orbit `prod4b` (2026-11-04, **freshly rebooted** node) | **main + PR #184** | **poison** @ seq 93, prefill frozen at **token 1** — the cold-boot case survives the reader patch |
 | 7 | orbit `sparks` = dgx5–8 (2 runs, incl. after 45 min idle) | packaged `flare` | clean |
 
 Runs 4 and 6 matter most: with dgx4 demoted from rank 0 the failure
 **followed the node, not the rank**. Run 5 isolates it further: remove dgx4
 entirely and the packaged binary is clean. The wedging pair is always
-dgx2-or-dgx1 waiting on **dgx4's** data.
+a peer waiting on **dgx4's** data. Run 8 adds the cold-boot dimension: the
+reader patch rescues a warm stand but not the first long request after a
+reboot — the drive stalls mid-burst inside the spin window at chunk 1.
 
 ### Rank 0 metrics through one wedge (1 Hz, `prod4`, layout `ESP`)
 
@@ -112,6 +123,27 @@ Result (run 6): prefill 0 → 157,086 tokens in ~60 s, no burst gaps, total
 lane (48.4 s). dgx4's KingSpec drive is still the slowest disk in the fleet;
 the pipelined reader simply keeps it under the collective's patience.
 
+**But not on a cold stand** (run 8, 2026-11-04): after a node reboot the
+first long request wedges at `sequence 93`, prefill frozen at token 1. Rank
+0's 1 Hz capture shows the first chunk's reads running hot (≈87 K reads/s,
+disk wait +2.7 s) and then the whole runtime dead within ~8 s — dgx4's drive
+stalls mid-burst inside the spin window, which the parallel reader raises in
+throughput but cannot bound in time. Operationally this means: after every
+reboot, a cold stand can lose its first long prompt even with the patch.
+The production lane therefore excludes dgx4 (`prod-no4`) until the drive is
+replaced; the `prod4b`+patch configuration is reserved for after the swap.
+
+## Production decision (2026-11-04)
+
+- Serving lane: **`prod-no4`** = dgx1+dgx2+dgx3+dgx8 (orbit `prod5`, rank 0
+  = dgx1), recipe `dsv41-flash-1017-4x-flare-gb10-nodgx4`. Clean on the same
+  prompts that killed the dgx1–4 lane, with or without the reader patch.
+- `prod4b` + `executable` stays configured for **after the NVMe swap**
+  (Samsung PM9A3/PM9E1 M.2 2242 or Corsair MP700 Micro class; back up
+  `~/.config/flare/orbit/controller.key` before the DGX OS reinstall to skip
+  re-pairing).
+- The `prod4` orbit (rank 0 = dgx4) remains in `orbit.toml` for reference.
+
 ## Upstream material
 
 ### Status in `NVIDIA/spark-local-inference` (was `flare-local-inference`)
@@ -170,24 +202,35 @@ the pipelined reader simply keeps it under the collective's patience.
 > Removing that node from the cluster (dgx1+dgx2+dgx3+dgx8) serves the same
 > prompt cleanly with the packaged binary.
 >
-> **Fix.** Current `main` + PR #184 cherry-picked (`pr184-on-main`) serves
-> the identical package and prompt: prefill 0→157 K in ~60 s, total Engram
-> disk wait 48.7 s (healthy-lane parity), no poison — 1/1 so far after 3/3
-> failures without it.
+> **Fix (partial).** Current `main` + PR #184 cherry-picked (`pr184-on-main`)
+> serves the identical package and prompt on a warm stand: prefill 0→157 K in
+> ~60 s, total Engram disk wait 48.7 s (healthy-lane parity), no poison — 1/1
+> after 3/3 failures without it. **On a freshly rebooted node the wedge
+> persists** (2026-11-04): the first long request dies at `sequence 93` with
+> prefill frozen at token 1 — the first chunk's ≈350 MB of rows must arrive
+> within the ≈8 s spin window and the slow drive stalls mid-burst (reads hot
+> at ≈87 K/s, then the runtime is dead). Excluding that node from the cluster
+> (`dgx1+dgx2+dgx3+dgx8`) is the only configuration that survived every case,
+> with or without the patch.
 >
 > **Requests.**
 > 1. Reconsider #184: its close reasoning ("the filesystem was the
 >    bottleneck") holds for Btrfs, but on ext4/GB10 the single-lane reader
->    is what makes the lane survive a disk that is merely slow.
-> 2. Make the collective spin limit configurable at serve time (or raise it
->    when disk-Engram delivery is in play) — it is currently baked into the
->    prepared package, so operators cannot trade latency for stability.
+>    is what makes a warm stand survive a disk that is merely slow.
+> 2. Make the collective spin limit configurable at serve time (or make it
+>    Engram-aware / auto-extend on the first chunks of a cold stand) — it is
+>    currently baked into the prepared package, so operators cannot trade
+>    latency for stability. The cold-boot case (run 8) dies at token 1:
+>    no reader improvement fixes a deadline that is shorter than the first
+>    chunk's cold read time.
 > 3. Expose `/metrics` on **every** rank (or at least the readiness/disk
 >    counters): the rank that waits is invisible today, which turns a disk
 >    stall into an unattributable fabric error.
 > 4. Consider a preflight/warning when a host's Engram disk falls below the
 >    package's implied read rate; a 6 W consumer 2242 at 29 K IOPS is not
->    the same class as the reference platforms.
+>    the same class as the reference platforms. A serve-time flag to shrink
+>    the prefill chunk (package-baked at 4096 tokens here) would also bound
+>    the per-chunk read volume on slow disks.
 
 ## Operational state after this investigation
 

+ 8 - 3
src/native/flare.rs

@@ -249,7 +249,7 @@ pub async fn serve(
     cluster: &crate::config::Cluster,
     dry_run: bool,
 ) -> Result<()> {
-    let controller = controller_for(config, cluster)?;
+    let controller = &controller_for(config, cluster)?;
     let orbit = orbit_cluster_for(config, &cluster.name);
     if dry_run {
         println!(
@@ -259,7 +259,12 @@ pub async fn serve(
         );
     } else {
         for node in &cluster.nodes {
-            let pre = preflight_cmd(config, &orbit, node.rank == 0);
+            // The orbit-file check belongs to the node that RUNS the CLI (the
+            // orbit's controller), not to our rank 0: a re-paired or reordered
+            // orbit puts the controller anywhere in our rank order (dgx4 is
+            // rank 3 of prod4b), and a rank-0 check fails the whole serve with
+            // "no cluster named <orbit> in the orbit file".
+            let pre = preflight_cmd(config, &orbit, node.name == controller.name);
             if let Err(e) = crate::ssh::run_ssh(config, node, pre, false).await {
                 bail!(
                     "FLARE preflight failed on {}: {e} — install `flare`, then `flare agent install` and pair the node with the controller",
@@ -388,7 +393,7 @@ pub async fn stop(
 ) -> Result<()> {
     // `orbit stop` must reach the orbit's controller, which the config may pin
     // to a node other than our rank 0.
-    let controller = controller_for(config, cluster)?;
+    let controller = &controller_for(config, cluster)?;
     let cmd = stop_cmd(config, recipe, &orbit_cluster_for(config, &cluster.name))?;
     crate::ssh::run_ssh(config, controller, cmd, dry_run).await?;
     Ok(())

+ 21 - 12
src/native/flare_orbit.rs

@@ -149,7 +149,7 @@ fn head(cluster: &crate::config::Cluster) -> Result<&Node> {
 }
 
 /// The node that runs the FLARE CLI for this cluster: `controller_node` from
-/// the config when it names one of the cluster's nodes, else rank 0.
+/// the config when it names a known node, else rank 0 of the cluster.
 ///
 /// The two are not the same thing. `controller_node` is the orbit's controller
 /// — the machine whose key the agents trust and whose disk holds the package —
@@ -159,10 +159,12 @@ fn head(cluster: &crate::config::Cluster) -> Result<&Node> {
 /// 0 without touching our cluster definition. Ignoring the pin sent
 /// `flare download`/`orbit run` to the wrong machine, which fails with
 /// "… is not an authorized controller of this node".
-pub fn controller_for<'a>(
-    config: &Config,
-    cluster: &'a crate::config::Cluster,
-) -> Result<&'a Node> {
+///
+/// The controller does NOT have to be a serving node: `prod-no4` serves on
+/// dgx1+dgx2+dgx3+dgx8 while the controller (dgx4, holding the package store)
+/// sits outside. A pinned controller is therefore resolved across every
+/// configured cluster, not just the launch one.
+pub fn controller_for(config: &Config, cluster: &crate::config::Cluster) -> Result<Node> {
     let pinned = config.flare.as_ref().and_then(|f| {
         f.clusters
             .get(&cluster.name)
@@ -170,17 +172,24 @@ pub fn controller_for<'a>(
             .or_else(|| f.controller_node.clone())
     });
     let Some(name) = pinned else {
-        return head(cluster);
+        return Ok(head(cluster)?.clone());
     };
     let bare = name.strip_suffix(".lan").unwrap_or(&name).to_string();
-    cluster
-        .nodes
-        .iter()
-        .find(|n| n.name.strip_suffix(".lan").unwrap_or(&n.name) == bare)
+    let find = |nodes: &[Node]| {
+        nodes
+            .iter()
+            .find(|n| n.name.strip_suffix(".lan").unwrap_or(&n.name) == bare)
+            .cloned()
+    };
+    find(&cluster.nodes)
+        .or_else(|| config.clusters.iter().find_map(|c| find(&c.nodes)))
         .ok_or_else(|| {
             anyhow::anyhow!(
-                "[flare] controller_node '{name}' is not a node of cluster '{}'",
-                cluster.name
+                "[flare] controller_node '{name}' is not a node of any configured cluster"
             )
         })
 }
+
+#[cfg(test)]
+#[path = "flare_orbit_tests.rs"]
+mod tests;

+ 143 - 0
src/native/flare_orbit_tests.rs

@@ -0,0 +1,143 @@
+//! Tests for `flare_orbit` — controller resolution and orbit-name mapping.
+
+use super::{controller_for, orbit_node_name};
+use crate::config::Node;
+use crate::native::flare_test_support::{cfg, prod4};
+
+#[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, "dgx58-switch", &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, "dgx58-switch", &node1), "dgx5.lan");
+}
+#[test]
+fn without_a_pin_the_controller_is_rank_zero() {
+    let c = cfg();
+    let name = controller_for(&c, &prod4()).unwrap().name.clone();
+    assert_eq!(name, "dgx1.lan");
+}
+
+#[test]
+fn a_pinned_controller_wins_over_rank_zero() {
+    // the orbit's rank 0 moved to dgx4 (2 TB disk fits store + agent cache)
+    let mut c = cfg();
+    c.flare.as_mut().unwrap().clusters.insert(
+        "4nodes-switch".into(),
+        crate::config::flare_cluster::FlareCluster {
+            controller_node: Some("dgx4.lan".into()),
+            ..Default::default()
+        },
+    );
+    assert_eq!(controller_for(&c, &prod4()).unwrap().name, "dgx4.lan");
+    // a bare name resolves too
+    c.flare
+        .as_mut()
+        .unwrap()
+        .clusters
+        .get_mut("4nodes-switch")
+        .unwrap()
+        .controller_node = Some("dgx3".into());
+    assert_eq!(controller_for(&c, &prod4()).unwrap().name, "dgx3.lan");
+}
+
+#[test]
+fn a_pin_unknown_anywhere_is_an_error() {
+    let mut c = cfg();
+    c.flare.as_mut().unwrap().clusters.insert(
+        "4nodes-switch".into(),
+        crate::config::flare_cluster::FlareCluster {
+            controller_node: Some("dgx9.lan".into()),
+            ..Default::default()
+        },
+    );
+    let err = controller_for(&c, &prod4()).unwrap_err().to_string();
+    assert!(
+        err.contains("not a node of any configured cluster"),
+        "{err}"
+    );
+}
+
+#[test]
+fn the_controller_may_sit_outside_the_launch_cluster() {
+    // prod-no4 serves on dgx1+dgx2+dgx3+dgx8 while the controller (dgx4,
+    // holding the package store) sits outside — resolve it from the other
+    // configured cluster instead of refusing
+    let mut c = cfg();
+    c.flare.as_mut().unwrap().clusters.insert(
+        "prod-no4".into(),
+        crate::config::flare_cluster::FlareCluster {
+            controller_node: Some("dgx4.lan".into()),
+            ..Default::default()
+        },
+    );
+    let mut no4 = prod4();
+    no4.name = "prod-no4".into();
+    no4.nodes.pop();
+    no4.nodes.push(Node {
+        name: "dgx8.lan".into(),
+        ssh_ip: "10.0.0.8".into(),
+        ib_ip: None,
+        rank: 3,
+        host_key: None,
+        entity: None,
+    });
+    c.clusters.push(prod4());
+    c.clusters.push(no4);
+    let cl = c.clusters.iter().find(|x| x.name == "prod-no4").unwrap();
+    let controller = controller_for(&c, cl).unwrap();
+    assert_eq!(controller.name, "dgx4.lan");
+}
+
+#[test]
+fn orbit_names_are_matched_by_node_name_not_by_rank() {
+    // The orbit's own rank order puts dgx4 first (its 2 TB disk fits the
+    // package store plus the agent cache); indexing that list by OUR rank
+    // named gx10-dgx3 for a controller that is dgx4.
+    let mut c = cfg();
+    c.flare.as_mut().unwrap().clusters.insert(
+        "4nodes-switch".into(),
+        crate::config::flare_cluster::FlareCluster {
+            orbit_nodes: vec![
+                "gx10-dgx4".into(),
+                "gx10-dgx1".into(),
+                "gx10-dgx2".into(),
+                "gx10-dgx3".into(),
+            ],
+            ..Default::default()
+        },
+    );
+    let cl = prod4();
+    let by_name = |n: &str| {
+        let node = cl.nodes.iter().find(|x| x.name == n).unwrap();
+        orbit_node_name(&c, "4nodes-switch", node)
+    };
+    assert_eq!(by_name("dgx4.lan"), "gx10-dgx4");
+    assert_eq!(by_name("dgx1.lan"), "gx10-dgx1");
+    assert_eq!(by_name("dgx3.lan"), "gx10-dgx3");
+}
+
+#[test]
+fn an_orbit_list_that_does_not_name_our_nodes_stays_positional() {
+    // legacy configs list bare orbit names; rank order is all they have
+    let mut c = cfg();
+    c.flare.as_mut().unwrap().orbit_nodes = vec![
+        "spark1".into(),
+        "spark2".into(),
+        "spark3".into(),
+        "spark4".into(),
+    ];
+    let cl = prod4();
+    let node = cl.nodes.iter().find(|x| x.rank == 2).unwrap();
+    assert_eq!(orbit_node_name(&c, "4nodes-switch", node), "spark3");
+}

+ 49 - 0
src/native/flare_test_support.rs

@@ -0,0 +1,49 @@
+//! Shared fixtures for the FLARE test modules (kept out of the 500-line
+//! budget of the test files themselves).
+
+use crate::config::FlareRecipe;
+use crate::config::{Cluster, Config, Node, Recipe};
+
+pub(crate) 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
+}
+
+pub(crate) 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
+}
+
+pub(crate) fn prod4() -> Cluster {
+    Cluster {
+        name: "4nodes-switch".into(),
+        master_ib_ip: None,
+        extra_entities: Vec::new(),
+        nodes: (1..=4)
+            .map(|i| Node {
+                name: format!("dgx{i}.lan"),
+                ssh_ip: format!("10.0.0.{i}"),
+                ib_ip: None,
+                rank: i - 1,
+                host_key: None,
+                entity: None,
+            })
+            .collect(),
+    }
+}

+ 25 - 155
src/native/flare_tests.rs

@@ -1,35 +1,9 @@
 //! Tests for the FLARE driver (`native::flare`).
 
 use super::*;
-use crate::config::{Config, Node, Recipe};
+use crate::config::Node;
 
-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
-}
+use crate::native::flare_test_support::{cfg, prod4, recipe};
 
 #[test]
 fn download_carries_revision_but_no_approval_flags() {
@@ -98,24 +72,6 @@ fn run_passes_flags_verbatim_and_the_controller_package() {
     );
 }
 
-#[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, "dgx58-switch", &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, "dgx58-switch", &node1), "dgx5.lan");
-}
-
 #[test]
 fn per_cluster_mapping_wins_over_the_flat_block() {
     // The same launcher config must serve FLARE on another cluster: the
@@ -163,6 +119,29 @@ fn preflight_checks_agent_and_pairing() {
     assert!(!preflight_cmd(&cfg(), "sparks", false).contains("orbit status"));
 }
 
+#[test]
+fn the_orbit_check_follows_the_controller_not_our_rank_zero() {
+    // a re-paired/reordered orbit pins the controller anywhere in our rank
+    // order (dgx4 is rank 3 of prod4b); the cluster check must follow the
+    // resolved controller, or the serve dies on "no cluster named …"
+    let mut c = cfg();
+    if let Some(f) = c.flare.as_mut() {
+        f.clusters.insert(
+            "4nodes-switch".into(),
+            crate::config::flare_cluster::FlareCluster {
+                controller_node: Some("dgx4.lan".into()),
+                ..Default::default()
+            },
+        );
+    }
+    let cl = prod4();
+    let controller = controller_for(&c, &cl).unwrap();
+    assert_eq!(controller.name, "dgx4.lan");
+    let rank0 = cl.nodes.iter().find(|n| n.rank == 0).unwrap();
+    assert_eq!(rank0.name, "dgx1.lan");
+    assert_ne!(rank0.name, controller.name);
+}
+
 #[test]
 fn package_path_is_taken_from_the_download_output() {
     let out = "Resolved package: org/repo@abc / pkg.safetensors\n\
@@ -322,115 +301,6 @@ fn two_candidates_are_refused() {
     assert_eq!(select_cached("/store", &v, "gb10-tp4"), None);
 }
 
-// ── which node runs the FLARE CLI ───────────────────────────────────────────
-
-use crate::config::Cluster;
-
-fn prod4() -> Cluster {
-    Cluster {
-        name: "4nodes-switch".into(),
-        master_ib_ip: None,
-        extra_entities: Vec::new(),
-        nodes: (1..=4)
-            .map(|i| Node {
-                name: format!("dgx{i}.lan"),
-                ssh_ip: format!("10.0.0.{i}"),
-                ib_ip: None,
-                rank: i - 1,
-                host_key: None,
-                entity: None,
-            })
-            .collect(),
-    }
-}
-
-#[test]
-fn without_a_pin_the_controller_is_rank_zero() {
-    let c = cfg();
-    let name = controller_for(&c, &prod4()).unwrap().name.clone();
-    assert_eq!(name, "dgx1.lan");
-}
-
-#[test]
-fn a_pinned_controller_wins_over_rank_zero() {
-    // the orbit's rank 0 moved to dgx4 (2 TB disk fits store + agent cache)
-    let mut c = cfg();
-    c.flare.as_mut().unwrap().clusters.insert(
-        "4nodes-switch".into(),
-        crate::config::flare_cluster::FlareCluster {
-            controller_node: Some("dgx4.lan".into()),
-            ..Default::default()
-        },
-    );
-    assert_eq!(controller_for(&c, &prod4()).unwrap().name, "dgx4.lan");
-    // a bare name resolves too
-    c.flare
-        .as_mut()
-        .unwrap()
-        .clusters
-        .get_mut("4nodes-switch")
-        .unwrap()
-        .controller_node = Some("dgx3".into());
-    assert_eq!(controller_for(&c, &prod4()).unwrap().name, "dgx3.lan");
-}
-
-#[test]
-fn a_pin_outside_the_cluster_is_an_error() {
-    let mut c = cfg();
-    c.flare.as_mut().unwrap().clusters.insert(
-        "4nodes-switch".into(),
-        crate::config::flare_cluster::FlareCluster {
-            controller_node: Some("dgx8.lan".into()),
-            ..Default::default()
-        },
-    );
-    let err = controller_for(&c, &prod4()).unwrap_err().to_string();
-    assert!(err.contains("not a node of cluster"), "{err}");
-}
-
-#[test]
-fn orbit_names_are_matched_by_node_name_not_by_rank() {
-    // The orbit's own rank order puts dgx4 first (its 2 TB disk fits the
-    // package store plus the agent cache); indexing that list by OUR rank
-    // named gx10-dgx3 for a controller that is dgx4.
-    let mut c = cfg();
-    c.flare.as_mut().unwrap().clusters.insert(
-        "4nodes-switch".into(),
-        crate::config::flare_cluster::FlareCluster {
-            orbit_nodes: vec![
-                "gx10-dgx4".into(),
-                "gx10-dgx1".into(),
-                "gx10-dgx2".into(),
-                "gx10-dgx3".into(),
-            ],
-            ..Default::default()
-        },
-    );
-    let cl = prod4();
-    let by_name = |n: &str| {
-        let node = cl.nodes.iter().find(|x| x.name == n).unwrap();
-        orbit_node_name(&c, "4nodes-switch", node)
-    };
-    assert_eq!(by_name("dgx4.lan"), "gx10-dgx4");
-    assert_eq!(by_name("dgx1.lan"), "gx10-dgx1");
-    assert_eq!(by_name("dgx3.lan"), "gx10-dgx3");
-}
-
-#[test]
-fn an_orbit_list_that_does_not_name_our_nodes_stays_positional() {
-    // legacy configs list bare orbit names; rank order is all they have
-    let mut c = cfg();
-    c.flare.as_mut().unwrap().orbit_nodes = vec![
-        "spark1".into(),
-        "spark2".into(),
-        "spark3".into(),
-        "spark4".into(),
-    ];
-    let cl = prod4();
-    let node = cl.nodes.iter().find(|x| x.rank == 2).unwrap();
-    assert_eq!(orbit_node_name(&c, "4nodes-switch", node), "spark3");
-}
-
 #[test]
 fn cluster_executable_override_reaches_orbit_run() {
     // a locally built, ABI-compatible runtime (the Engram disk-reader fix)

+ 3 - 0
src/native/mod.rs

@@ -20,6 +20,9 @@ pub mod flare_orbit;
 pub mod flare_package;
 mod provision;
 
+#[cfg(test)]
+pub mod flare_test_support;
+
 /// DGPP HTTP port for a recipe (its `[dgpp].http_port`, else 18080).
 pub fn http_port(recipe: &Recipe) -> u16 {
     recipe