From 76a2756fdd54f45ccca359e8994ca870b811d319 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Fri, 10 Apr 2026 13:47:49 -0500 Subject: [PATCH 01/20] refactor: centralize network.json and ShapedDevices access Introduce lqos_network_devices actor + dir watcher and route lqosd + tools through public APIs. --- src/rust/Cargo.lock | 45 ++ src/rust/Cargo.toml | 2 + src/rust/lqos_bus/src/bus/response.rs | 2 +- src/rust/lqos_network_devices/Cargo.toml | 18 + src/rust/lqos_network_devices/README.md | 50 ++ src/rust/lqos_network_devices/src/actor.rs | 186 ++++++++ .../src/directory_watcher.rs | 107 +++++ .../lqos_network_devices/src/hash_cache.rs | 64 +++ src/rust/lqos_network_devices/src/lib.rs | 99 ++++ src/rust/lqos_network_devices/src/state.rs | 50 ++ src/rust/lqos_python/Cargo.toml | 1 + src/rust/lqos_python/src/device_weights.rs | 8 +- src/rust/lqos_python/src/lib.rs | 2 +- src/rust/lqos_stormguard/Cargo.toml | 1 + .../lqos_stormguard/src/adaptive_actions.rs | 6 +- src/rust/lqos_support_tool/Cargo.toml | 1 + .../src/sanity_checks/net_json.rs | 2 +- .../src/sanity_checks/shaped_devices.rs | 6 +- src/rust/lqos_utils/src/directory_watcher.rs | 135 ++++++ src/rust/lqos_utils/src/lib.rs | 3 + src/rust/lqosd/Cargo.toml | 1 + .../lqosd/src/lts2_sys/control_channel.rs | 60 +-- src/rust/lqosd/src/main.rs | 124 ++--- src/rust/lqosd/src/network_devices_hooks.rs | 23 + .../src/node_manager/local_api/circuit.rs | 4 +- .../local_api/circuit_activity.rs | 9 +- .../node_manager/local_api/circuit_count.rs | 3 +- .../src/node_manager/local_api/config.rs | 20 +- .../node_manager/local_api/cpu_affinity.rs | 3 +- .../node_manager/local_api/device_counts.rs | 3 +- .../src/node_manager/local_api/directories.rs | 5 +- .../node_manager/local_api/ethernet_caps.rs | 3 +- .../node_manager/local_api/executive_cache.rs | 199 ++++---- .../node_manager/local_api/flow_explorer.rs | 15 +- .../src/node_manager/local_api/search.rs | 42 +- .../local_api/shaped_device_api.rs | 3 +- .../local_api/shaped_devices_page.rs | 3 +- .../local_api/tree_attached_circuits.rs | 76 +-- src/rust/lqosd/src/node_manager/template.rs | 3 +- .../ws/single_user_channels/circuit.rs | 15 +- .../src/node_manager/ws/ticker/bakery.rs | 12 +- .../ws/ticker/circuit_capacity.rs | 3 +- .../ws/ticker/ipstats_conversion.rs | 3 +- .../node_manager/ws/ticker/tree_capacity.rs | 68 +-- .../node_manager/ws/ticker/tree_summary_l2.rs | 55 +-- .../shaped_devices_tracker/circuit_live.rs | 5 +- .../lqosd/src/shaped_devices_tracker/mod.rs | 449 ++++++------------ .../src/shaped_devices_tracker/netjson.rs | 53 --- .../flow_data/flow_analysis/finished_flows.rs | 4 +- src/rust/lqosd/src/throughput_tracker/mod.rs | 163 +++---- .../throughput_tracker/stats_submission.rs | 9 +- .../src/throughput_tracker/tracking_data.rs | 15 +- src/rust/lqosd/src/treeguard/actor.rs | 61 +-- src/rust/lqosd/src/validation.rs | 3 +- 54 files changed, 1444 insertions(+), 861 deletions(-) create mode 100644 src/rust/lqos_network_devices/Cargo.toml create mode 100644 src/rust/lqos_network_devices/README.md create mode 100644 src/rust/lqos_network_devices/src/actor.rs create mode 100644 src/rust/lqos_network_devices/src/directory_watcher.rs create mode 100644 src/rust/lqos_network_devices/src/hash_cache.rs create mode 100644 src/rust/lqos_network_devices/src/lib.rs create mode 100644 src/rust/lqos_network_devices/src/state.rs create mode 100644 src/rust/lqos_utils/src/directory_watcher.rs create mode 100644 src/rust/lqosd/src/network_devices_hooks.rs delete mode 100644 src/rust/lqosd/src/shaped_devices_tracker/netjson.rs diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index e8c923bd..98d0433d 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -607,6 +607,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.7" @@ -2099,6 +2109,22 @@ dependencies = [ "lqos_sys", ] +[[package]] +name = "lqos_network_devices" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "crossbeam-channel", + "fxhash", + "lqos_config", + "lqos_utils", + "once_cell", + "parking_lot", + "tokio", + "tracing", +] + [[package]] name = "lqos_overrides" version = "0.1.0" @@ -2135,6 +2161,7 @@ dependencies = [ "lqos_bakery", "lqos_bus", "lqos_config", + "lqos_network_devices", "lqos_overrides", "lqos_sys", "lqos_utils", @@ -2199,6 +2226,7 @@ dependencies = [ "lqos_bakery", "lqos_bus", "lqos_config", + "lqos_network_devices", "lqos_overrides", "lqos_probe", "lqos_queue_tracker", @@ -2208,6 +2236,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "lqos_support_tool" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "colored", + "lqos_config", + "lqos_network_devices", + "miniz_oxide", + "nix", + "serde", + "serde_cbor", + "serde_json", +] + [[package]] name = "lqos_sys" version = "0.1.0" @@ -2291,6 +2335,7 @@ dependencies = [ "lqos_bus", "lqos_config", "lqos_heimdall", + "lqos_network_devices", "lqos_overrides", "lqos_probe", "lqos_queue_tracker", diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 03389574..06d33998 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -17,6 +17,7 @@ codegen-units = 16 members = [ "lqos_sys", # System support for handling the XDP component "lqos_config", # Configuration support + "lqos_network_devices", # Shared network.json + ShapedDevices.csv runtime access layer "lqosd", # LibreQoS Daemon "lqos_bus", # Bus data types "lqtop", # A command line utility to show current activity @@ -35,6 +36,7 @@ members = [ "lqos_bakery", # The bakery makes CAKEs - controls queue creation. "lqos_overrides", # A CLI tool and library for unifying the override system and allowing API support for changing network.json and ShapedDevices.csv "lqos_topology", # Shared topology runtime domain logic and health/effective-state helper. + "lqos_support_tool", # Support/sanity-check CLI for gathering and submitting support dumps. ] [dependencies] diff --git a/src/rust/lqos_bus/src/bus/response.rs b/src/rust/lqos_bus/src/bus/response.rs index 915885dd..b3c050f7 100644 --- a/src/rust/lqos_bus/src/bus/response.rs +++ b/src/rust/lqos_bus/src/bus/response.rs @@ -147,7 +147,7 @@ pub struct AsnHeatmapData { /// Metrics for the Executive Summary header cards. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Allocative, Default)] pub struct ExecutiveSummaryHeader { - /// Total number of unique circuits from SHAPED_DEVICES. + /// Total number of unique circuits from the shaped-devices snapshot. pub circuit_count: u64, /// Total number of shaped devices. pub device_count: u64, diff --git a/src/rust/lqos_network_devices/Cargo.toml b/src/rust/lqos_network_devices/Cargo.toml new file mode 100644 index 00000000..c3ae59dc --- /dev/null +++ b/src/rust/lqos_network_devices/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lqos_network_devices" +version = "0.1.0" +edition = "2024" +license = "GPL-2.0-only" + +[dependencies] +anyhow = { workspace = true } +arc-swap = { workspace = true } +crossbeam-channel = { workspace = true } +fxhash = "0.2.1" +lqos_config = { path = "../lqos_config" } +lqos_utils = { path = "../lqos_utils" } +once_cell = { workspace = true } +parking_lot = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + diff --git a/src/rust/lqos_network_devices/README.md b/src/rust/lqos_network_devices/README.md new file mode 100644 index 00000000..84929567 --- /dev/null +++ b/src/rust/lqos_network_devices/README.md @@ -0,0 +1,50 @@ +# `lqos_network_devices` + +Centralized, shared access to LibreQoS topology inputs: + +- `ShapedDevices.csv` (and `ShapedDevices.insight.csv`) +- `network.json` (and `network.insight.json` / `network.effective.json`) + +## Runtime model + +In daemon mode, this crate starts: + +- A single-thread actor that owns reload/apply commands. +- A config-directory watcher that coalesces filesystem events and requests reloads. + +Callers access state through public accessor functions; the underlying channel sender is kept in a +private static. + +## Public API (high level) + +- `start_daemon_mode(hooks)`: starts actor + directory watcher. +- `shaped_devices_snapshot()`: `Arc` snapshot. +- `shaped_device_hash_cache_snapshot()`: hash→index cache snapshot (device/circuit hash lookups). +- `with_network_json_read(|net_json| ...)`: read-only access to in-memory `NetworkJson`. +- `with_network_json_write(|net_json| ...)`: mutable access to in-memory `NetworkJson` (used by runtime counters). +- `request_reload_shaped_devices(reason)` / `request_reload_network_json(reason)`: ask actor to reload from disk. +- `apply_shaped_devices_snapshot(reason, shaped)`: publish a caller-provided shaped-devices snapshot via actor. +- `swap_shaped_devices_snapshot(reason, shaped_arc)`: replace shaped-devices snapshot without actor (intended for tests). + +## Notes for `lqosd` + +`lqosd` starts daemon mode and provides hooks to invalidate derived snapshots/caches on updates. +Runtime throughput tracking mutates the in-memory `NetworkJson` via `with_network_json_write`. + +## Access & update catalog + +Current (2026-04) call sites and update patterns: + +- Daemon startup: `lqosd` calls `start_daemon_mode()` once at boot and provides `DaemonHooks`. +- Shaped-device updates: + - Directory watcher requests reload when `ShapedDevices.csv` or `ShapedDevices.insight.csv` changes. + - Node manager admin edits write `ShapedDevices.csv` and immediately publish the new snapshot via + `apply_shaped_devices_snapshot()`. +- Network topology updates: + - Directory watcher requests reload when `network.json`, `network.insight.json`, or + `network.effective.json` changes. + - Node manager admin edits write `network.json` and request reload via `request_reload_network_json()`. +- Hot-path reads (in-memory snapshots): node manager REST endpoints, throughput tracking, Insight/LTS2 + control-channel snapshotting, and shaped-device/circuit views. +- One-shot disk loads: tools/sidecars (e.g. `lqos_python`, `lqos_stormguard`, support tooling) should + use `load_shaped_devices()` / `load_network_json()` rather than reaching into config modules directly. diff --git a/src/rust/lqos_network_devices/src/actor.rs b/src/rust/lqos_network_devices/src/actor.rs new file mode 100644 index 00000000..396e0028 --- /dev/null +++ b/src/rust/lqos_network_devices/src/actor.rs @@ -0,0 +1,186 @@ +use crate::state; +use crate::{DaemonHooks, load_network_json, load_shaped_devices}; +use anyhow::{Result, anyhow}; +use crossbeam_channel::{Receiver, Sender}; +use once_cell::sync::OnceCell; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; +use tracing::{debug, error, warn}; + +const RELOAD_RETRY_DELAY_MS: u64 = 500; +const RELOAD_ATTEMPTS: usize = 2; + +static ACTOR_SENDER: OnceCell> = OnceCell::new(); + +pub(crate) fn start_actor(hooks: Option>) -> Result<()> { + if ACTOR_SENDER.get().is_some() { + return Ok(()); + } + + let (tx, rx) = crossbeam_channel::bounded::(64); + let _ = ACTOR_SENDER.set(tx); + + std::thread::Builder::new() + .name("lqos_network_devices".to_string()) + .spawn(move || actor_loop(rx, hooks))?; + + Ok(()) +} + +fn sender() -> Result> { + ACTOR_SENDER + .get() + .cloned() + .ok_or_else(|| anyhow!("lqos_network_devices runtime actor is not running")) +} + +pub(crate) fn request_reload_shaped_devices(reason: &str) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::ReloadShapedDevices { + reason: reason.to_string(), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("ShapedDevices reload reply channel closed"))? +} + +pub(crate) fn request_reload_network_json(reason: &str) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::ReloadNetworkJson { + reason: reason.to_string(), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("NetworkJson reload reply channel closed"))? +} + +pub(crate) fn apply_shaped_devices_snapshot( + reason: &str, + shaped: lqos_config::ConfigShapedDevices, +) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::ApplyShapedDevicesSnapshot { + reason: reason.to_string(), + shaped: Box::new(shaped), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("Apply shaped devices reply channel closed"))? +} + +enum NetworkDevicesCommand { + ReloadShapedDevices { + reason: String, + reply: oneshot::Sender>, + }, + ReloadNetworkJson { + reason: String, + reply: oneshot::Sender>, + }, + ApplyShapedDevicesSnapshot { + reason: String, + shaped: Box, + reply: oneshot::Sender>, + }, +} + +fn actor_loop(rx: Receiver, hooks: Option>) { + debug!("lqos_network_devices actor starting"); + + if let Err(err) = reload_shaped_devices_inner("startup", hooks.as_deref()) { + warn!("Initial shaped-devices load failed: {err}"); + } + if let Err(err) = reload_network_json_inner("startup", hooks.as_deref()) { + warn!("Initial network-json load failed: {err}"); + } + + while let Ok(command) = rx.recv() { + match command { + NetworkDevicesCommand::ReloadShapedDevices { reason, reply } => { + let result = reload_shaped_devices_inner(&reason, hooks.as_deref()); + let _ = reply.send(result); + } + NetworkDevicesCommand::ReloadNetworkJson { reason, reply } => { + let result = reload_network_json_inner(&reason, hooks.as_deref()); + let _ = reply.send(result); + } + NetworkDevicesCommand::ApplyShapedDevicesSnapshot { reason, shaped, reply } => { + debug!("Publishing shaped-devices snapshot reason={reason}"); + state::publish_shaped_devices(*shaped); + if let Some(hooks) = &hooks { + hooks.on_shaped_devices_updated(); + } + let _ = reply.send(Ok(())); + } + } + } + + error!("lqos_network_devices actor stopped"); +} + +fn reload_shaped_devices_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> { + for attempt in 1..=RELOAD_ATTEMPTS { + match load_shaped_devices() { + Ok(shaped) => { + debug!("Loaded shaped devices reason={reason}"); + state::publish_shaped_devices(shaped); + if let Some(hooks) = hooks { + hooks.on_shaped_devices_updated(); + } + return Ok(()); + } + Err(err) if attempt < RELOAD_ATTEMPTS => { + warn!( + "ShapedDevices reload reason={reason} attempt {attempt}/{} failed: {err}. Retrying after {} ms.", + RELOAD_ATTEMPTS, + RELOAD_RETRY_DELAY_MS + ); + std::thread::sleep(Duration::from_millis(RELOAD_RETRY_DELAY_MS)); + } + Err(err) => { + warn!( + "ShapedDevices reload reason={reason} failed after {} attempts: {err}. Keeping last-known-good snapshot with {} devices.", + RELOAD_ATTEMPTS, + state::shaped_devices_snapshot().devices.len() + ); + return Err(err); + } + } + } + unreachable!("reload loop must return"); +} + +fn reload_network_json_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> { + for attempt in 1..=RELOAD_ATTEMPTS { + match load_network_json() { + Ok(net_json) => { + debug!("Loaded network json reason={reason}"); + state::publish_network_json(net_json); + if let Some(hooks) = hooks { + hooks.on_network_json_updated(); + } + return Ok(()); + } + Err(err) if attempt < RELOAD_ATTEMPTS => { + warn!( + "NetworkJson reload reason={reason} attempt {attempt}/{} failed: {err}. Retrying after {} ms.", + RELOAD_ATTEMPTS, + RELOAD_RETRY_DELAY_MS + ); + std::thread::sleep(Duration::from_millis(RELOAD_RETRY_DELAY_MS)); + } + Err(err) => { + warn!( + "NetworkJson reload reason={reason} failed after {} attempts: {err}. Keeping last-known-good snapshot.", + RELOAD_ATTEMPTS + ); + return Err(err); + } + } + } + unreachable!("reload loop must return"); +} diff --git a/src/rust/lqos_network_devices/src/directory_watcher.rs b/src/rust/lqos_network_devices/src/directory_watcher.rs new file mode 100644 index 00000000..d41f85ee --- /dev/null +++ b/src/rust/lqos_network_devices/src/directory_watcher.rs @@ -0,0 +1,107 @@ +use crate::{request_reload_network_json, request_reload_shaped_devices}; +use anyhow::Result; +use lqos_utils::directory_watcher::{DirectoryWatcher, WatchedDirectoryError}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use tracing::{debug, error, warn}; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum DirectoryReloadEvent { + ShapedDevices, + NetworkJson, +} + +pub(crate) fn start_network_devices_directory_watch() -> Result<()> { + std::thread::Builder::new() + .name("Network Devices Dir Watcher".to_string()) + .spawn(|| { + let mut watcher = match NetworkDevicesDirectoryWatcher::new() { + Ok(watcher) => watcher, + Err(err) => { + error!("Failed to start Network Devices directory watcher: {err}"); + return; + } + }; + debug!("Watching LibreQoS config directory for network/shaped updates"); + loop { + match watcher.watch_relevant() { + Ok(events) => { + for event in events { + if let Err(err) = dispatch_reload(event) { + warn!("Network Devices directory reload dispatch failed: {err}"); + } + } + } + Err(err) => { + warn!("Network Devices directory watcher returned: {err}"); + } + } + } + })?; + Ok(()) +} + +struct NetworkDevicesDirectoryWatcher { + inner: DirectoryWatcher, +} + +impl NetworkDevicesDirectoryWatcher { + fn new() -> Result { + let config = lqos_config::load_config()?; + let path = PathBuf::from(&config.lqos_directory); + Ok(Self { + inner: DirectoryWatcher::new("Network Devices Directory", path), + }) + } + + fn watch(&mut self) -> Result, WatchedDirectoryError> { + self.inner.watch() + } + + fn watch_relevant(&mut self) -> Result, WatchedDirectoryError> { + loop { + let events = classify_changed_paths(&self.watch()?); + if !events.is_empty() { + return Ok(events); + } + } + } +} + +fn classify_changed_paths(paths: &[PathBuf]) -> Vec { + paths.iter() + .filter_map(|path| classify_changed_path(path)) + .collect::>() + .into_iter() + .collect() +} + +fn classify_changed_path(path: &Path) -> Option { + let file_name = path.file_name()?.to_str()?; + if is_ignored_filename(file_name) { + return None; + } + + match file_name { + "ShapedDevices.csv" | "ShapedDevices.insight.csv" => Some(DirectoryReloadEvent::ShapedDevices), + "network.effective.json" | "network.insight.json" | "network.json" => { + Some(DirectoryReloadEvent::NetworkJson) + } + _ => None, + } +} + +fn is_ignored_filename(file_name: &str) -> bool { + file_name.ends_with(".lock") + || file_name.ends_with(".tmp") + || file_name.ends_with(".swp") + || file_name.ends_with('~') +} + +fn dispatch_reload(event: DirectoryReloadEvent) -> Result<()> { + match event { + DirectoryReloadEvent::ShapedDevices => request_reload_shaped_devices("dirwatch:ShapedDevices.csv"), + DirectoryReloadEvent::NetworkJson => request_reload_network_json("dirwatch:network.json"), + } +} + diff --git a/src/rust/lqos_network_devices/src/hash_cache.rs b/src/rust/lqos_network_devices/src/hash_cache.rs new file mode 100644 index 00000000..066dc4d2 --- /dev/null +++ b/src/rust/lqos_network_devices/src/hash_cache.rs @@ -0,0 +1,64 @@ +use fxhash::FxHashMap; +use lqos_config::ShapedDevice; + +#[derive(Debug, Default)] +pub struct ShapedDeviceHashCache { + by_device_hash: FxHashMap, + by_circuit_hash: FxHashMap, +} + +impl ShapedDeviceHashCache { + pub fn from_devices(devices: &[ShapedDevice]) -> Self { + let mut by_device_hash = FxHashMap::default(); + by_device_hash.reserve(devices.len()); + let mut by_circuit_hash = FxHashMap::default(); + by_circuit_hash.reserve(devices.len()); + for (idx, dev) in devices.iter().enumerate() { + by_device_hash.insert(dev.device_hash, idx); + by_circuit_hash.entry(dev.circuit_hash).or_insert(idx); + } + Self { + by_device_hash, + by_circuit_hash, + } + } + + pub fn index_by_device_hash( + &self, + shaped: &lqos_config::ConfigShapedDevices, + device_hash: i64, + ) -> Option { + if let Some(idx) = self.by_device_hash.get(&device_hash).copied() + && shaped + .devices + .get(idx) + .is_some_and(|d| d.device_hash == device_hash) + { + return Some(idx); + } + shaped + .devices + .iter() + .position(|d| d.device_hash == device_hash) + } + + pub fn index_by_circuit_hash( + &self, + shaped: &lqos_config::ConfigShapedDevices, + circuit_hash: i64, + ) -> Option { + if let Some(idx) = self.by_circuit_hash.get(&circuit_hash).copied() + && shaped + .devices + .get(idx) + .is_some_and(|d| d.circuit_hash == circuit_hash) + { + return Some(idx); + } + shaped + .devices + .iter() + .position(|d| d.circuit_hash == circuit_hash) + } +} + diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs new file mode 100644 index 00000000..d9c127a0 --- /dev/null +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -0,0 +1,99 @@ +//! Shared, centralized access to `network.json` and `ShapedDevices.csv`. +//! +//! This crate provides: +//! - Disk-load helpers used by one-shot tools. +//! - An optional runtime actor + directory watcher used by `lqosd` to keep +//! in-memory snapshots up to date. + +mod actor; +mod directory_watcher; +mod hash_cache; +mod state; + +use anyhow::{Context, Result}; +use lqos_config::{ConfigShapedDevices, NetworkJson}; +use std::sync::Arc; +use tracing::debug; + +pub use hash_cache::ShapedDeviceHashCache; + +/// Hooks that `lqosd` can provide to run side effects after updates. +pub trait DaemonHooks: Send + Sync + 'static { + /// Called after a new `ShapedDevices.csv` snapshot is published. + fn on_shaped_devices_updated(&self); + /// Called after a new `network.json` snapshot is published. + fn on_network_json_updated(&self); +} + +/// Starts the runtime actor and directory watcher used by long-running daemons. +/// +/// This function has side effects: it spawns background threads and installs global state. +pub fn start_daemon_mode(hooks: Option>) -> Result<()> { + actor::start_actor(hooks)?; + directory_watcher::start_network_devices_directory_watch() +} + +/// Loads `ShapedDevices.csv` from disk. +pub fn load_shaped_devices() -> Result { + ConfigShapedDevices::load().context("Unable to load ShapedDevices.csv") +} + +/// Loads `network.json` from disk. +pub fn load_network_json() -> Result { + NetworkJson::load().context("Unable to load network.json") +} + +/// Returns the current in-memory `ShapedDevices.csv` snapshot. +/// +/// Note: If the runtime actor has not been started, this returns an empty default snapshot. +pub fn shaped_devices_snapshot() -> Arc { + state::shaped_devices_snapshot() +} + +/// Returns the current in-memory shaped-device hash cache snapshot. +pub fn shaped_device_hash_cache_snapshot() -> Arc { + state::shaped_device_hash_cache_snapshot() +} + +/// Run a closure with a read-only view of the current in-memory `NetworkJson`. +pub fn with_network_json_read(f: impl FnOnce(&NetworkJson) -> R) -> R { + state::with_network_json_read(f) +} + +/// Run a closure with mutable access to the in-memory `NetworkJson`. +/// +/// Side effects: this mutates the in-memory snapshot used by `lqosd`. +pub fn with_network_json_write(f: impl FnOnce(&mut NetworkJson) -> R) -> R { + state::with_network_json_write(f) +} + +/// Requests the runtime actor reload `ShapedDevices.csv` from disk. +pub fn request_reload_shaped_devices(reason: &str) -> Result<()> { + actor::request_reload_shaped_devices(reason) +} + +/// Requests the runtime actor reload `network.json` from disk. +pub fn request_reload_network_json(reason: &str) -> Result<()> { + actor::request_reload_network_json(reason) +} + +/// Publishes a caller-provided shaped-devices snapshot through the runtime actor. +pub fn apply_shaped_devices_snapshot(reason: &str, shaped: ConfigShapedDevices) -> Result<()> { + actor::apply_shaped_devices_snapshot(reason, shaped) +} + +/// Replaces the in-memory `ShapedDevices.csv` snapshot without involving the runtime actor. +/// +/// Side effects: +/// - Updates the shaped-device hash cache snapshot. +/// - Does **not** write to disk. +/// - Does **not** invoke any daemon hooks. +/// +/// This is primarily intended for tests and for callers that already hold an `Arc` snapshot. +pub fn swap_shaped_devices_snapshot( + reason: &str, + shaped: Arc, +) -> Arc { + debug!("Swapping shaped-devices snapshot reason={reason}"); + state::swap_shaped_devices_snapshot(shaped) +} diff --git a/src/rust/lqos_network_devices/src/state.rs b/src/rust/lqos_network_devices/src/state.rs new file mode 100644 index 00000000..cc365eab --- /dev/null +++ b/src/rust/lqos_network_devices/src/state.rs @@ -0,0 +1,50 @@ +use crate::hash_cache::ShapedDeviceHashCache; +use arc_swap::ArcSwap; +use lqos_config::{ConfigShapedDevices, NetworkJson}; +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use std::sync::Arc; + +static SHAPED_DEVICES: Lazy> = + Lazy::new(|| ArcSwap::new(Arc::new(ConfigShapedDevices::default()))); +static SHAPED_DEVICE_HASH_CACHE: Lazy> = + Lazy::new(|| ArcSwap::new(Arc::new(ShapedDeviceHashCache::default()))); +static NETWORK_JSON: Lazy> = Lazy::new(|| RwLock::new(NetworkJson::default())); + +pub(crate) fn shaped_devices_snapshot() -> Arc { + SHAPED_DEVICES.load_full() +} + +pub(crate) fn shaped_device_hash_cache_snapshot() -> Arc { + SHAPED_DEVICE_HASH_CACHE.load_full() +} + +pub(crate) fn with_network_json_read(f: impl FnOnce(&NetworkJson) -> R) -> R { + let reader = NETWORK_JSON.read(); + f(&reader) +} + +pub(crate) fn with_network_json_write(f: impl FnOnce(&mut NetworkJson) -> R) -> R { + let mut writer = NETWORK_JSON.write(); + f(&mut writer) +} + +pub(crate) fn publish_shaped_devices(new_file: ConfigShapedDevices) { + let cache = ShapedDeviceHashCache::from_devices(&new_file.devices); + SHAPED_DEVICES.store(Arc::new(new_file)); + SHAPED_DEVICE_HASH_CACHE.store(Arc::new(cache)); +} + +pub(crate) fn swap_shaped_devices_snapshot( + new_snapshot: Arc, +) -> Arc { + let cache = ShapedDeviceHashCache::from_devices(&new_snapshot.devices); + let old = SHAPED_DEVICES.swap(new_snapshot); + SHAPED_DEVICE_HASH_CACHE.store(Arc::new(cache)); + old +} + +pub(crate) fn publish_network_json(new_file: NetworkJson) { + let mut writer = NETWORK_JSON.write(); + *writer = new_file; +} diff --git a/src/rust/lqos_python/Cargo.toml b/src/rust/lqos_python/Cargo.toml index 7ffde011..6b85823e 100644 --- a/src/rust/lqos_python/Cargo.toml +++ b/src/rust/lqos_python/Cargo.toml @@ -15,6 +15,7 @@ lqos_bus = { path = "../lqos_bus" } lqos_sys = { path = "../lqos_sys" } lqos_utils = { path = "../lqos_utils" } lqos_config = { path = "../lqos_config" } +lqos_network_devices = { path = "../lqos_network_devices" } lqos_overrides = { path = "../lqos_overrides" } tokio = { workspace = true } anyhow = { workspace = true } diff --git a/src/rust/lqos_python/src/device_weights.rs b/src/rust/lqos_python/src/device_weights.rs index 4650f485..3f0f0183 100644 --- a/src/rust/lqos_python/src/device_weights.rs +++ b/src/rust/lqos_python/src/device_weights.rs @@ -13,7 +13,7 @@ use anyhow::Result; use lqos_bus::{BusRequest, BusResponse}; -use lqos_config::{ConfigShapedDevices, ShapedDevice, load_config}; +use lqos_config::{ShapedDevice, load_config}; use pyo3::pyclass; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -84,7 +84,7 @@ fn get_weights_from_lts( /// This function is used to get the device weights from the ShapedDevices.csv file fn get_weights_from_shaped_devices() -> Result> { - let mut devices_list = ConfigShapedDevices::load()?.devices; + let mut devices_list = lqos_network_devices::load_shaped_devices()?.devices; devices_list.sort_by(|a, b| a.circuit_id.cmp(&b.circuit_id)); let mut result = vec![]; let mut prev_id = String::new(); @@ -211,9 +211,9 @@ pub struct NetworkNodeWeight { /// Calculate the top-level network tree nodes and then /// calculate the weights for each node pub(crate) fn calculate_tree_weights() -> Result> { - let device_list = ConfigShapedDevices::load()?.devices; + let device_list = lqos_network_devices::load_shaped_devices()?.devices; let device_weights = get_weights_rust()?; - let network = lqos_config::NetworkJson::load()?; + let network = lqos_network_devices::load_network_json()?; let root_index = network .get_nodes_when_ready() .iter() diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index c27bd88e..da42c557 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -2541,7 +2541,7 @@ pub fn get_libreqos_directory() -> PyResult { #[pyfunction] /// Returns `true` when the loaded network graph contains only a single root node. pub fn is_network_flat() -> PyResult { - Ok(lqos_config::NetworkJson::load() + Ok(lqos_network_devices::load_network_json() .unwrap() .get_nodes_when_ready() .len() diff --git a/src/rust/lqos_stormguard/Cargo.toml b/src/rust/lqos_stormguard/Cargo.toml index 6713c7e9..14f9ebbe 100644 --- a/src/rust/lqos_stormguard/Cargo.toml +++ b/src/rust/lqos_stormguard/Cargo.toml @@ -11,6 +11,7 @@ lqos_bus = { path = "../lqos_bus" } tokio.workspace = true lqos_queue_tracker = { path = "../lqos_queue_tracker" } lqos_utils = { path = "../lqos_utils" } +lqos_network_devices = { path = "../lqos_network_devices" } lqos_bakery = { path = "../lqos_bakery" } lqos_overrides = { path = "../lqos_overrides" } lqos_probe = { path = "../lqos_probe" } diff --git a/src/rust/lqos_stormguard/src/adaptive_actions.rs b/src/rust/lqos_stormguard/src/adaptive_actions.rs index b50ba262..21ab8125 100644 --- a/src/rust/lqos_stormguard/src/adaptive_actions.rs +++ b/src/rust/lqos_stormguard/src/adaptive_actions.rs @@ -1,6 +1,6 @@ use anyhow::{Result, anyhow}; use lqos_bakery::BakeryCommands; -use lqos_config::{ConfigShapedDevices, ShapedDevice}; +use lqos_config::ShapedDevice; use lqos_overrides::{OverrideFile, OverrideLayer, OverrideStore}; use lqos_queue_tracker::QUEUE_STRUCTURE; use lqos_utils::hash_to_i64; @@ -136,7 +136,7 @@ pub fn clear_circuit_fallback( pub fn load_persisted_circuit_fallbacks() -> Result> { let overrides = OverrideStore::load_layer(OverrideLayer::Stormguard)?; - let current_devices = ConfigShapedDevices::load()?.devices; + let current_devices = lqos_network_devices::load_shaped_devices()?.devices; Ok(group_circuit_fallbacks(&overrides, ¤t_devices)) } @@ -159,7 +159,7 @@ fn current_site_override( } fn load_devices_for_circuit(circuit_id: &str) -> Result> { - let shaped_devices = ConfigShapedDevices::load()?; + let shaped_devices = lqos_network_devices::load_shaped_devices()?; Ok(shaped_devices .devices .into_iter() diff --git a/src/rust/lqos_support_tool/Cargo.toml b/src/rust/lqos_support_tool/Cargo.toml index 4ba95f9b..1c9d6116 100644 --- a/src/rust/lqos_support_tool/Cargo.toml +++ b/src/rust/lqos_support_tool/Cargo.toml @@ -8,6 +8,7 @@ anyhow = { workspace = true } clap = { workspace = true } colored = { workspace = true } lqos_config = { path = "../lqos_config" } +lqos_network_devices = { path = "../lqos_network_devices" } serde = { workspace = true } serde_cbor = { workspace = true } miniz_oxide = { workspace = true } diff --git a/src/rust/lqos_support_tool/src/sanity_checks/net_json.rs b/src/rust/lqos_support_tool/src/sanity_checks/net_json.rs index 5d92a215..b10372ad 100644 --- a/src/rust/lqos_support_tool/src/sanity_checks/net_json.rs +++ b/src/rust/lqos_support_tool/src/sanity_checks/net_json.rs @@ -47,7 +47,7 @@ pub fn can_we_load_net_json(results: &mut Vec) { } pub fn can_we_parse_net_json(results: &mut Vec) { - match lqos_config::NetworkJson::load() { + match lqos_network_devices::load_network_json() { Ok(_json) => { results.push(SanityCheck { name: "network.json is valid JSON".to_string(), diff --git a/src/rust/lqos_support_tool/src/sanity_checks/shaped_devices.rs b/src/rust/lqos_support_tool/src/sanity_checks/shaped_devices.rs index 5adada5f..6bfe3327 100644 --- a/src/rust/lqos_support_tool/src/sanity_checks/shaped_devices.rs +++ b/src/rust/lqos_support_tool/src/sanity_checks/shaped_devices.rs @@ -19,7 +19,7 @@ pub fn shaped_devices_exists(results: &mut Vec) { } pub fn can_we_read_shaped_devices(results: &mut Vec) { - match lqos_config::ConfigShapedDevices::load() { + match lqos_network_devices::load_shaped_devices() { Ok(sd) => { results.push(SanityCheck { name: "ShapedDevices.csv Loads?".to_string(), @@ -38,7 +38,7 @@ pub fn can_we_read_shaped_devices(results: &mut Vec) { } pub fn parent_check(results: &mut Vec) { - if let Ok(net_json) = lqos_config::NetworkJson::load() { + if let Ok(net_json) = lqos_network_devices::load_network_json() { if net_json.get_nodes_when_ready().len() < 2 { results.push(SanityCheck { name: "Flat Network - Skipping Parent Check".to_string(), @@ -48,7 +48,7 @@ pub fn parent_check(results: &mut Vec) { return; } - if let Ok(shaped_devices) = lqos_config::ConfigShapedDevices::load() { + if let Ok(shaped_devices) = lqos_network_devices::load_shaped_devices() { for sd in shaped_devices.devices.iter() { if !net_json .get_nodes_when_ready() diff --git a/src/rust/lqos_utils/src/directory_watcher.rs b/src/rust/lqos_utils/src/directory_watcher.rs new file mode 100644 index 00000000..018893a3 --- /dev/null +++ b/src/rust/lqos_utils/src/directory_watcher.rs @@ -0,0 +1,135 @@ +use notify::{Config, Event, RecursiveMode, Watcher}; +use std::{ + collections::BTreeSet, + path::PathBuf, + time::{Duration, Instant}, +}; +use thiserror::Error; +use tracing::{debug, error}; + +const DIRECTORY_WATCH_DEBOUNCE_MILLIS: u64 = 250; + +/// Watches one directory and returns the coalesced changed paths for each batch +/// of file-system notifications. +pub struct DirectoryWatcher { + nice_name: String, + path: PathBuf, + debounce_window: Duration, + rx: Option>>, + watcher: Option, +} + +impl DirectoryWatcher { + /// Creates a new directory watcher with the default debounce window. + pub fn new(nice_name: S, path: PathBuf) -> Self { + Self { + nice_name: nice_name.to_string(), + path, + debounce_window: Duration::from_millis(DIRECTORY_WATCH_DEBOUNCE_MILLIS), + rx: None, + watcher: None, + } + } + + /// Waits for the next batch of changed paths in the watched directory. + pub fn watch(&mut self) -> Result, WatchedDirectoryError> { + if !self.path.is_dir() { + return Err(WatchedDirectoryError::NotADirectory(self.path.clone())); + } + + self.ensure_watcher_started()?; + let Some(rx) = &self.rx else { + return Err(WatchedDirectoryError::WatchReceiveError); + }; + + loop { + let first_event = rx + .recv() + .map_err(|_| WatchedDirectoryError::WatchReceiveError)?; + let mut changed_paths = BTreeSet::new(); + collect_paths(first_event, &mut changed_paths); + + let mut deadline = Instant::now() + self.debounce_window; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + + match rx.recv_timeout(remaining) { + Ok(event) => { + collect_paths(event, &mut changed_paths); + deadline = Instant::now() + self.debounce_window; + } + Err(crossbeam_channel::RecvTimeoutError::Timeout) => break, + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { + return Err(WatchedDirectoryError::WatchReceiveError); + } + } + } + + if !changed_paths.is_empty() { + debug!("{} changed: {:?}", self.nice_name, changed_paths); + return Ok(changed_paths.into_iter().collect()); + } + } + } + + fn ensure_watcher_started(&mut self) -> Result<(), WatchedDirectoryError> { + if self.watcher.is_some() { + return Ok(()); + } + + let (tx, rx) = crossbeam_channel::bounded(64); + let maybe_watcher = notify::RecommendedWatcher::new(tx, Config::default()); + let Ok(mut watcher) = maybe_watcher else { + if let Err(err) = maybe_watcher { + error!("Unable to create watcher for {}", self.nice_name); + error!("{err:?}"); + } + return Err(WatchedDirectoryError::CreateWatcherError); + }; + + watcher + .watch(&self.path, RecursiveMode::NonRecursive) + .map_err(|_| WatchedDirectoryError::StartWatcherError)?; + + self.rx = Some(rx); + self.watcher = Some(watcher); + Ok(()) + } +} + +fn collect_paths( + event: Result, + changed_paths: &mut BTreeSet, +) { + match event { + Ok(event) => { + changed_paths.extend(event.paths); + } + Err(err) => { + error!("Directory watcher event error: {err:?}"); + } + } +} + +/// Errors that can occur while watching a directory. +#[derive(Debug, Error)] +pub enum WatchedDirectoryError { + /// The requested watch path does not exist as a directory. + #[error("Watch path is not a directory: {0}")] + NotADirectory(PathBuf), + + /// Unable to create the underlying notify watcher. + #[error("Unable to create watcher")] + CreateWatcherError, + + /// Unable to start the underlying notify watcher. + #[error("Unable to start watcher")] + StartWatcherError, + + /// The watcher channel stopped delivering events. + #[error("Unable to receive watcher events")] + WatchReceiveError, +} diff --git a/src/rust/lqos_utils/src/lib.rs b/src/rust/lqos_utils/src/lib.rs index c7867a89..781a3a41 100644 --- a/src/rust/lqos_utils/src/lib.rs +++ b/src/rust/lqos_utils/src/lib.rs @@ -10,6 +10,9 @@ pub mod fdtimer; /// Wrapper for watching when a file has changed. pub mod file_watcher; +/// Watches a directory and coalesces filesystem events. +pub mod directory_watcher; + /// Utilities for handling strings in hex format pub mod hex_string; diff --git a/src/rust/lqosd/Cargo.toml b/src/rust/lqosd/Cargo.toml index aecd11f7..028dee6e 100644 --- a/src/rust/lqosd/Cargo.toml +++ b/src/rust/lqosd/Cargo.toml @@ -12,6 +12,7 @@ flamegraphs = [] [dependencies] anyhow = { workspace = true } lqos_config = { path = "../lqos_config" } +lqos_network_devices = { path = "../lqos_network_devices" } lqos_sys = { path = "../lqos_sys" } lqos_queue_tracker = { path = "../lqos_queue_tracker" } lqos_utils = { path = "../lqos_utils" } diff --git a/src/rust/lqosd/src/lts2_sys/control_channel.rs b/src/rust/lqosd/src/lts2_sys/control_channel.rs index be610f57..ede8a9f1 100644 --- a/src/rust/lqosd/src/lts2_sys/control_channel.rs +++ b/src/rust/lqosd/src/lts2_sys/control_channel.rs @@ -1436,8 +1436,8 @@ async fn circuit_snapshot_streaming( } // Load "shaped devices" snapshot and pick devices in the target circuit - let shaped = crate::shaped_devices_tracker::SHAPED_DEVICES.load(); - let shaped_cache = crate::shaped_devices_tracker::SHAPED_DEVICE_HASH_CACHE.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); + let shaped_cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let mut device_indexes: Vec = Vec::new(); for (idx, dev) in shaped.devices.iter().enumerate() { if dev.circuit_hash == circuit_hash { @@ -1479,7 +1479,7 @@ async fn circuit_snapshot_streaming( // retire_check is local; use the same heuristic: require most_recent_cycle >= tp_cycle - RETIRE_AFTER_SECONDS // We don't have RETIRE_AFTER_SECONDS here; accept all entries for snapshot. if let Some(device_hash) = te.device_hash - && let Some(id) = shaped_cache.index_by_device_hash(&shaped, device_hash) + && let Some(id) = shaped_cache.index_by_device_hash(shaped.as_ref(), device_hash) && let Some(agg) = aggregates.get_mut(&id) { // bytes_per_second -> later convert to bits @@ -1647,32 +1647,34 @@ async fn tree_snapshot_streaming( } // Use the same data source as local_api::network_tree - let net_json = crate::shaped_devices_tracker::NETWORK_JSON.read(); - let result: Vec<(usize, LiveNetworkTransport)> = net_json - .get_nodes_when_ready() - .iter() - .enumerate() - .map(|(i, n)| { - let t = n.clone_to_transit(); - let mapped = LiveNetworkTransport { - name: t.name, - max_throughput: t.max_throughput, - current_throughput: t.current_throughput, - current_packets: t.current_packets, - current_tcp_packets: t.current_tcp_packets, - current_udp_packets: t.current_udp_packets, - current_icmp_packets: t.current_icmp_packets, - current_retransmits: t.current_retransmits, - current_marks: t.current_marks, - current_drops: t.current_drops, - rtts: t.rtts, - parents: t.parents, - immediate_parent: t.immediate_parent, - node_type: t.node_type, - }; - (i, mapped) - }) - .collect(); + let result: Vec<(usize, LiveNetworkTransport)> = + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .enumerate() + .map(|(i, n)| { + let t = n.clone_to_transit(); + let mapped = LiveNetworkTransport { + name: t.name, + max_throughput: t.max_throughput, + current_throughput: t.current_throughput, + current_packets: t.current_packets, + current_tcp_packets: t.current_tcp_packets, + current_udp_packets: t.current_udp_packets, + current_icmp_packets: t.current_icmp_packets, + current_retransmits: t.current_retransmits, + current_marks: t.current_marks, + current_drops: t.current_drops, + rtts: t.rtts, + parents: t.parents, + immediate_parent: t.immediate_parent, + node_type: t.node_type, + }; + (i, mapped) + }) + .collect() + }); let Ok(bytes) = serde_cbor::to_vec(&result) else { error!("Failed to serialize LiveNetworkTransport payload"); diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index de494a2a..9184931e 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -9,6 +9,7 @@ mod ip_mapping; #[cfg(feature = "equinix_tests")] mod lqos_daht_test; pub mod lts2_sys; +mod network_devices_hooks; mod node_manager; mod preflight_checks; mod probe_provider; @@ -241,9 +242,10 @@ fn main() -> Result<()> { let _ = throughput_tracker::flow_data::setup_flow_analysis(); start_heimdall()?; spawn_queue_structure_monitor()?; - shaped_devices_tracker::shaped_devices_watcher()?; - shaped_devices_tracker::network_json_watcher()?; shaped_devices_tracker::shaping_inputs_watcher()?; + lqos_network_devices::start_daemon_mode(Some(std::sync::Arc::new( + network_devices_hooks::LqosdNetworkDevicesHooks, + )))?; let system_usage_tx = system_stats::start_system_stats()?; throughput_tracker::spawn_throughput_monitor( flow_tx, @@ -401,7 +403,7 @@ fn memory_debug() { // fb.visit_root(&*THROUGHPUT_TRACKER); // fb.visit_root(&*ALL_FLOWS); // fb.visit_root(&*RECENT_FLOWS); - //fb.visit_root(&*NETWORK_JSON); + // lqos_network_devices::with_network_json_read(|net_json| fb.visit_root(net_json)); let flamegraph_src = fb.finish(); let flamegraph_src = flamegraph_src.flamegraph(); let Ok(mut file) = std::fs::File::create("/tmp/lqosd-mem.svg") else { @@ -1124,7 +1126,6 @@ fn queue_stats_total_data() -> lqos_bus::QueueStatsTotal { } fn circuit_capacity_data() -> Vec { - use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::THROUGHPUT_TRACKER; use lqos_utils::units::DownUpOrder; use std::collections::HashMap; @@ -1159,7 +1160,7 @@ fn circuit_capacity_data() -> Vec { } }); - let shaped_devices = SHAPED_DEVICES.load(); + let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); circuits .iter() .filter_map(|(circuit_id, accumulator)| { @@ -1185,42 +1186,41 @@ fn circuit_capacity_data() -> Vec { } fn tree_capacity_data() -> Vec { - use crate::shaped_devices_tracker::NETWORK_JSON; - - let net_json = NETWORK_JSON.read(); - net_json - .get_nodes_when_ready() - .iter() - .enumerate() - .map(|(id, node)| { - let node = crate::shaped_devices_tracker::node_to_transport(node); - let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0; - let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0; - let effective_max = node.effective_max_throughput.unwrap_or(node.max_throughput); - let max_down = effective_max.0; - let max_up = effective_max.1; - let median_rtt = if node.rtts.is_empty() { - 0.0 - } else { - let n = node.rtts.len() / 2; - if node.rtts.len().is_multiple_of(2) { - (node.rtts[n - 1] + node.rtts[n]) / 2.0 + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .enumerate() + .map(|(id, node)| { + let node = crate::shaped_devices_tracker::node_to_transport(node); + let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0; + let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0; + let effective_max = node.effective_max_throughput.unwrap_or(node.max_throughput); + let max_down = effective_max.0; + let max_up = effective_max.1; + let median_rtt = if node.rtts.is_empty() { + 0.0 } else { - node.rtts[n] - } - }; + let n = node.rtts.len() / 2; + if node.rtts.len().is_multiple_of(2) { + (node.rtts[n - 1] + node.rtts[n]) / 2.0 + } else { + node.rtts[n] + } + }; - lqos_bus::NodeCapacity { - id, - name: node.name.clone(), - down, - up, - max_down, - max_up, - median_rtt, - } - }) - .collect() + lqos_bus::NodeCapacity { + id, + name: node.name.clone(), + down, + up, + max_down, + max_up, + median_rtt, + } + }) + .collect() + }) } fn retransmit_summary_data() -> lqos_bus::RetransmitSummary { @@ -1234,37 +1234,39 @@ fn retransmit_summary_data() -> lqos_bus::RetransmitSummary { } fn tree_summary_l2_data() -> Vec<(usize, Vec<(usize, lqos_config::NetworkJsonTransport)>)> { - use crate::shaped_devices_tracker::NETWORK_JSON; use std::collections::BTreeMap; - let net_json = NETWORK_JSON.read(); - let nodes = net_json.get_nodes_when_ready(); - let mut candidates: Vec<(usize, usize, lqos_config::NetworkJsonTransport, u64)> = Vec::new(); + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let mut candidates: Vec<(usize, usize, lqos_config::NetworkJsonTransport, u64)> = + Vec::new(); - for (p_idx, p_node) in nodes.iter().enumerate() { - if p_node.immediate_parent == Some(0) { - for (c_idx, c_node) in nodes.iter().enumerate() { - if c_node.immediate_parent == Some(p_idx) { - let t = crate::shaped_devices_tracker::node_to_transport(c_node); - let total = t.current_throughput.0 + t.current_throughput.1; - candidates.push((p_idx, c_idx, t, total)); + for (p_idx, p_node) in nodes.iter().enumerate() { + if p_node.immediate_parent == Some(0) { + for (c_idx, c_node) in nodes.iter().enumerate() { + if c_node.immediate_parent == Some(p_idx) { + let t = crate::shaped_devices_tracker::node_to_transport(c_node); + let total = t.current_throughput.0 + t.current_throughput.1; + candidates.push((p_idx, c_idx, t, total)); + } } } } - } - candidates.sort_by(|a, b| b.3.cmp(&a.3)); - let n: usize = 10; - if candidates.len() > n { - candidates.truncate(n); - } + candidates.sort_by(|a, b| b.3.cmp(&a.3)); + let n: usize = 10; + if candidates.len() > n { + candidates.truncate(n); + } - let mut map: BTreeMap> = BTreeMap::new(); - for (p_idx, c_idx, t, _total) in candidates.into_iter() { - map.entry(p_idx).or_default().push((c_idx, t)); - } + let mut map: BTreeMap> = + BTreeMap::new(); + for (p_idx, c_idx, t, _total) in candidates.into_iter() { + map.entry(p_idx).or_default().push((c_idx, t)); + } - map.into_iter().collect() + map.into_iter().collect() + }) } fn search_result_to_bus(entry: node_manager::SearchResult) -> lqos_bus::SearchResultEntry { diff --git a/src/rust/lqosd/src/network_devices_hooks.rs b/src/rust/lqosd/src/network_devices_hooks.rs new file mode 100644 index 00000000..36ec9434 --- /dev/null +++ b/src/rust/lqosd/src/network_devices_hooks.rs @@ -0,0 +1,23 @@ +use crate::shaped_devices_tracker; +use crate::throughput_tracker::THROUGHPUT_TRACKER; + +pub(crate) struct LqosdNetworkDevicesHooks; + +impl lqos_network_devices::DaemonHooks for LqosdNetworkDevicesHooks { + fn on_shaped_devices_updated(&self) { + shaped_devices_tracker::invalidate_circuit_live_snapshot(); + shaped_devices_tracker::invalidate_executive_cache_snapshot(); + lqos_network_devices::with_network_json_read(|net_json| { + THROUGHPUT_TRACKER.refresh_circuit_ids(net_json); + }); + } + + fn on_network_json_updated(&self) { + shaped_devices_tracker::invalidate_circuit_live_snapshot(); + shaped_devices_tracker::invalidate_executive_cache_snapshot(); + lqos_network_devices::with_network_json_read(|net_json| { + THROUGHPUT_TRACKER.refresh_circuit_ids(net_json); + }); + } +} + diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit.rs b/src/rust/lqosd/src/node_manager/local_api/circuit.rs index 0b62a00a..85c4c917 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit.rs @@ -1,6 +1,6 @@ use crate::node_manager::local_api::ethernet_caps::ethernet_advisory_for_circuit; use crate::shaped_devices_tracker::{ - SHAPED_DEVICES, effective_parent_for_circuit, resolve_parent_node_reference, + effective_parent_for_circuit, resolve_parent_node_reference, }; use lqos_config::{CircuitEthernetMetadata, ShapedDevice}; use serde::{Deserialize, Serialize}; @@ -73,7 +73,7 @@ fn circuit_parent_node( pub fn circuit_by_id_data(id: &str) -> Option { let safe_id = id.to_lowercase().trim().to_string(); - let reader = SHAPED_DEVICES.load(); + let reader = lqos_network_devices::shaped_devices_snapshot(); let mut devices: Vec = reader .devices .iter() diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs index 0abc0e61..44526a60 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs @@ -1,4 +1,3 @@ -use crate::shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}; use crate::throughput_tracker::flow_data::{ALL_FLOWS, FlowbeeLocalData, get_asn_name_and_country}; use lqos_utils::hash_to_i64; use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; @@ -220,8 +219,8 @@ fn flow_qoo(local: &FlowbeeLocalData) -> DownUpOrder> { fn flow_snapshot_rows(circuit_id: &str) -> Vec { let circuit_hash = hash_to_i64(circuit_id); - let shaped = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); + let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let display_rate_ceiling = circuit_display_rate_ceiling_bps(&shaped, circuit_hash); let Ok(now) = time_since_boot() else { return Vec::new(); @@ -243,8 +242,8 @@ fn flow_snapshot_rows(circuit_id: &str) -> Vec { let device_name = local .device_hash - .and_then(|hash| cache.index_by_device_hash(&shaped, hash)) - .and_then(|idx| shaped.devices.get(idx)) + .and_then(|hash| cache.index_by_device_hash(shaped.as_ref(), hash)) + .and_then(|idx| shaped.as_ref().devices.get(idx)) .map(|d| d.device_name.clone()) .unwrap_or_else(|| "Unknown".to_string()); diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs index 14bcd335..6a3d2c20 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs @@ -1,4 +1,3 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::THROUGHPUT_TRACKER; use lqos_utils::unix_time::time_since_boot; use serde::Serialize; @@ -36,7 +35,7 @@ pub fn circuit_count_data() -> CircuitCount { .collect(); // Get configured circuits from ShapedDevices - let shaped_devices = SHAPED_DEVICES.load(); + let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); let configured_circuits: HashSet<&str> = shaped_devices .devices .iter() diff --git a/src/rust/lqosd/src/node_manager/local_api/config.rs b/src/rust/lqosd/src/node_manager/local_api/config.rs index 8838b79c..81def6e3 100644 --- a/src/rust/lqosd/src/node_manager/local_api/config.rs +++ b/src/rust/lqosd/src/node_manager/local_api/config.rs @@ -1,5 +1,4 @@ use crate::node_manager::auth::LoginResult; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use axum::http::StatusCode; use default_net::get_interfaces; use lqos_bus::{BusRequest, bus_request}; @@ -8,7 +7,6 @@ use lqos_utils::hash_to_i64; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet, HashSet}; -use std::sync::Arc; type TopologySourceIntegration = (&'static str, fn(&Config) -> bool); @@ -101,7 +99,7 @@ pub fn network_json_data() -> Value { } pub fn all_shaped_devices_data() -> Vec { - SHAPED_DEVICES.load().devices.clone() + lqos_network_devices::shaped_devices_snapshot().devices.clone() } /// Returns the enabled integration names that act as the source of truth for @@ -345,7 +343,8 @@ fn persist_shaped_devices(mut devices: Vec) -> Result<(), String> copied .write_csv("ShapedDevices.csv") .map_err(|e| format!("Unable to write ShapedDevices.csv: {e}"))?; - SHAPED_DEVICES.store(Arc::new(copied)); + lqos_network_devices::apply_shaped_devices_snapshot("node_manager:persist_shaped_devices", copied) + .map_err(|e| format!("Unable to publish ShapedDevices.csv snapshot: {e}"))?; Ok(()) } @@ -379,6 +378,8 @@ pub fn update_network_and_devices_data( ensure_topology_editor_unlocked()?; persist_network_json(&network_json)?; persist_shaped_devices(shaped_devices)?; + lqos_network_devices::request_reload_network_json("node_manager:update_network_and_devices") + .map_err(|e| format!("Unable to reload network.json: {e}"))?; Ok(()) } @@ -398,6 +399,8 @@ pub fn update_network_json_only_data( ensure_topology_editor_unlocked()?; persist_network_json(&network_json)?; + lqos_network_devices::request_reload_network_json("node_manager:update_network_json_only") + .map_err(|e| format!("Unable to reload network.json: {e}"))?; Ok(()) } @@ -412,8 +415,7 @@ pub fn get_shaped_device_data( return Err(StatusCode::FORBIDDEN); } let wanted = device_id.trim(); - Ok(SHAPED_DEVICES - .load() + Ok(lqos_network_devices::shaped_devices_snapshot() .devices .iter() .find(|device| device.device_id == wanted) @@ -432,7 +434,7 @@ pub fn create_shaped_device_data( return Err("Unauthorized".to_string()); } ensure_topology_editor_unlocked()?; - let mut devices = SHAPED_DEVICES.load().devices.clone(); + let mut devices = lqos_network_devices::shaped_devices_snapshot().devices.clone(); devices.push(device.clone()); persist_shaped_devices(devices)?; let created = get_shaped_device_data(login, device.device_id.clone()) @@ -455,7 +457,7 @@ pub fn update_shaped_device_data( return Err("Unauthorized".to_string()); } ensure_topology_editor_unlocked()?; - let mut devices = SHAPED_DEVICES.load().devices.clone(); + let mut devices = lqos_network_devices::shaped_devices_snapshot().devices.clone(); let wanted = original_device_id.trim(); let Some(index) = devices.iter().position(|row| row.device_id == wanted) else { return Err("Not found".to_string()); @@ -479,7 +481,7 @@ pub fn delete_shaped_device_data(login: LoginResult, device_id: String) -> Resul } ensure_topology_editor_unlocked()?; let wanted = device_id.trim(); - let mut devices = SHAPED_DEVICES.load().devices.clone(); + let mut devices = lqos_network_devices::shaped_devices_snapshot().devices.clone(); let before = devices.len(); devices.retain(|device| device.device_id != wanted); if devices.len() == before { diff --git a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs index 747cad17..ea11e8e8 100644 --- a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs @@ -1,7 +1,6 @@ use crate::node_manager::local_api::network_tree_lite::{ NetworkTreeLiteNode, network_tree_lite_data, }; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::system_stats::{CPU_USAGE, NUM_CPUS}; use lqos_config::{ShapingCpuDetection, detect_shaping_cpus, load_config}; use serde::Serialize; @@ -560,7 +559,7 @@ fn derive_runtime_node_placements( } fn direct_circuit_counts_by_node() -> HashMap { - let shaped = SHAPED_DEVICES.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); let mut circuits_by_node: HashMap> = HashMap::new(); for device in &shaped.devices { diff --git a/src/rust/lqosd/src/node_manager/local_api/device_counts.rs b/src/rust/lqosd/src/node_manager/local_api/device_counts.rs index 6617aceb..7e0cc887 100644 --- a/src/rust/lqosd/src/node_manager/local_api/device_counts.rs +++ b/src/rust/lqosd/src/node_manager/local_api/device_counts.rs @@ -1,5 +1,4 @@ use crate::node_manager::local_api::unknown_ips::get_unknown_ips; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use serde::Serialize; #[derive(Serialize, Debug, Clone)] @@ -10,7 +9,7 @@ pub struct DeviceCount { pub fn device_count() -> DeviceCount { DeviceCount { - shaped_devices: SHAPED_DEVICES.load().devices.len(), + shaped_devices: lqos_network_devices::shaped_devices_snapshot().devices.len(), unknown_ips: get_unknown_ips().len(), } } diff --git a/src/rust/lqosd/src/node_manager/local_api/directories.rs b/src/rust/lqosd/src/node_manager/local_api/directories.rs index 63a5e525..54981629 100644 --- a/src/rust/lqosd/src/node_manager/local_api/directories.rs +++ b/src/rust/lqosd/src/node_manager/local_api/directories.rs @@ -1,5 +1,4 @@ use crate::node_manager::local_api::network_tree; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -85,7 +84,7 @@ pub fn circuit_directory_page(query: CircuitDirectoryQuery) -> CircuitDirectoryP let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); - let devices = SHAPED_DEVICES.load(); + let devices = lqos_network_devices::shaped_devices_snapshot(); let mut circuits: BTreeMap = BTreeMap::new(); for device in &devices.devices { @@ -210,7 +209,7 @@ pub fn treeguard_metadata_summary() -> TreeGuardMetadataSummary { }) .count(); - let devices = SHAPED_DEVICES.load(); + let devices = lqos_network_devices::shaped_devices_snapshot(); let mut circuit_ids = BTreeSet::new(); let mut fq_codel_circuit_ids = BTreeSet::new(); for device in &devices.devices { diff --git a/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs b/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs index 94b72dc0..fb9f1c0a 100644 --- a/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs +++ b/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs @@ -1,4 +1,3 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use lqos_config::{ CIRCUIT_ETHERNET_METADATA_FILENAME, CircuitEthernetMetadata, CircuitEthernetMetadataFile, load_config, @@ -143,7 +142,7 @@ fn load_advisory_file() -> Option { } fn parent_node_by_circuit_id() -> HashMap { - let devices = SHAPED_DEVICES.load(); + let devices = lqos_network_devices::shaped_devices_snapshot(); let mut parent_nodes = HashMap::new(); for device in &devices.devices { let circuit_id = device.circuit_id.trim(); diff --git a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs index 7ccca272..d1612663 100644 --- a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs +++ b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs @@ -4,7 +4,6 @@ use crate::node_manager::local_api::executive::{ ExecutiveOversubscribedSiteRow, ExecutiveRttBlocks, ExecutiveScalarBlocks, ExecutiveSplitBlocks, ExecutiveTopAsnRow, ExecutiveTreeLocator, }; -use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; use crate::throughput_tracker::{ THROUGHPUT_TRACKER, asn_heatmaps, circuit_heatmaps, executive_summary_header, global_heatmap, site_heatmaps, @@ -595,23 +594,24 @@ fn node_path_names(idx: usize, nodes: &[lqos_config::NetworkJsonNode]) -> Option } fn node_locator_map() -> FxHashMap { - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); - let mut locators = FxHashMap::default(); - for (idx, node) in nodes.iter().enumerate() { - if node.name == "Root" || locators.contains_key(&node.name) { - continue; + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let mut locators = FxHashMap::default(); + for (idx, node) in nodes.iter().enumerate() { + if node.name == "Root" || locators.contains_key(&node.name) { + continue; + } + locators.insert( + node.name.clone(), + ExecutiveTreeLocator { + node_id: node.id.clone(), + node_path: node_path_names(idx, nodes), + parent_index: Some(idx), + }, + ); } - locators.insert( - node.name.clone(), - ExecutiveTreeLocator { - node_id: node.id.clone(), - node_path: node_path_names(idx, nodes), - parent_index: Some(idx), - }, - ); - } - locators + locators + }) } fn entity_snapshots( @@ -686,7 +686,7 @@ fn entity_snapshots( fn build_oversubscribed_sites( entities: &[ExecutiveEntitySnapshot], ) -> Vec { - let devices = SHAPED_DEVICES.load(); + let devices = lqos_network_devices::shaped_devices_snapshot(); let mut circuit_map: std::collections::HashMap = std::collections::HashMap::new(); for device in &devices.devices { @@ -697,106 +697,107 @@ fn build_oversubscribed_sites( entry.2 = entry.2.max(device.upload_max_mbps); } - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); - if nodes.is_empty() { - return Vec::new(); - } - - let mut children: Vec> = vec![Vec::new(); nodes.len()]; - for (idx, node) in nodes.iter().enumerate() { - if let Some(parent) = node.immediate_parent - && parent < children.len() - { - children[parent].push(idx); - } - } - let entity_map = entities .iter() .filter(|entity| entity.entity_kind == ExecutiveEntityKind::Site) .map(|entity| (entity.label.clone(), entity)) .collect::>(); - let mut results = Vec::new(); - for (idx, node) in nodes.iter().enumerate() { - if node.name == "Root" { - continue; - } - let node_type = node.node_type.as_deref().unwrap_or("").to_ascii_lowercase(); - if node_type != "site" && !node_type.is_empty() && node_type != "ap" { - continue; + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + if nodes.is_empty() { + return Vec::new(); } - let mut stack = vec![idx]; - let mut visited = FxHashSet::default(); - let mut descendant_names = FxHashSet::default(); - while let Some(next_idx) = stack.pop() { - if !visited.insert(next_idx) { + let mut children: Vec> = vec![Vec::new(); nodes.len()]; + for (idx, node) in nodes.iter().enumerate() { + if let Some(parent) = node.immediate_parent + && parent < children.len() + { + children[parent].push(idx); + } + } + + let mut results = Vec::new(); + for (idx, node) in nodes.iter().enumerate() { + if node.name == "Root" { continue; } - if let Some(name) = nodes.get(next_idx).map(|entry| entry.name.clone()) { - descendant_names.insert(name); + let node_type = node.node_type.as_deref().unwrap_or("").to_ascii_lowercase(); + if node_type != "site" && !node_type.is_empty() && node_type != "ap" { + continue; } - if let Some(child_nodes) = children.get(next_idx) { - for child in child_nodes { - stack.push(*child); + + let mut stack = vec![idx]; + let mut visited = FxHashSet::default(); + let mut descendant_names = FxHashSet::default(); + while let Some(next_idx) = stack.pop() { + if !visited.insert(next_idx) { + continue; + } + if let Some(name) = nodes.get(next_idx).map(|entry| entry.name.clone()) { + descendant_names.insert(name); + } + if let Some(child_nodes) = children.get(next_idx) { + for child in child_nodes { + stack.push(*child); + } } } - } - let cap_down = node.max_throughput.0 as f32; - let cap_up = node.max_throughput.1 as f32; + let cap_down = node.max_throughput.0 as f32; + let cap_up = node.max_throughput.1 as f32; - let mut sub_down = 0.0_f32; - let mut sub_up = 0.0_f32; - for (parent_name, circuit_down, circuit_up) in circuit_map.values() { - if descendant_names.contains(parent_name) { - sub_down += *circuit_down; - sub_up += *circuit_up; + let mut sub_down = 0.0_f32; + let mut sub_up = 0.0_f32; + for (parent_name, circuit_down, circuit_up) in circuit_map.values() { + if descendant_names.contains(parent_name) { + sub_down += *circuit_down; + sub_up += *circuit_up; + } } + + let Some(stats) = entity_map.get(&node.name) else { + continue; + }; + let (avg_down_util, _) = average_with_count(&stats.heatmap.download); + let (avg_up_util, _) = average_with_count(&stats.heatmap.upload); + let median_rtt_ms = median_value(&stats.heatmap.rtt); + let ratio_down = (cap_down > 0.0).then_some(sub_down / cap_down); + let ratio_up = (cap_up > 0.0).then_some(sub_up / cap_up); + let ratio_max = match (ratio_down, ratio_up) { + (Some(down), Some(up)) => Some(down.max(up)), + (Some(down), None) => Some(down), + (None, Some(up)) => Some(up), + (None, None) => None, + }; + + results.push(ExecutiveOversubscribedSiteRow { + row_key: stats.row_key.clone(), + site_name: node.name.clone(), + tree: stats.tree.clone(), + ratio_down, + ratio_up, + ratio_max, + cap_down, + cap_up, + sub_down, + sub_up, + avg_down_util, + avg_up_util, + median_rtt_ms, + }); } - let Some(stats) = entity_map.get(&node.name) else { - continue; - }; - let (avg_down_util, _) = average_with_count(&stats.heatmap.download); - let (avg_up_util, _) = average_with_count(&stats.heatmap.upload); - let median_rtt_ms = median_value(&stats.heatmap.rtt); - let ratio_down = (cap_down > 0.0).then_some(sub_down / cap_down); - let ratio_up = (cap_up > 0.0).then_some(sub_up / cap_up); - let ratio_max = match (ratio_down, ratio_up) { - (Some(down), Some(up)) => Some(down.max(up)), - (Some(down), None) => Some(down), - (None, Some(up)) => Some(up), - (None, None) => None, - }; - - results.push(ExecutiveOversubscribedSiteRow { - row_key: stats.row_key.clone(), - site_name: node.name.clone(), - tree: stats.tree.clone(), - ratio_down, - ratio_up, - ratio_max, - cap_down, - cap_up, - sub_down, - sub_up, - avg_down_util, - avg_up_util, - median_rtt_ms, + results.sort_by(|left, right| { + right + .ratio_max + .unwrap_or(0.0) + .total_cmp(&left.ratio_max.unwrap_or(0.0)) }); - } - - results.sort_by(|left, right| { - right - .ratio_max - .unwrap_or(0.0) - .total_cmp(&left.ratio_max.unwrap_or(0.0)) - }); - results.truncate(EXECUTIVE_OVERSUBSCRIBED_LIMIT); - results + results.truncate(EXECUTIVE_OVERSUBSCRIBED_LIMIT); + results + }) } /// Invalidates the shared executive cache so the next read rebuilds immediately. diff --git a/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs b/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs index 2e6621a2..03a35aa2 100644 --- a/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs +++ b/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs @@ -2,10 +2,7 @@ use crate::throughput_tracker::flow_data::{ AsnCountryListEntry, AsnListEntry, AsnProtocolListEntry, FlowAnalysis, FlowbeeLocalData, RECENT_FLOWS, RttData, }; -use crate::{ - shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}, - throughput_tracker::THROUGHPUT_TRACKER, -}; +use crate::throughput_tracker::THROUGHPUT_TRACKER; use lqos_sys::flowbee_data::FlowbeeKey; use lqos_utils::units::DownUpOrder; use lqos_utils::unix_time::{time_since_boot, unix_now}; @@ -57,8 +54,8 @@ fn all_flows_to_transport( boot_time: u64, all_flows_for_asn: Vec<(FlowbeeKey, FlowbeeLocalData, FlowAnalysis)>, ) -> Vec { - let shaped = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); + let shaped_cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); all_flows_for_asn .iter() @@ -75,12 +72,12 @@ fn all_flows_to_transport( } let shaped_device = te .device_hash - .and_then(|hash| shaped_cache.index_by_device_hash(&shaped, hash)) + .and_then(|hash| shaped_cache.index_by_device_hash(shaped.as_ref(), hash)) .or_else(|| { te.circuit_hash - .and_then(|hash| shaped_cache.index_by_circuit_hash(&shaped, hash)) + .and_then(|hash| shaped_cache.index_by_circuit_hash(shaped.as_ref(), hash)) }) - .and_then(|idx| shaped.devices.get(idx)); + .and_then(|idx| shaped.as_ref().devices.get(idx)); if let Some(device) = shaped_device { if circuit_id.is_empty() { circuit_id = device.circuit_id.clone(); diff --git a/src/rust/lqosd/src/node_manager/local_api/search.rs b/src/rust/lqosd/src/node_manager/local_api/search.rs index 8c6d6cc8..e991da9b 100644 --- a/src/rust/lqosd/src/node_manager/local_api/search.rs +++ b/src/rust/lqosd/src/node_manager/local_api/search.rs @@ -1,4 +1,3 @@ -use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; use ip_network::IpNetwork; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -103,7 +102,7 @@ pub fn search_results(search: SearchRequest) -> Vec { // First pass: exact IP matches using the LPM trie if let Some(ip) = exact_ip { - let sd_reader = SHAPED_DEVICES.load(); + let sd_reader = lqos_network_devices::shaped_devices_snapshot(); let query_v6 = match ip { IpAddr::V4(v4) => v4.to_ipv6_mapped(), IpAddr::V6(v6) => v6, @@ -143,7 +142,7 @@ pub fn search_results(search: SearchRequest) -> Vec { IpNetwork::V6(v6net) => Some(IpNetwork::V6(v6net)), }; if let Some(query_v6) = net_v6.as_ref() { - let sd_reader = SHAPED_DEVICES.load(); + let sd_reader = lqos_network_devices::shaped_devices_snapshot(); for (n, &idx) in sd_reader.trie.iter() { if results.len() >= MAX_RESULTS { break; @@ -168,7 +167,7 @@ pub fn search_results(search: SearchRequest) -> Vec { } } else { // Fallback: textual prefix (e.g., "10.1.") - let sd_reader = SHAPED_DEVICES.load(); + let sd_reader = lqos_network_devices::shaped_devices_snapshot(); for (n, &idx) in sd_reader.trie.iter() { if results.len() >= MAX_RESULTS { break; @@ -195,7 +194,7 @@ pub fn search_results(search: SearchRequest) -> Vec { // Third pass: Circuit/Device name substring matches if results.len() < MAX_RESULTS && term_lc.len() >= 3 { - let sd_reader = SHAPED_DEVICES.load(); + let sd_reader = lqos_network_devices::shaped_devices_snapshot(); for sd in sd_reader.devices.iter() { if results.len() >= MAX_RESULTS { break; @@ -233,23 +232,24 @@ pub fn search_results(search: SearchRequest) -> Vec { // Fourth pass: Site name substring matches if results.len() < MAX_RESULTS && term_lc.len() >= 3 { - let net_reader = NETWORK_JSON.read(); - for (idx, n) in net_reader.get_nodes_when_ready().iter().enumerate() { - if results.len() >= MAX_RESULTS { - break; + lqos_network_devices::with_network_json_read(|net_reader| { + for (idx, n) in net_reader.get_nodes_when_ready().iter().enumerate() { + if results.len() >= MAX_RESULTS { + break; + } + if n.name.to_lowercase().contains(&term_lc) { + push_result( + &mut results, + &mut seen, + SearchResult::Site { + idx, + name: n.name.clone(), + }, + MAX_RESULTS, + ); + } } - if n.name.to_lowercase().contains(&term_lc) { - push_result( - &mut results, - &mut seen, - SearchResult::Site { - idx, - name: n.name.clone(), - }, - MAX_RESULTS, - ); - } - } + }); } results diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs index 411ed7ae..7168070c 100644 --- a/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs @@ -1,6 +1,5 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use lqos_config::ShapedDevice; pub fn all_shaped_devices_data() -> Vec { - SHAPED_DEVICES.load().devices.clone() + lqos_network_devices::shaped_devices_snapshot().devices.clone() } diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs index 684159b8..f5af6352 100644 --- a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs @@ -1,4 +1,3 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use lqos_config::ShapedDevice; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashSet; @@ -53,7 +52,7 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); - let devices = SHAPED_DEVICES.load(); + let devices = lqos_network_devices::shaped_devices_snapshot(); let mut filtered: Vec = devices .devices diff --git a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs index c0937af4..e50e33f8 100644 --- a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs +++ b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs @@ -1,5 +1,4 @@ use crate::node_manager::local_api::ethernet_caps::{EthernetCapBadge, ethernet_cap_badge_map}; -use crate::shaped_devices_tracker::NETWORK_JSON; use crate::shaped_devices_tracker::circuit_live::fresh_circuit_live_snapshot; use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; use serde::{Deserialize, Deserializer, Serialize}; @@ -81,45 +80,46 @@ fn normalized_page_size(query: &TreeAttachedCircuitsQuery) -> usize { } fn resolve_node_name(query: &TreeAttachedCircuitsQuery) -> Option { - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); - if let Some(node_id) = query.node_id.as_deref() - && let Some(node) = nodes - .iter() - .find(|node| node.id.as_deref() == Some(node_id)) - { - return Some(node.name.clone()); - } - - let node_path = query.node_path.as_ref()?; - if node_path.is_empty() { - return None; - } - nodes.iter().find_map(|node| { - let path = if node.name == "Root" { - vec!["Root".to_string()] - } else { - let parent_indexes = if node.parents.is_empty() { - vec![node.immediate_parent.unwrap_or(0)] - } else { - node.parents.clone() - }; - let mut names = Vec::new(); - for idx in parent_indexes { - let parent = nodes.get(idx)?; - names.push(parent.name.clone()); - } - if names.last() != Some(&node.name) { - names.push(node.name.clone()); - } - names - }; - if &path == node_path { - Some(node.name.clone()) - } else { - None + if let Some(node_id) = query.node_id.as_deref() + && let Some(node) = nodes + .iter() + .find(|node| node.id.as_deref() == Some(node_id)) + { + return Some(node.name.clone()); } + + let node_path = query.node_path.as_ref()?; + if node_path.is_empty() { + return None; + } + nodes.iter().find_map(|node| { + let path = if node.name == "Root" { + vec!["Root".to_string()] + } else { + let parent_indexes = if node.parents.is_empty() { + vec![node.immediate_parent.unwrap_or(0)] + } else { + node.parents.clone() + }; + let mut names = Vec::new(); + for idx in parent_indexes { + let parent = nodes.get(idx)?; + names.push(parent.name.clone()); + } + if names.last() != Some(&node.name) { + names.push(node.name.clone()); + } + names + }; + if &path == node_path { + Some(node.name.clone()) + } else { + None + } + }) }) } diff --git a/src/rust/lqosd/src/node_manager/template.rs b/src/rust/lqosd/src/node_manager/template.rs index 259e273f..169597f9 100644 --- a/src/rust/lqosd/src/node_manager/template.rs +++ b/src/rust/lqosd/src/node_manager/template.rs @@ -3,7 +3,6 @@ use crate::lts2_sys::shared_types::LtsStatus; use crate::node_manager::auth::{FIRST_LOAD, get_username}; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::tool_status::is_api_available; use axum::body::{Body, to_bytes}; use axum::http::header; @@ -153,7 +152,7 @@ pub async fn apply_templates( let week_ago = now - (7 * 24 * 60 * 60); let fl = FIRST_LOAD.load(Relaxed); if fl != 0 && fl < week_ago { - let sd = SHAPED_DEVICES.load(); + let sd = lqos_network_devices::shaped_devices_snapshot(); let num_circuits = sd .devices .iter() diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs index 9a7a9a6b..26f9f225 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs @@ -2,9 +2,9 @@ use crate::node_manager::local_api::circuit_activity::{CircuitSummaryData, circu use crate::node_manager::ws::messages::{CircuitDevicesResult, WsResponse, encode_ws_message}; use crate::node_manager::ws::ticker::all_circuits; use crate::rtt_exclusions; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::THROUGHPUT_TRACKER; use lqos_bus::{BusRequest, Circuit}; +use lqos_utils::hash_to_i64; use lqos_utils::units::{DownUpOrder, down_up_retransmit_sample}; use std::time::Duration; use tokio::time::MissedTickBehavior; @@ -13,15 +13,9 @@ use tracing::info; const QUEUING_ACTIVITY_RTT_FLOOR_BPS: u64 = 200_000; fn qoo_score_for_circuit(circuit: &str) -> Option { - let shaped = SHAPED_DEVICES.load(); - let circuit_hash = shaped - .devices - .iter() - .find(|d| d.circuit_id == circuit) - .map(|d| d.circuit_hash); - circuit_hash.and_then(|hash| { - let qoq_heatmaps = THROUGHPUT_TRACKER.circuit_qoq_heatmaps.lock(); - qoq_heatmaps.get(&hash).and_then(|heatmap| { + let circuit_hash = hash_to_i64(circuit.trim()); + let qoq_heatmaps = THROUGHPUT_TRACKER.circuit_qoq_heatmaps.lock(); + qoq_heatmaps.get(&circuit_hash).and_then(|heatmap| { let blocks = heatmap.blocks(); let dl = blocks.download_total.last().copied().flatten(); let ul = blocks.upload_total.last().copied().flatten(); @@ -31,7 +25,6 @@ fn qoo_score_for_circuit(circuit: &str) -> Option { (None, Some(u)) => Some(u), (None, None) => None, } - }) }) } diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs b/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs index d3e6bc55..037697f4 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs @@ -5,7 +5,6 @@ use crate::node_manager::ws::messages::{ }; use crate::node_manager::ws::publish_subscribe::PubSub; use crate::node_manager::ws::published_channels::PublishedChannels; -use crate::shaped_devices_tracker::NETWORK_JSON; use lqos_bakery::{ BakeryActivityEntry as BakeryActivitySnapshot, BakeryApplyType, BakeryMode, BakeryPreflightSnapshot, BakeryRuntimeNodeOperationAction, BakeryRuntimeNodeOperationStatus, @@ -79,11 +78,12 @@ fn map_preflight(snapshot: BakeryPreflightSnapshot) -> BakeryPreflightData { } fn resolve_site_name(site_hash: i64) -> Option { - let reader = NETWORK_JSON.read(); - reader - .get_nodes_when_ready() - .iter() - .find_map(|node| (hash_to_i64(&node.name) == site_hash).then(|| node.name.clone())) + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .find_map(|node| (hash_to_i64(&node.name) == site_hash).then(|| node.name.clone())) + }) } fn resolve_runtime_site_name(site_hash: i64) -> Option { diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs b/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs index b1834423..0b23ce0f 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs @@ -1,7 +1,6 @@ use crate::node_manager::ws::messages::{CircuitCapacityRow, WsResponse}; use crate::node_manager::ws::publish_subscribe::PubSub; use crate::node_manager::ws::published_channels::PublishedChannels; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::THROUGHPUT_TRACKER; use lqos_utils::units::DownUpOrder; use std::collections::HashMap; @@ -47,7 +46,7 @@ pub async fn circuit_capacity(channels: Arc) { }); // Map circuits to capacities - let shaped_devices = SHAPED_DEVICES.load(); + let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); let capacities: Vec = { circuits .iter() diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs b/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs index 644c0357..0adcab17 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs @@ -1,4 +1,3 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use lqos_bus::{IpStats, TcHandle}; use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; use serde::{Deserialize, Serialize}; @@ -33,7 +32,7 @@ impl From<&IpStats> for IpStatsWithPlan { }; if !result.circuit_id.is_empty() { - let shaped_devices_reader = SHAPED_DEVICES.load(); + let shaped_devices_reader = lqos_network_devices::shaped_devices_snapshot(); if let Some(circuit) = shaped_devices_reader .devices .iter() diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/tree_capacity.rs b/src/rust/lqosd/src/node_manager/ws/ticker/tree_capacity.rs index 79be5824..6b680c25 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/tree_capacity.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/tree_capacity.rs @@ -1,7 +1,7 @@ use crate::node_manager::ws::messages::{NodeCapacity, WsResponse}; use crate::node_manager::ws::publish_subscribe::PubSub; use crate::node_manager::ws::published_channels::PublishedChannels; -use crate::shaped_devices_tracker::{NETWORK_JSON, node_to_transport}; +use crate::shaped_devices_tracker::node_to_transport; use std::sync::Arc; pub async fn tree_capacity(channels: Arc) { @@ -12,40 +12,42 @@ pub async fn tree_capacity(channels: Arc) { return; } let capacities: Vec = { - let net_json = NETWORK_JSON.read(); - net_json - .get_nodes_when_ready() - .iter() - .enumerate() - .map(|(id, node)| { - let node = node_to_transport(node); - let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0; - let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0; - let effective_max = node.effective_max_throughput.unwrap_or(node.max_throughput); - let max_down = effective_max.0; - let max_up = effective_max.1; - let median_rtt = if node.rtts.is_empty() { - 0.0 - } else { - let n = node.rtts.len() / 2; - if node.rtts.len().is_multiple_of(2) { - (node.rtts[n - 1] + node.rtts[n]) / 2.0 + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .enumerate() + .map(|(id, node)| { + let node = node_to_transport(node); + let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0; + let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0; + let effective_max = + node.effective_max_throughput.unwrap_or(node.max_throughput); + let max_down = effective_max.0; + let max_up = effective_max.1; + let median_rtt = if node.rtts.is_empty() { + 0.0 } else { - node.rtts[n] - } - }; + let n = node.rtts.len() / 2; + if node.rtts.len().is_multiple_of(2) { + (node.rtts[n - 1] + node.rtts[n]) / 2.0 + } else { + node.rtts[n] + } + }; - NodeCapacity { - id, - name: node.name.clone(), - down, - up, - max_down, - max_up, - median_rtt, - } - }) - .collect() + NodeCapacity { + id, + name: node.name.clone(), + down, + up, + max_down, + max_up, + median_rtt, + } + }) + .collect() + }) }; let message = WsResponse::TreeCapacity { data: capacities }; diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/tree_summary_l2.rs b/src/rust/lqosd/src/node_manager/ws/ticker/tree_summary_l2.rs index beb35304..b6fcbcd7 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/tree_summary_l2.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/tree_summary_l2.rs @@ -1,7 +1,7 @@ use crate::node_manager::ws::messages::WsResponse; use crate::node_manager::ws::publish_subscribe::PubSub; use crate::node_manager::ws::published_channels::PublishedChannels; -use crate::shaped_devices_tracker::{NETWORK_JSON, node_to_transport}; +use crate::shaped_devices_tracker::node_to_transport; use lqos_config::NetworkJsonTransport; use std::collections::BTreeMap; use std::sync::Arc; @@ -24,38 +24,39 @@ pub async fn tree_summary_l2(channels: Arc) { // Build top-N second-level children across all first-level parents let grouped: Vec<(usize, Vec<(usize, NetworkJsonTransport)>)> = { - let net_json = NETWORK_JSON.read(); - let nodes = net_json.get_nodes_when_ready(); - // Collect candidates as (parent_idx, child_idx, transport, total_bytes_per_sec) - let mut candidates: Vec<(usize, usize, NetworkJsonTransport, u64)> = Vec::new(); + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + // Collect candidates as (parent_idx, child_idx, transport, total_bytes_per_sec) + let mut candidates: Vec<(usize, usize, NetworkJsonTransport, u64)> = Vec::new(); - // Identify first-level parents (immediate_parent == Some(0)) - for (p_idx, p_node) in nodes.iter().enumerate() { - if p_node.immediate_parent == Some(0) { - // For each child-of-child under this parent - for (c_idx, c_node) in nodes.iter().enumerate() { - if c_node.immediate_parent == Some(p_idx) { - let t = node_to_transport(c_node); - let total = t.current_throughput.0 + t.current_throughput.1; - candidates.push((p_idx, c_idx, t, total)); + // Identify first-level parents (immediate_parent == Some(0)) + for (p_idx, p_node) in nodes.iter().enumerate() { + if p_node.immediate_parent == Some(0) { + // For each child-of-child under this parent + for (c_idx, c_node) in nodes.iter().enumerate() { + if c_node.immediate_parent == Some(p_idx) { + let t = node_to_transport(c_node); + let total = t.current_throughput.0 + t.current_throughput.1; + candidates.push((p_idx, c_idx, t, total)); + } } } } - } - // Sort by total throughput descending and cap to N - candidates.sort_by(|a, b| b.3.cmp(&a.3)); - let n: usize = 10; - if candidates.len() > n { - candidates.truncate(n); - } + // Sort by total throughput descending and cap to N + candidates.sort_by(|a, b| b.3.cmp(&a.3)); + let n: usize = 10; + if candidates.len() > n { + candidates.truncate(n); + } - // Group by parent id - let mut map: BTreeMap> = BTreeMap::new(); - for (p_idx, c_idx, t, _total) in candidates.into_iter() { - map.entry(p_idx).or_default().push((c_idx, t)); - } - map.into_iter().collect() + // Group by parent id + let mut map: BTreeMap> = BTreeMap::new(); + for (p_idx, c_idx, t, _total) in candidates.into_iter() { + map.entry(p_idx).or_default().push((c_idx, t)); + } + map.into_iter().collect() + }) }; let message = WsResponse::TreeSummaryL2 { data: grouped }; diff --git a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs index 2ba30e1c..9d5e5353 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs @@ -9,7 +9,6 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use super::{CIRCUIT_LIVE_LAST_REFRESH_SECS, CIRCUIT_LIVE_REFRESH_LOCK, CIRCUIT_LIVE_SNAPSHOT}; -use super::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}; /// Per-circuit live metrics aggregated from the device-level throughput tracker. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -104,8 +103,8 @@ pub fn rebuild_circuit_live_snapshot() -> Arc { }; let kernel_now: std::time::Duration = kernel_now.into(); - let shaped_devices = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); + let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let mut by_circuit_id: FxHashMap = FxHashMap::default(); for (ip_key, data) in THROUGHPUT_TRACKER.raw_data.lock().iter() { diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index 8610e29a..0bf9dae4 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -5,7 +5,7 @@ use arc_swap::ArcSwap; use fxhash::{FxHashMap, FxHashSet}; use lqos_bus::{BusResponse, Circuit}; use lqos_config::{ - ConfigShapedDevices, NetworkJsonNode, NetworkJsonTransport, ShapedDevice, + ConfigShapedDevices, NetworkJsonNode, NetworkJsonTransport, TopologyShapingInputsFile, load_config, topology_shaping_inputs_path, }; use lqos_queue_tracker::EFFECTIVE_NODE_RATES; @@ -21,82 +21,10 @@ use std::sync::atomic::AtomicU64; use std::time::Duration; use tracing::{debug, error, info, warn}; -// Removed rate_for_plan() function - no longer needed with f32 plan structures -const SHAPED_DEVICES_RELOAD_RETRY_DELAY_MS: u64 = 500; -const SHAPED_DEVICES_RELOAD_ATTEMPTS: usize = 2; - pub mod circuit_live; -mod netjson; use crate::throughput_tracker::THROUGHPUT_TRACKER; pub use circuit_live::CircuitLiveSnapshot; -pub use netjson::*; -pub static SHAPED_DEVICES: Lazy> = - Lazy::new(|| ArcSwap::new(Arc::new(ConfigShapedDevices::default()))); - -#[derive(Debug, Default)] -pub struct ShapedDeviceHashCache { - by_device_hash: FxHashMap, - by_circuit_hash: FxHashMap, -} - -impl ShapedDeviceHashCache { - fn from_devices(devices: &[ShapedDevice]) -> Self { - let mut by_device_hash = FxHashMap::default(); - by_device_hash.reserve(devices.len()); - let mut by_circuit_hash = FxHashMap::default(); - by_circuit_hash.reserve(devices.len()); - for (idx, dev) in devices.iter().enumerate() { - by_device_hash.insert(dev.device_hash, idx); - by_circuit_hash.entry(dev.circuit_hash).or_insert(idx); - } - Self { - by_device_hash, - by_circuit_hash, - } - } - - pub fn index_by_device_hash( - &self, - shaped: &ConfigShapedDevices, - device_hash: i64, - ) -> Option { - if let Some(idx) = self.by_device_hash.get(&device_hash).copied() - && shaped - .devices - .get(idx) - .is_some_and(|d| d.device_hash == device_hash) - { - return Some(idx); - } - shaped - .devices - .iter() - .position(|d| d.device_hash == device_hash) - } - - pub fn index_by_circuit_hash( - &self, - shaped: &ConfigShapedDevices, - circuit_hash: i64, - ) -> Option { - if let Some(idx) = self.by_circuit_hash.get(&circuit_hash).copied() - && shaped - .devices - .get(idx) - .is_some_and(|d| d.circuit_hash == circuit_hash) - { - return Some(idx); - } - shaped - .devices - .iter() - .position(|d| d.circuit_hash == circuit_hash) - } -} - -pub static SHAPED_DEVICE_HASH_CACHE: Lazy> = - Lazy::new(|| ArcSwap::new(Arc::new(ShapedDeviceHashCache::default()))); pub static CIRCUIT_LIVE_SNAPSHOT: Lazy> = Lazy::new(|| ArcSwap::new(Arc::new(CircuitLiveSnapshot::default()))); pub static CIRCUIT_LIVE_LAST_REFRESH_SECS: AtomicU64 = AtomicU64::new(0); @@ -286,128 +214,57 @@ fn build_network_tree_summaries( summaries } -fn publish_shaped_devices(new_file: ConfigShapedDevices) { - debug!("ShapedDevices.csv loaded"); - let cache = ShapedDeviceHashCache::from_devices(&new_file.devices); - SHAPED_DEVICES.store(Arc::new(new_file)); - SHAPED_DEVICE_HASH_CACHE.store(Arc::new(cache)); - invalidate_circuit_live_snapshot(); - invalidate_executive_cache_snapshot(); - let nj = NETWORK_JSON.read(); - crate::throughput_tracker::THROUGHPUT_TRACKER.refresh_circuit_ids(&nj); -} - -fn load_shaped_devices() { - debug!("ShapedDevices.csv has changed. Attempting to load it."); - for attempt in 1..=SHAPED_DEVICES_RELOAD_ATTEMPTS { - match ConfigShapedDevices::load() { - Ok(new_file) => { - publish_shaped_devices(new_file); - return; - } - Err(err) => { - if attempt < SHAPED_DEVICES_RELOAD_ATTEMPTS { - warn!( - "ShapedDevices.csv reload attempt {attempt}/{} failed: {err}. Retrying after {} ms.", - SHAPED_DEVICES_RELOAD_ATTEMPTS, SHAPED_DEVICES_RELOAD_RETRY_DELAY_MS - ); - std::thread::sleep(Duration::from_millis(SHAPED_DEVICES_RELOAD_RETRY_DELAY_MS)); - } else { - let current = SHAPED_DEVICES.load(); - warn!( - "ShapedDevices.csv reload failed after {} attempts: {err}. Keeping last-known-good data with {} devices.", - SHAPED_DEVICES_RELOAD_ATTEMPTS, - current.devices.len() - ); - } - } - } - } -} - -pub fn shaped_devices_watcher() -> Result<()> { - std::thread::Builder::new() - .name("ShapedDevices Watcher".to_string()) - .spawn(|| { - debug!("Watching for ShapedDevices.csv changes"); - if let Err(e) = watch_for_shaped_devices_changing() { - error!("Error watching for ShapedDevices.csv: {:?}", e); - } - })?; - Ok(()) -} - -/// Fires up a Linux file system watcher than notifies -/// when `ShapedDevices.csv` changes, and triggers a reload. -fn watch_for_shaped_devices_changing() -> Result<()> { - let watch_path = ConfigShapedDevices::path(); - if watch_path.is_err() { - error!("Unable to generate path for ShapedDevices.csv"); - return Err(anyhow::Error::msg( - "Unable to create path for ShapedDevices.csv", - )); - } - let watch_path = watch_path?; - - let mut watcher = FileWatcher::new("ShapedDevices.csv", watch_path); - watcher.set_file_exists_callback(load_shaped_devices); - watcher.set_file_created_callback(load_shaped_devices); - watcher.set_file_changed_callback(load_shaped_devices); - loop { - let result = watcher.watch(); - info!("ShapedDevices watcher returned: {result:?}"); - } -} - pub fn get_one_network_map_layer(parent_idx: usize) -> BusResponse { - let net_json = NETWORK_JSON.read(); - let nodes_ref = net_json.get_nodes_when_ready(); - let shaped_devices = SHAPED_DEVICES.load(); - let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); - if let Some(parent) = nodes_ref.get(parent_idx) { - let mut nodes = vec![( - parent_idx, - node_to_transport_with_summary( - parent, - summaries.get(parent_idx).copied().unwrap_or_default(), - ), - )]; - nodes.extend( - nodes_ref - .iter() - .enumerate() - .filter(|(_, node)| node.immediate_parent == Some(parent_idx)) - .map(|(i, node)| { - ( - i, - node_to_transport_with_summary( - node, - summaries.get(i).copied().unwrap_or_default(), - ), - ) - }), - ); - BusResponse::NetworkMap(nodes) - } else { - BusResponse::Fail("No such node".to_string()) - } + lqos_network_devices::with_network_json_read(|net_json| { + let nodes_ref = net_json.get_nodes_when_ready(); + let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); + let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); + if let Some(parent) = nodes_ref.get(parent_idx) { + let mut nodes = vec![( + parent_idx, + node_to_transport_with_summary( + parent, + summaries.get(parent_idx).copied().unwrap_or_default(), + ), + )]; + nodes.extend( + nodes_ref + .iter() + .enumerate() + .filter(|(_, node)| node.immediate_parent == Some(parent_idx)) + .map(|(i, node)| { + ( + i, + node_to_transport_with_summary( + node, + summaries.get(i).copied().unwrap_or_default(), + ), + ) + }), + ); + BusResponse::NetworkMap(nodes) + } else { + BusResponse::Fail("No such node".to_string()) + } + }) } pub fn full_network_map_snapshot() -> Vec<(usize, NetworkJsonTransport)> { - let nj = NETWORK_JSON.read(); - let nodes = nj.get_nodes_when_ready(); - let shaped_devices = SHAPED_DEVICES.load(); - let summaries = build_network_tree_summaries(nodes, shaped_devices.as_ref()); - nodes - .iter() - .enumerate() - .map(|(i, n)| { - ( - i, - node_to_transport_with_summary(n, summaries.get(i).copied().unwrap_or_default()), - ) - }) - .collect() + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); + let summaries = build_network_tree_summaries(nodes, shaped_devices.as_ref()); + nodes + .iter() + .enumerate() + .map(|(i, n)| { + ( + i, + node_to_transport_with_summary(n, summaries.get(i).copied().unwrap_or_default()), + ) + }) + .collect() + }) } fn node_to_transport_lite(node: &NetworkJsonNode) -> NetworkTreeLiteNode { @@ -470,13 +327,14 @@ fn node_to_transport_lite(node: &NetworkJsonNode) -> NetworkTreeLiteNode { /// Returns a lightweight live snapshot of the network tree for pages that do not need the full /// `NetworkJsonTransport` payload. pub fn full_network_map_lite_snapshot() -> Vec<(usize, NetworkTreeLiteNode)> { - let nj = NETWORK_JSON.read(); - let nodes = nj.get_nodes_when_ready(); - nodes - .iter() - .enumerate() - .map(|(i, n)| (i, node_to_transport_lite(n))) - .collect() + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + nodes + .iter() + .enumerate() + .map(|(i, n)| (i, node_to_transport_lite(n))) + .collect() + }) } pub fn get_full_network_map() -> BusResponse { @@ -484,42 +342,45 @@ pub fn get_full_network_map() -> BusResponse { } pub fn get_top_n_root_queues(n_queues: usize) -> BusResponse { - let net_json = NETWORK_JSON.read(); - let nodes_ref = net_json.get_nodes_when_ready(); - let shaped_devices = SHAPED_DEVICES.load(); - let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); - if let Some(parent) = nodes_ref.first() { - let mut nodes = vec![( - 0, - node_to_transport_with_summary(parent, summaries.first().copied().unwrap_or_default()), - )]; - nodes.extend( - nodes_ref - .iter() - .enumerate() - .filter(|(idx, node)| *idx != 0 && node.immediate_parent == Some(0)) - .map(|(idx, node)| { - ( - idx, - node_to_transport_with_summary( - node, - summaries.get(idx).copied().unwrap_or_default(), - ), - ) - }), - ); - // Remove the top-level entry for root - nodes.remove(0); - // Sort by total bandwidth (up + down) descending - nodes.sort_by(|a, b| { - let total_a = a.1.current_throughput.0 + a.1.current_throughput.1; - let total_b = b.1.current_throughput.0 + b.1.current_throughput.1; - total_b.cmp(&total_a) - }); - // Summarize everything after n_queues - if nodes.len() > n_queues { - let mut other_bw = (0, 0); - let mut other_packets = (0, 0); + lqos_network_devices::with_network_json_read(|net_json| { + let nodes_ref = net_json.get_nodes_when_ready(); + let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); + let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); + if let Some(parent) = nodes_ref.first() { + let mut nodes = vec![( + 0, + node_to_transport_with_summary( + parent, + summaries.first().copied().unwrap_or_default(), + ), + )]; + nodes.extend( + nodes_ref + .iter() + .enumerate() + .filter(|(idx, node)| *idx != 0 && node.immediate_parent == Some(0)) + .map(|(idx, node)| { + ( + idx, + node_to_transport_with_summary( + node, + summaries.get(idx).copied().unwrap_or_default(), + ), + ) + }), + ); + // Remove the top-level entry for root + nodes.remove(0); + // Sort by total bandwidth (up + down) descending + nodes.sort_by(|a, b| { + let total_a = a.1.current_throughput.0 + a.1.current_throughput.1; + let total_b = b.1.current_throughput.0 + b.1.current_throughput.1; + total_b.cmp(&total_a) + }); + // Summarize everything after n_queues + if nodes.len() > n_queues { + let mut other_bw = (0, 0); + let mut other_packets = (0, 0); let mut other_tcp_packets = (0, 0); let mut other_tcp_retransmit_packets = (0, 0); let mut other_udp_packets = (0, 0); @@ -581,21 +442,23 @@ pub fn get_top_n_root_queues(n_queues: usize) -> BusResponse { }, )); } - BusResponse::NetworkMap(nodes) - } else { - BusResponse::Fail("No such node".to_string()) - } + BusResponse::NetworkMap(nodes) + } else { + BusResponse::Fail("No such node".to_string()) + } + }) } pub fn map_node_names(nodes: &[usize]) -> BusResponse { - let mut result = Vec::new(); - let reader = NETWORK_JSON.read(); - nodes.iter().for_each(|id| { - if let Some(node) = reader.get_nodes_when_ready().get(*id) { - result.push((*id, node.name.clone())); - } - }); - BusResponse::NodeNames(result) + lqos_network_devices::with_network_json_read(|net_json| { + let mut result = Vec::new(); + nodes.iter().for_each(|id| { + if let Some(node) = net_json.get_nodes_when_ready().get(*id) { + result.push((*id, node.name.clone())); + } + }); + BusResponse::NodeNames(result) + }) } /// Canonical parent-node metadata resolved from `network.json`. @@ -618,36 +481,36 @@ pub fn resolve_parent_node_reference( if trimmed.is_empty() && trimmed_id.is_none() { return None; } + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); - - if let Some(parent_node_id) = trimmed_id - && let Some(node) = nodes - .iter() - .find(|node| node.id.as_deref() == Some(parent_node_id)) - { - return Some(ResolvedParentNode { - name: node.name.clone(), - id: node.id.clone(), - }); - } - - if let Some(node) = nodes.iter().find(|node| node.name == trimmed) { - return Some(ResolvedParentNode { - name: node.name.clone(), - id: node.id.clone(), - }); - } - - nodes.iter().find_map(|node| { - node.active_attachment_name - .as_deref() - .filter(|alias| alias.trim() == trimmed) - .map(|_| ResolvedParentNode { + if let Some(parent_node_id) = trimmed_id + && let Some(node) = nodes + .iter() + .find(|node| node.id.as_deref() == Some(parent_node_id)) + { + return Some(ResolvedParentNode { name: node.name.clone(), id: node.id.clone(), - }) + }); + } + + if let Some(node) = nodes.iter().find(|node| node.name == trimmed) { + return Some(ResolvedParentNode { + name: node.name.clone(), + id: node.id.clone(), + }); + } + + nodes.iter().find_map(|node| { + node.active_attachment_name + .as_deref() + .filter(|alias| alias.trim() == trimmed) + .map(|_| ResolvedParentNode { + name: node.name.clone(), + id: node.id.clone(), + }) + }) }) } @@ -662,31 +525,29 @@ pub fn resolve_parent_node_alias(parent_node: &str) -> Option { } pub fn get_funnel(circuit_id: &str) -> BusResponse { - let reader = NETWORK_JSON.read(); - if let Some(index) = reader.get_index_for_name(circuit_id) { - // Reverse the scanning order and skip the last entry (the parent) - let mut result = Vec::new(); - for idx in reader.get_nodes_when_ready()[index] - .parents - .iter() - .rev() - .skip(1) - { - result.push(( - *idx, - node_to_transport(&reader.get_nodes_when_ready()[*idx]), - )); + lqos_network_devices::with_network_json_read(|net_json| { + if let Some(index) = net_json.get_index_for_name(circuit_id) { + // Reverse the scanning order and skip the last entry (the parent) + let mut result = Vec::new(); + for idx in net_json.get_nodes_when_ready()[index] + .parents + .iter() + .rev() + .skip(1) + { + result.push((*idx, node_to_transport(&net_json.get_nodes_when_ready()[*idx]))); + } + return BusResponse::NetworkMap(result); } - return BusResponse::NetworkMap(result); - } - BusResponse::Fail("Unknown Node".into()) + BusResponse::Fail("Unknown Node".into()) + }) } pub fn get_all_circuits() -> BusResponse { if let Ok(kernel_now) = time_since_boot() { - let devices = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let devices = lqos_network_devices::shaped_devices_snapshot(); + let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let data = THROUGHPUT_TRACKER .raw_data .lock() @@ -805,8 +666,8 @@ pub fn get_all_circuits() -> BusResponse { pub fn get_circuit_by_id(desired_circuit_id: String) -> BusResponse { if let Ok(kernel_now) = time_since_boot() { let desired_hash = hash_to_i64(&desired_circuit_id); - let devices = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let devices = lqos_network_devices::shaped_devices_snapshot(); + let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let data = THROUGHPUT_TRACKER .raw_data .lock() diff --git a/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs b/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs deleted file mode 100644 index 5b263925..00000000 --- a/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs +++ /dev/null @@ -1,53 +0,0 @@ -use anyhow::Result; -use lqos_config::NetworkJson; -use lqos_utils::file_watcher::FileWatcher; -use once_cell::sync::Lazy; -use parking_lot::RwLock; -use tracing::{debug, error, info, warn}; - -pub static NETWORK_JSON: Lazy> = - Lazy::new(|| RwLock::new(NetworkJson::default())); - -pub fn network_json_watcher() -> Result<()> { - std::thread::Builder::new() - .name("NetJson Watcher".to_string()) - .spawn(|| { - debug!("Watching for network.kson changes"); - if let Err(e) = watch_for_network_json_changing() { - error!("Error watching for network.json changes: {:?}", e); - } - })?; - Ok(()) -} - -/// Fires up a Linux file system watcher that notifies -/// when `network.json` changes and triggers a reload. -fn watch_for_network_json_changing() -> Result<()> { - let watch_path = NetworkJson::path(); - let Ok(watch_path) = watch_path else { - error!("Unable to generate path for network.json"); - return Err(anyhow::Error::msg("Unable to create path for network.json")); - }; - - let mut watcher = FileWatcher::new("network.json", watch_path); - watcher.set_file_exists_callback(load_network_json); - watcher.set_file_created_callback(load_network_json); - watcher.set_file_changed_callback(load_network_json); - loop { - let result = watcher.watch(); - info!("network.json watcher returned: {result:?}"); - } -} - -fn load_network_json() { - let njs = NetworkJson::load(); - if let Ok(njs) = njs { - let mut nj = NETWORK_JSON.write(); - *nj = njs; - super::invalidate_circuit_live_snapshot(); - super::invalidate_executive_cache_snapshot(); - crate::throughput_tracker::THROUGHPUT_TRACKER.refresh_circuit_ids(&nj); - } else { - warn!("Unable to load network.json"); - } -} diff --git a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs index 854abbf0..13cb7e0a 100644 --- a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs +++ b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs @@ -2,7 +2,6 @@ use super::{ FlowAnalysis, FlowbeeEffectiveDirection, get_asn_lat_lon, get_asn_name_and_country, get_asn_name_by_id, }; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::flow_data::FlowbeeLocalData; use allocative_derive::Allocative; use crossbeam_channel::Sender; @@ -476,8 +475,7 @@ fn enqueue(key: FlowbeeKey, data: FlowbeeLocalData, analysis: FlowAnalysis) { .get(&key.local_ip) .and_then(|te| te.circuit_hash) .or_else(|| { - SHAPED_DEVICES - .load() + lqos_network_devices::shaped_devices_snapshot() .get_circuit_hash_from_ip(&key.local_ip) }) }); diff --git a/src/rust/lqosd/src/throughput_tracker/mod.rs b/src/rust/lqosd/src/throughput_tracker/mod.rs index d83898f0..6e06acd2 100644 --- a/src/rust/lqosd/src/throughput_tracker/mod.rs +++ b/src/rust/lqosd/src/throughput_tracker/mod.rs @@ -11,7 +11,6 @@ use crate::system_stats::SystemStats; use crate::throughput_tracker::flow_data::FlowbeeEffectiveDirection; use crate::{ lts2_sys::{get_lts_license_status, shared_types::LtsStatus}, - shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}, stats::TIME_TO_POLL_HOSTS, throughput_tracker::tracking_data::{FlowApplyContext, ThroughputTracker}, }; @@ -190,59 +189,61 @@ fn throughput_task( // Formerly a "spawn blocking" blob { - let mut net_json_calc = NETWORK_JSON.write(); - timer_metrics.update_cycle = timer_metrics.start.elapsed().as_secs_f64(); - net_json_calc.zero_throughput_and_rtt(); - timer_metrics.zero_throughput_and_rtt = timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER.copy_previous_and_reset_rtt(); - timer_metrics.copy_previous_and_reset_rtt = timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER - .apply_new_throughput_counters(&mut net_json_calc, bakery_sender.clone()); - timer_metrics.apply_new_throughput_counters = - timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER.apply_flow_data(FlowApplyContext { - timeout_seconds, - sender: netflow_sender.clone(), - net_json_calc: &mut net_json_calc, - rtt_circuit_tracker: &mut rtt_circuit_tracker, - rtt_by_circuit: &mut rtt_by_circuit, - tcp_retries: &mut tcp_retries, - tcp_retry_packets: &mut tcp_retry_packets, - expired_keys: &mut expired_flows, + lqos_network_devices::with_network_json_write(|net_json_calc| { + timer_metrics.update_cycle = timer_metrics.start.elapsed().as_secs_f64(); + net_json_calc.zero_throughput_and_rtt(); + timer_metrics.zero_throughput_and_rtt = + timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER.copy_previous_and_reset_rtt(); + timer_metrics.copy_previous_and_reset_rtt = + timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER + .apply_new_throughput_counters(net_json_calc, bakery_sender.clone()); + timer_metrics.apply_new_throughput_counters = + timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER.apply_flow_data(FlowApplyContext { + timeout_seconds, + sender: netflow_sender.clone(), + net_json_calc, + rtt_circuit_tracker: &mut rtt_circuit_tracker, + rtt_by_circuit: &mut rtt_by_circuit, + tcp_retries: &mut tcp_retries, + tcp_retry_packets: &mut tcp_retry_packets, + expired_keys: &mut expired_flows, + }); + CIRCUIT_RTT_BUFFERS.store(Arc::new(rtt_by_circuit.clone())); + THROUGHPUT_TRACKER.record_circuit_heatmaps(); + let enable_site_heatmaps = lqos_config::load_config() + .map(|config| config.enable_site_heatmaps) + .unwrap_or(true); + net_json_calc.record_site_heatmaps(enable_site_heatmaps); + + // Clean up work tables + rtt_circuit_tracker.clear(); + rtt_by_circuit.clear(); + tcp_retries.clear(); + tcp_retry_packets.clear(); + expired_flows.clear(); + rtt_circuit_tracker.shrink_to_fit(); + rtt_by_circuit.shrink_to_fit(); + tcp_retries.shrink_to_fit(); + tcp_retry_packets.shrink_to_fit(); + expired_flows.shrink_to_fit(); + + timer_metrics.apply_flow_data = timer_metrics.start.elapsed().as_secs_f64(); + if bakery_reload_in_progress { + debug!( + "Throughput monitor: skipping queue-stat application during Bakery full reload" + ); + } else { + THROUGHPUT_TRACKER.apply_queue_stats(net_json_calc); + } + timer_metrics.apply_queue_stats = timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER.update_totals(); + timer_metrics.update_totals = timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER.next_cycle(); + timer_metrics.next_cycle = timer_metrics.start.elapsed().as_secs_f64(); }); - CIRCUIT_RTT_BUFFERS.store(Arc::new(rtt_by_circuit.clone())); - THROUGHPUT_TRACKER.record_circuit_heatmaps(); - let enable_site_heatmaps = lqos_config::load_config() - .map(|config| config.enable_site_heatmaps) - .unwrap_or(true); - net_json_calc.record_site_heatmaps(enable_site_heatmaps); - - // Clean up work tables - rtt_circuit_tracker.clear(); - rtt_by_circuit.clear(); - tcp_retries.clear(); - tcp_retry_packets.clear(); - expired_flows.clear(); - rtt_circuit_tracker.shrink_to_fit(); - rtt_by_circuit.shrink_to_fit(); - tcp_retries.shrink_to_fit(); - tcp_retry_packets.shrink_to_fit(); - expired_flows.shrink_to_fit(); - - timer_metrics.apply_flow_data = timer_metrics.start.elapsed().as_secs_f64(); - if bakery_reload_in_progress { - debug!( - "Throughput monitor: skipping queue-stat application during Bakery full reload" - ); - } else { - THROUGHPUT_TRACKER.apply_queue_stats(&mut net_json_calc); - } - timer_metrics.apply_queue_stats = timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER.update_totals(); - timer_metrics.update_totals = timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER.next_cycle(); - timer_metrics.next_cycle = timer_metrics.start.elapsed().as_secs_f64(); - std::mem::drop(net_json_calc); timer_metrics.finish_update_cycle = timer_metrics.start.elapsed().as_secs_f64(); let duration_ms = start.elapsed().as_micros(); TIME_TO_POLL_HOSTS.store(duration_ms as u64, std::sync::atomic::Ordering::Relaxed); @@ -453,7 +454,7 @@ pub fn circuit_heatmaps() -> BusResponse { return BusResponse::CircuitHeatmaps(Vec::new()); } - let devices = SHAPED_DEVICES.load(); + let devices = lqos_network_devices::shaped_devices_snapshot(); let mut circuit_meta: FxHashMap = FxHashMap::default(); devices.devices.iter().for_each(|device| { circuit_meta @@ -492,25 +493,26 @@ pub fn site_heatmaps() -> BusResponse { return BusResponse::SiteHeatmaps(Vec::new()); } - let reader = NETWORK_JSON.read(); - let mut rows: Vec = reader - .get_nodes_when_ready() - .iter() - .filter_map(|node| { - if node.name == "Root" || node.name.parse::().is_ok() { - return None; - } - node.heatmap.as_ref().map(|heatmap| SiteHeatmapData { - site_name: node.name.clone(), - node_type: node.node_type.clone(), - depth: node.parents.len().saturating_sub(1), - blocks: heatmap.blocks(), - qoq_blocks: node.qoq_heatmap.as_ref().map(|heatmap| heatmap.blocks()), + lqos_network_devices::with_network_json_read(|net_json| { + let mut rows: Vec = net_json + .get_nodes_when_ready() + .iter() + .filter_map(|node| { + if node.name == "Root" || node.name.parse::().is_ok() { + return None; + } + node.heatmap.as_ref().map(|heatmap| SiteHeatmapData { + site_name: node.name.clone(), + node_type: node.node_type.clone(), + depth: node.parents.len().saturating_sub(1), + blocks: heatmap.blocks(), + qoq_blocks: node.qoq_heatmap.as_ref().map(|heatmap| heatmap.blocks()), + }) }) - }) - .collect(); - rows.sort_by(|a, b| a.site_name.cmp(&b.site_name)); - BusResponse::SiteHeatmaps(rows) + .collect(); + rows.sort_by(|a, b| a.site_name.cmp(&b.site_name)); + BusResponse::SiteHeatmaps(rows) + }) } /// Retrieve per-ASN heatmap data for the executive summary. @@ -891,7 +893,7 @@ fn current_host_counts() -> (u32, u32) { /// Gather headline metrics for the Executive Summary header cards. pub fn executive_summary_header() -> BusResponse { - let devices = SHAPED_DEVICES.load(); + let devices = lqos_network_devices::shaped_devices_snapshot(); let circuit_count = devices .devices .iter() @@ -900,12 +902,11 @@ pub fn executive_summary_header() -> BusResponse { .len() as u64; let device_count = devices.devices.len() as u64; - let site_count = { - let reader = NETWORK_JSON.read(); - let total_nodes = reader.get_nodes_when_ready().len(); + let site_count = lqos_network_devices::with_network_json_read(|net_json| { + let total_nodes = net_json.get_nodes_when_ready().len(); // Remove the synthetic root node when counting sites. total_nodes.saturating_sub(1) as u64 - }; + }); let (total_hosts, shaped_hosts) = current_host_counts(); let mapped_ip_count = shaped_hosts as u64; @@ -1109,8 +1110,8 @@ pub fn top_flows(n: u32, flow_type: TopFlowType) -> BusResponse { } } - let shaped = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); + let shaped_cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); let result = table @@ -1181,8 +1182,8 @@ pub fn flows_by_ip(ip: &str) -> BusResponse { let ip = XdpIpAddress::from_ip(ip); let lock = ALL_FLOWS.lock(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); - let shaped = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); + let shaped_cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let (circuit_id, circuit_name) = { let mut circuit_id = String::new(); let mut circuit_name = String::new(); diff --git a/src/rust/lqosd/src/throughput_tracker/stats_submission.rs b/src/rust/lqosd/src/throughput_tracker/stats_submission.rs index d3724822..007cd8c9 100644 --- a/src/rust/lqosd/src/throughput_tracker/stats_submission.rs +++ b/src/rust/lqosd/src/throughput_tracker/stats_submission.rs @@ -1,6 +1,5 @@ use crate::lts2_sys::get_lts_license_status; use crate::lts2_sys::shared_types::{CircuitCakeDrops, CircuitCakeMarks, LtsStatus}; -use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; use crate::system_stats::SystemStats; use crate::throughput_tracker::flow_data::ALL_FLOWS; use crate::throughput_tracker::flow_data::FlowbeeEffectiveDirection; @@ -288,7 +287,7 @@ pub(crate) fn submit_throughput_stats( let mut circuit_throughput: FxHashMap = FxHashMap::default(); let mut circuit_retransmits: FxHashMap> = FxHashMap::default(); - let shaped_devices = SHAPED_DEVICES.load(); + let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); const CRAZY_LIMIT: u64 = 8; // 8x the max bandwidth let plan_lookup: FxHashMap = shaped_devices .devices @@ -453,10 +452,8 @@ pub(crate) fn submit_throughput_stats( } // Network tree stats - let tree = { - let reader = NETWORK_JSON.read(); - reader.get_nodes_when_ready().clone() - }; + let tree = + lqos_network_devices::with_network_json_read(|net_json| net_json.get_nodes_when_ready().clone()); let mut site_throughput: Vec = Vec::new(); let mut site_retransmits: Vec = Vec::new(); let mut site_rtt: Vec = Vec::new(); diff --git a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs index f052943f..d7c48e55 100644 --- a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs +++ b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs @@ -8,7 +8,6 @@ use super::{ }; use crate::throughput_tracker::CIRCUIT_RTT_BUFFERS; use crate::{ - shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}, stats::HIGH_WATERMARK, throughput_tracker::flow_data::{FlowbeeEffectiveDirection, expire_rtt_flows, flowbee_rtt_map}, }; @@ -172,7 +171,7 @@ impl ThroughputTracker { return; } - let shaped_devices = SHAPED_DEVICES.load(); + let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); let mut capacity_lookup: FxHashMap = FxHashMap::default(); capacity_lookup.reserve(shaped_devices.devices.len()); shaped_devices.devices.iter().for_each(|device| { @@ -405,7 +404,7 @@ impl ThroughputTracker { fn shaped_device_for_hashes<'a>( shaped: &'a lqos_config::ConfigShapedDevices, - cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache, + cache: &lqos_network_devices::ShapedDeviceHashCache, device_hash: Option, circuit_hash: Option, ) -> Option<&'a lqos_config::ShapedDevice> { @@ -424,7 +423,7 @@ impl ThroughputTracker { fn lookup_network_parents_from_hashes( shaped: &lqos_config::ConfigShapedDevices, - cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache, + cache: &lqos_network_devices::ShapedDeviceHashCache, device_hash: Option, circuit_hash: Option, lock: &NetworkJson, @@ -434,8 +433,8 @@ impl ThroughputTracker { } pub(crate) fn refresh_circuit_ids(&self, lock: &NetworkJson) { - let shaped = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); + let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let mut raw_data = self.raw_data.lock(); raw_data.iter_mut().for_each(|(_key, data)| { let shaped_device = Self::shaped_device_for_hashes( @@ -463,8 +462,8 @@ impl ThroughputTracker { let mut changed_circuits = HashSet::new(); let self_cycle = self.cycle.load(std::sync::atomic::Ordering::Relaxed); - let shaped = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); + let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); let mut raw_data = self.raw_data.lock(); throughput_for_each(&mut |xdp_ip, counts| { let reduced = ReducedHostCounters::from_counters(counts); diff --git a/src/rust/lqosd/src/treeguard/actor.rs b/src/rust/lqosd/src/treeguard/actor.rs index c0447d45..8bc93ae8 100644 --- a/src/rust/lqosd/src/treeguard/actor.rs +++ b/src/rust/lqosd/src/treeguard/actor.rs @@ -5,7 +5,6 @@ use crate::node_manager::ws::messages::{TreeguardActivityEntry, TreeguardStatusData}; use crate::shaped_devices_tracker::circuit_live::fresh_circuit_live_snapshot; -use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; use crate::system_stats::SystemStats; use crate::throughput_tracker::CIRCUIT_RTT_BUFFERS; use crate::treeguard::TreeguardError; @@ -269,16 +268,17 @@ fn empty_status_snapshot() -> TreeguardStatusData { fn current_topology_totals() -> (usize, usize) { let total_nodes = { - let reader = NETWORK_JSON.read(); - reader - .get_nodes_when_ready() - .iter() - .filter(|n| n.name != "Root") - .count() + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .filter(|n| n.name != "Root") + .count() + }) }; let total_circuits = { - let shaped = SHAPED_DEVICES.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); let mut circuits: FxHashSet<&str> = FxHashSet::default(); for d in shaped.devices.iter() { let id = d.circuit_id.trim(); @@ -837,7 +837,7 @@ fn run_tick( return; } - let shaped = SHAPED_DEVICES.load(); + let shaped = lqos_network_devices::shaped_devices_snapshot(); ensure_circuit_inventory(runtime_state, &shaped); let link_states = &mut runtime_state.link_states; @@ -898,12 +898,13 @@ fn run_tick( } else { let mut enrolled: FxHashSet = tg.links.nodes.iter().cloned().collect(); if top_level_auto_virtualize { - let reader = NETWORK_JSON.read(); - for node in reader.get_nodes_when_ready().iter() { - if node.name != "Root" && node.immediate_parent == Some(0) { - enrolled.insert(node.name.clone()); + lqos_network_devices::with_network_json_read(|net_json| { + for node in net_json.get_nodes_when_ready().iter() { + if node.name != "Root" && node.immediate_parent == Some(0) { + enrolled.insert(node.name.clone()); + } } - } + }); } enrolled.len() }; @@ -1029,10 +1030,10 @@ fn run_tick( link_states.remove(&node_name); } } else { - let reader = NETWORK_JSON.read(); + lqos_network_devices::with_network_json_read(|net_json| { let top_level_nodes: FxHashSet = if top_level_auto_virtualize && !tg.links.all_nodes { - reader + net_json .get_nodes_when_ready() .iter() .filter(|n| n.name != "Root" && n.immediate_parent == Some(0)) @@ -1059,7 +1060,7 @@ fn run_tick( .unwrap_or_default(); let mut removed: FxHashSet = if tg.links.all_nodes { - let current: FxHashSet<&str> = reader + let current: FxHashSet<&str> = net_json .get_nodes_when_ready() .iter() .filter(|n| n.name != "Root") @@ -1078,7 +1079,7 @@ fn run_tick( .collect() }; if tg.links.all_nodes { - let current: FxHashSet<&str> = reader + let current: FxHashSet<&str> = net_json .get_nodes_when_ready() .iter() .filter(|n| n.name != "Root") @@ -1121,7 +1122,7 @@ fn run_tick( managed_nodes.remove(&node_name); link_states.remove(&node_name); } - let nodes = reader.get_nodes_when_ready(); + let nodes = net_json.get_nodes_when_ready(); let parent_by_index: Vec> = nodes.iter().map(|node| node.immediate_parent).collect(); let subtree_node_counts = build_subtree_node_counts(&parent_by_index); @@ -1193,7 +1194,7 @@ fn run_tick( continue; } - let Some(index) = reader.get_index_for_name(node_name) else { + let Some(index) = net_json.get_index_for_name(node_name) else { status.warnings.push(format!( "TreeGuard links allowlist: node '{node_name}' not found in network.json." )); @@ -1564,6 +1565,7 @@ fn run_tick( link_virtualization_backoff_until_unix, ); } + }); } // --- Circuit sampling + decisions (SQM switching) --- @@ -3271,7 +3273,6 @@ mod tests { try_consume_circuit_change_budget, }; use crate::node_manager::ws::messages::TreeguardActivityEntry; - use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; use crate::system_stats::SystemStats; use crate::throughput_tracker::CIRCUIT_RTT_BUFFERS; use crate::treeguard::decisions; @@ -4197,11 +4198,14 @@ mod tests { }]; let mut shaped = ConfigShapedDevices::default(); shaped.replace_with_new_data(devices.clone()); - let old_shaped = SHAPED_DEVICES.load_full(); - SHAPED_DEVICES.store(Arc::new(shaped)); + let old_shaped = lqos_network_devices::swap_shaped_devices_snapshot( + "treeguard:test:run_tick_end_to_end", + Arc::new(shaped), + ); - let old_network = NETWORK_JSON.read().nodes.clone(); - NETWORK_JSON.write().nodes = Vec::new(); + let old_network = + lqos_network_devices::with_network_json_read(|net_json| net_json.nodes.clone()); + lqos_network_devices::with_network_json_write(|net_json| net_json.nodes = Vec::new()); let old_queue_structure = QUEUE_STRUCTURE.load_full(); QUEUE_STRUCTURE.store(Arc::new(QueueStructure { @@ -4310,8 +4314,11 @@ mod tests { assert_eq!(circuit_hash, lqos_utils::hash_to_i64("circuit-3")); assert_eq!(sqm_override, Some("fq_codel/fq_codel".to_string())); - SHAPED_DEVICES.store(old_shaped); - NETWORK_JSON.write().nodes = old_network; + let _ = lqos_network_devices::swap_shaped_devices_snapshot( + "treeguard:test:run_tick_end_to_end:restore", + old_shaped, + ); + lqos_network_devices::with_network_json_write(|net_json| net_json.nodes = old_network); QUEUE_STRUCTURE.store(old_queue_structure); CIRCUIT_RTT_BUFFERS.store(old_rtt); } diff --git a/src/rust/lqosd/src/validation.rs b/src/rust/lqosd/src/validation.rs index f604c548..a32c141b 100644 --- a/src/rust/lqosd/src/validation.rs +++ b/src/rust/lqosd/src/validation.rs @@ -1,8 +1,7 @@ use lqos_bus::BusResponse; -use lqos_config::ConfigShapedDevices; pub fn validate_shaped_devices_csv() -> BusResponse { - let result = ConfigShapedDevices::load(); + let result = lqos_network_devices::load_shaped_devices(); match result { Ok(..) => BusResponse::Ack, Err(e) => BusResponse::ShapedDevicesValidation(format!("{e:#?}")), From f02b72affcbd915b47706640bdb92b019920b286 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 11 Apr 2026 08:35:28 -0500 Subject: [PATCH 02/20] refactor: add shaped-devices catalog API --- src/rust/Cargo.lock | 1 + src/rust/lqos_network_devices/Cargo.toml | 2 +- src/rust/lqos_network_devices/README.md | 17 +- src/rust/lqos_network_devices/src/actor.rs | 12 +- src/rust/lqos_network_devices/src/catalog.rs | 182 ++++ .../src/directory_watcher.rs | 12 +- .../lqos_network_devices/src/hash_cache.rs | 1 - src/rust/lqos_network_devices/src/lib.rs | 15 + src/rust/lqos_network_devices/src/state.rs | 55 +- src/rust/lqos_network_devices/src/tests.rs | 97 ++ src/rust/lqos_network_devices/src/topology.rs | 64 ++ .../lqosd/src/lts2_sys/control_channel.rs | 62 +- src/rust/lqosd/src/main.rs | 7 +- src/rust/lqosd/src/network_devices_hooks.rs | 1 - .../src/node_manager/local_api/circuit.rs | 20 +- .../local_api/circuit_activity.rs | 17 +- .../node_manager/local_api/circuit_count.rs | 12 +- .../src/node_manager/local_api/config.rs | 20 +- .../node_manager/local_api/cpu_affinity.rs | 4 +- .../node_manager/local_api/device_counts.rs | 2 +- .../src/node_manager/local_api/directories.rs | 8 +- .../node_manager/local_api/ethernet_caps.rs | 4 +- .../node_manager/local_api/executive_cache.rs | 20 +- .../node_manager/local_api/flow_explorer.rs | 14 +- .../src/node_manager/local_api/search.rs | 29 +- .../local_api/shaped_device_api.rs | 2 +- .../local_api/shaped_devices_page.rs | 7 +- src/rust/lqosd/src/node_manager/template.rs | 10 +- .../ws/single_user_channels/circuit.rs | 18 +- .../ws/ticker/circuit_capacity.rs | 7 +- .../ws/ticker/ipstats_conversion.rs | 7 +- .../shaped_devices_tracker/circuit_live.rs | 13 +- .../lqosd/src/shaped_devices_tracker/mod.rs | 237 ++--- .../flow_data/flow_analysis/finished_flows.rs | 3 +- src/rust/lqosd/src/throughput_tracker/mod.rs | 40 +- .../throughput_tracker/stats_submission.rs | 12 +- .../src/throughput_tracker/tracking_data.rs | 52 +- src/rust/lqosd/src/treeguard/actor.rs | 988 +++++++++--------- 38 files changed, 1150 insertions(+), 924 deletions(-) create mode 100644 src/rust/lqos_network_devices/src/catalog.rs create mode 100644 src/rust/lqos_network_devices/src/tests.rs create mode 100644 src/rust/lqos_network_devices/src/topology.rs diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 98d0433d..700f8e58 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2117,6 +2117,7 @@ dependencies = [ "arc-swap", "crossbeam-channel", "fxhash", + "ip_network", "lqos_config", "lqos_utils", "once_cell", diff --git a/src/rust/lqos_network_devices/Cargo.toml b/src/rust/lqos_network_devices/Cargo.toml index c3ae59dc..0d2d4e80 100644 --- a/src/rust/lqos_network_devices/Cargo.toml +++ b/src/rust/lqos_network_devices/Cargo.toml @@ -9,10 +9,10 @@ anyhow = { workspace = true } arc-swap = { workspace = true } crossbeam-channel = { workspace = true } fxhash = "0.2.1" +ip_network = { workspace = true } lqos_config = { path = "../lqos_config" } lqos_utils = { path = "../lqos_utils" } once_cell = { workspace = true } parking_lot = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } - diff --git a/src/rust/lqos_network_devices/README.md b/src/rust/lqos_network_devices/README.md index 84929567..2f12b450 100644 --- a/src/rust/lqos_network_devices/README.md +++ b/src/rust/lqos_network_devices/README.md @@ -18,14 +18,27 @@ private static. ## Public API (high level) - `start_daemon_mode(hooks)`: starts actor + directory watcher. -- `shaped_devices_snapshot()`: `Arc` snapshot. -- `shaped_device_hash_cache_snapshot()`: hash→index cache snapshot (device/circuit hash lookups). +- `shaped_devices_catalog()`: preferred shaped-devices snapshot handle with common lookup helpers. +- `shaped_devices_snapshot()`: low-level `Arc` snapshot. +- `shaped_device_hash_cache_snapshot()`: low-level hash→index cache snapshot (device/circuit hash lookups). - `with_network_json_read(|net_json| ...)`: read-only access to in-memory `NetworkJson`. - `with_network_json_write(|net_json| ...)`: mutable access to in-memory `NetworkJson` (used by runtime counters). +- `resolve_parent_node_reference(parent_node, parent_node_id)`: canonicalize shaped-device parent references against `network.json`. - `request_reload_shaped_devices(reason)` / `request_reload_network_json(reason)`: ask actor to reload from disk. - `apply_shaped_devices_snapshot(reason, shaped)`: publish a caller-provided shaped-devices snapshot via actor. - `swap_shaped_devices_snapshot(reason, shaped_arc)`: replace shaped-devices snapshot without actor (intended for tests). +## `ShapedDevicesCatalog` helpers + +`ShapedDevicesCatalog` exposes common verb-style operations over a consistent shaped-devices snapshot: + +- `device_by_hashes(device_hash, circuit_hash)`: lookups using stable baked hashes. +- `devices_for_circuit_id(circuit_id)`: rows for one circuit. +- `configured_circuit_count()`: unique configured circuits. +- `circuit_rate_caps_by_circuit_id()`: per-circuit configured max Mbps. +- `device_longest_match_for_ip(ip)`: longest-prefix match via the LPM trie. +- `iter_ip_mappings()`: iterate the LPM trie mappings. + ## Notes for `lqosd` `lqosd` starts daemon mode and provides hooks to invalidate derived snapshots/caches on updates. diff --git a/src/rust/lqos_network_devices/src/actor.rs b/src/rust/lqos_network_devices/src/actor.rs index 396e0028..77254c14 100644 --- a/src/rust/lqos_network_devices/src/actor.rs +++ b/src/rust/lqos_network_devices/src/actor.rs @@ -108,7 +108,11 @@ fn actor_loop(rx: Receiver, hooks: Option { + NetworkDevicesCommand::ApplyShapedDevicesSnapshot { + reason, + shaped, + reply, + } => { debug!("Publishing shaped-devices snapshot reason={reason}"); state::publish_shaped_devices(*shaped); if let Some(hooks) = &hooks { @@ -136,8 +140,7 @@ fn reload_shaped_devices_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Err(err) if attempt < RELOAD_ATTEMPTS => { warn!( "ShapedDevices reload reason={reason} attempt {attempt}/{} failed: {err}. Retrying after {} ms.", - RELOAD_ATTEMPTS, - RELOAD_RETRY_DELAY_MS + RELOAD_ATTEMPTS, RELOAD_RETRY_DELAY_MS ); std::thread::sleep(Duration::from_millis(RELOAD_RETRY_DELAY_MS)); } @@ -168,8 +171,7 @@ fn reload_network_json_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> R Err(err) if attempt < RELOAD_ATTEMPTS => { warn!( "NetworkJson reload reason={reason} attempt {attempt}/{} failed: {err}. Retrying after {} ms.", - RELOAD_ATTEMPTS, - RELOAD_RETRY_DELAY_MS + RELOAD_ATTEMPTS, RELOAD_RETRY_DELAY_MS ); std::thread::sleep(Duration::from_millis(RELOAD_RETRY_DELAY_MS)); } diff --git a/src/rust/lqos_network_devices/src/catalog.rs b/src/rust/lqos_network_devices/src/catalog.rs new file mode 100644 index 00000000..bb1a085e --- /dev/null +++ b/src/rust/lqos_network_devices/src/catalog.rs @@ -0,0 +1,182 @@ +use crate::hash_cache::ShapedDeviceHashCache; +use fxhash::FxHashSet; +use ip_network::IpNetwork; +use lqos_config::{ConfigShapedDevices, ShapedDevice}; +use lqos_utils::XdpIpAddress; +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::Arc; + +/// Snapshot handle for shaped devices plus derived lookup structures. +/// +/// Prefer using this catalog over reaching into `ConfigShapedDevices` directly. +/// It keeps related snapshots consistent (shaped devices + hash cache) and exposes +/// common operations as verb-style methods. +#[derive(Clone)] +pub struct ShapedDevicesCatalog { + shaped: Arc, + cache: Arc, + generation: u64, +} + +impl ShapedDevicesCatalog { + pub(crate) fn new( + shaped: Arc, + cache: Arc, + generation: u64, + ) -> Self { + Self { + shaped, + cache, + generation, + } + } + + /// Builds a catalog from an arbitrary shaped-devices snapshot. + /// + /// This is intended for one-shot tools that load `ShapedDevices.csv` from disk + /// and want the higher-level lookup helpers without starting the runtime actor. + pub fn from_shaped_devices(shaped: Arc) -> Self { + let cache = Arc::new(ShapedDeviceHashCache::from_devices(&shaped.devices)); + Self { + shaped, + cache, + generation: 0, + } + } + + /// Returns the monotonic generation number of the underlying shaped-devices snapshot. + pub fn generation(&self) -> u64 { + self.generation + } + + /// Returns the number of shaped-device rows in the snapshot. + pub fn devices_len(&self) -> usize { + self.shaped.devices.len() + } + + /// Iterates over all shaped-device rows in the snapshot. + pub fn iter_devices(&self) -> impl Iterator { + self.shaped.devices.iter() + } + + /// Returns an owned copy of all shaped-device rows. + pub fn clone_all_devices(&self) -> Vec { + self.shaped.devices.clone() + } + + /// Looks up a shaped device by the stable `device_hash` field. + pub fn device_by_device_hash(&self, device_hash: i64) -> Option<&ShapedDevice> { + self.cache + .index_by_device_hash(&self.shaped, device_hash) + .and_then(|idx| self.shaped.devices.get(idx)) + } + + /// Looks up a shaped device by the stable `circuit_hash` field. + pub fn device_by_circuit_hash(&self, circuit_hash: i64) -> Option<&ShapedDevice> { + self.cache + .index_by_circuit_hash(&self.shaped, circuit_hash) + .and_then(|idx| self.shaped.devices.get(idx)) + } + + /// Looks up a shaped device using optional hashes, preferring `device_hash` + /// over `circuit_hash` when both are present. + pub fn device_by_hashes( + &self, + device_hash: Option, + circuit_hash: Option, + ) -> Option<&ShapedDevice> { + if let Some(device_hash) = device_hash + && let Some(device) = self.device_by_device_hash(device_hash) + { + return Some(device); + } + if let Some(circuit_hash) = circuit_hash + && let Some(device) = self.device_by_circuit_hash(circuit_hash) + { + return Some(device); + } + None + } + + /// Returns a cloned list of shaped-device rows that belong to a circuit identifier. + pub fn devices_for_circuit_id(&self, circuit_id: &str) -> Vec { + let safe_id = circuit_id.to_lowercase().trim().to_string(); + self.shaped + .devices + .iter() + .filter(|device| device.circuit_id.to_lowercase().trim() == safe_id) + .cloned() + .collect() + } + + /// Returns the count of unique configured circuits present in the shaped-devices snapshot. + pub fn configured_circuit_count(&self) -> usize { + let mut circuits: FxHashSet<&str> = FxHashSet::default(); + circuits.reserve(self.shaped.devices.len()); + for device in &self.shaped.devices { + let circuit_id = device.circuit_id.trim(); + if circuit_id.is_empty() { + continue; + } + circuits.insert(circuit_id); + } + circuits.len() + } + + /// Returns a per-circuit map of maximum configured rates, using the first-seen parent node. + pub fn circuit_rate_caps_by_circuit_id(&self) -> HashMap { + let mut by_circuit_id: HashMap = HashMap::new(); + for device in &self.shaped.devices { + let entry = by_circuit_id + .entry(device.circuit_id.clone()) + .or_insert_with(|| CircuitRateCaps { + parent_node: device.parent_node.clone(), + download_max_mbps: 0.0, + upload_max_mbps: 0.0, + }); + entry.download_max_mbps = entry.download_max_mbps.max(device.download_max_mbps); + entry.upload_max_mbps = entry.upload_max_mbps.max(device.upload_max_mbps); + } + by_circuit_id + } + + /// Looks up the `circuit_hash` for an IP address using the shaped-devices LPM trie. + pub fn circuit_hash_for_ip(&self, ip: &XdpIpAddress) -> Option { + self.shaped.get_circuit_hash_from_ip(ip) + } + + /// Looks up a `(circuit_id, circuit_name)` pair for an IP address using the shaped-devices LPM trie. + pub fn circuit_id_and_name_for_ip(&self, ip: &XdpIpAddress) -> Option<(String, String)> { + self.shaped.get_circuit_id_and_name_from_ip(ip) + } + + /// Returns the longest-prefix match entry for an IP address using the shaped-devices LPM trie. + pub fn device_longest_match_for_ip( + &self, + ip: &XdpIpAddress, + ) -> Option<(IpNetwork, &ShapedDevice)> { + let lookup = match ip.as_ip() { + IpAddr::V4(ip) => ip.to_ipv6_mapped(), + IpAddr::V6(ip) => ip, + }; + let (net, idx) = self.shaped.trie.longest_match(lookup)?; + self.shaped.devices.get(*idx).map(|dev| (net, dev)) + } + + /// Iterates over all IP network mappings in the shaped-devices LPM trie. + pub fn iter_ip_mappings(&self) -> impl Iterator { + self.shaped + .trie + .iter() + .filter_map(|(net, &idx)| self.shaped.devices.get(idx).map(|dev| (net, dev))) + } +} + +/// Canonical per-circuit configured rate caps derived from shaped-device rows. +#[derive(Clone, Debug, PartialEq)] +pub struct CircuitRateCaps { + pub parent_node: String, + pub download_max_mbps: f32, + pub upload_max_mbps: f32, +} diff --git a/src/rust/lqos_network_devices/src/directory_watcher.rs b/src/rust/lqos_network_devices/src/directory_watcher.rs index d41f85ee..9f9e94f8 100644 --- a/src/rust/lqos_network_devices/src/directory_watcher.rs +++ b/src/rust/lqos_network_devices/src/directory_watcher.rs @@ -69,7 +69,8 @@ impl NetworkDevicesDirectoryWatcher { } fn classify_changed_paths(paths: &[PathBuf]) -> Vec { - paths.iter() + paths + .iter() .filter_map(|path| classify_changed_path(path)) .collect::>() .into_iter() @@ -83,7 +84,9 @@ fn classify_changed_path(path: &Path) -> Option { } match file_name { - "ShapedDevices.csv" | "ShapedDevices.insight.csv" => Some(DirectoryReloadEvent::ShapedDevices), + "ShapedDevices.csv" | "ShapedDevices.insight.csv" => { + Some(DirectoryReloadEvent::ShapedDevices) + } "network.effective.json" | "network.insight.json" | "network.json" => { Some(DirectoryReloadEvent::NetworkJson) } @@ -100,8 +103,9 @@ fn is_ignored_filename(file_name: &str) -> bool { fn dispatch_reload(event: DirectoryReloadEvent) -> Result<()> { match event { - DirectoryReloadEvent::ShapedDevices => request_reload_shaped_devices("dirwatch:ShapedDevices.csv"), + DirectoryReloadEvent::ShapedDevices => { + request_reload_shaped_devices("dirwatch:ShapedDevices.csv") + } DirectoryReloadEvent::NetworkJson => request_reload_network_json("dirwatch:network.json"), } } - diff --git a/src/rust/lqos_network_devices/src/hash_cache.rs b/src/rust/lqos_network_devices/src/hash_cache.rs index 066dc4d2..5e668187 100644 --- a/src/rust/lqos_network_devices/src/hash_cache.rs +++ b/src/rust/lqos_network_devices/src/hash_cache.rs @@ -61,4 +61,3 @@ impl ShapedDeviceHashCache { .position(|d| d.circuit_hash == circuit_hash) } } - diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs index d9c127a0..e8c41767 100644 --- a/src/rust/lqos_network_devices/src/lib.rs +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -6,16 +6,23 @@ //! in-memory snapshots up to date. mod actor; +mod catalog; mod directory_watcher; mod hash_cache; mod state; +mod topology; + +#[cfg(test)] +mod tests; use anyhow::{Context, Result}; use lqos_config::{ConfigShapedDevices, NetworkJson}; use std::sync::Arc; use tracing::debug; +pub use catalog::{CircuitRateCaps, ShapedDevicesCatalog}; pub use hash_cache::ShapedDeviceHashCache; +pub use topology::{ResolvedParentNode, resolve_parent_node, resolve_parent_node_reference}; /// Hooks that `lqosd` can provide to run side effects after updates. pub trait DaemonHooks: Send + Sync + 'static { @@ -50,6 +57,14 @@ pub fn shaped_devices_snapshot() -> Arc { state::shaped_devices_snapshot() } +/// Returns a higher-level shaped-devices catalog snapshot. +/// +/// Prefer this API over directly iterating `shaped_devices_snapshot().devices` or separately +/// fetching the hash cache snapshot. +pub fn shaped_devices_catalog() -> ShapedDevicesCatalog { + state::shaped_devices_catalog() +} + /// Returns the current in-memory shaped-device hash cache snapshot. pub fn shaped_device_hash_cache_snapshot() -> Arc { state::shaped_device_hash_cache_snapshot() diff --git a/src/rust/lqos_network_devices/src/state.rs b/src/rust/lqos_network_devices/src/state.rs index cc365eab..9d449304 100644 --- a/src/rust/lqos_network_devices/src/state.rs +++ b/src/rust/lqos_network_devices/src/state.rs @@ -1,22 +1,41 @@ +use crate::catalog::ShapedDevicesCatalog; use crate::hash_cache::ShapedDeviceHashCache; use arc_swap::ArcSwap; use lqos_config::{ConfigShapedDevices, NetworkJson}; use once_cell::sync::Lazy; use parking_lot::RwLock; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; -static SHAPED_DEVICES: Lazy> = - Lazy::new(|| ArcSwap::new(Arc::new(ConfigShapedDevices::default()))); -static SHAPED_DEVICE_HASH_CACHE: Lazy> = - Lazy::new(|| ArcSwap::new(Arc::new(ShapedDeviceHashCache::default()))); +#[derive(Clone)] +struct PublishedShapedState { + shaped: Arc, + cache: Arc, + generation: u64, +} + +static NEXT_SHAPED_GENERATION: AtomicU64 = AtomicU64::new(1); + +static SHAPED_STATE: Lazy> = Lazy::new(|| { + ArcSwap::new(Arc::new(PublishedShapedState { + shaped: Arc::new(ConfigShapedDevices::default()), + cache: Arc::new(ShapedDeviceHashCache::default()), + generation: 0, + })) +}); static NETWORK_JSON: Lazy> = Lazy::new(|| RwLock::new(NetworkJson::default())); pub(crate) fn shaped_devices_snapshot() -> Arc { - SHAPED_DEVICES.load_full() + SHAPED_STATE.load_full().shaped.clone() } pub(crate) fn shaped_device_hash_cache_snapshot() -> Arc { - SHAPED_DEVICE_HASH_CACHE.load_full() + SHAPED_STATE.load_full().cache.clone() +} + +pub(crate) fn shaped_devices_catalog() -> ShapedDevicesCatalog { + let state = SHAPED_STATE.load_full(); + ShapedDevicesCatalog::new(state.shaped.clone(), state.cache.clone(), state.generation) } pub(crate) fn with_network_json_read(f: impl FnOnce(&NetworkJson) -> R) -> R { @@ -30,18 +49,28 @@ pub(crate) fn with_network_json_write(f: impl FnOnce(&mut NetworkJson) -> R) } pub(crate) fn publish_shaped_devices(new_file: ConfigShapedDevices) { - let cache = ShapedDeviceHashCache::from_devices(&new_file.devices); - SHAPED_DEVICES.store(Arc::new(new_file)); - SHAPED_DEVICE_HASH_CACHE.store(Arc::new(cache)); + let generation = NEXT_SHAPED_GENERATION.fetch_add(1, Ordering::Relaxed); + let shaped = Arc::new(new_file); + let cache = Arc::new(ShapedDeviceHashCache::from_devices(&shaped.devices)); + SHAPED_STATE.store(Arc::new(PublishedShapedState { + shaped, + cache, + generation, + })); } pub(crate) fn swap_shaped_devices_snapshot( new_snapshot: Arc, ) -> Arc { - let cache = ShapedDeviceHashCache::from_devices(&new_snapshot.devices); - let old = SHAPED_DEVICES.swap(new_snapshot); - SHAPED_DEVICE_HASH_CACHE.store(Arc::new(cache)); - old + let generation = NEXT_SHAPED_GENERATION.fetch_add(1, Ordering::Relaxed); + let cache = Arc::new(ShapedDeviceHashCache::from_devices(&new_snapshot.devices)); + let new_state = Arc::new(PublishedShapedState { + shaped: new_snapshot, + cache, + generation, + }); + let old = SHAPED_STATE.swap(new_state); + old.shaped.clone() } pub(crate) fn publish_network_json(new_file: NetworkJson) { diff --git a/src/rust/lqos_network_devices/src/tests.rs b/src/rust/lqos_network_devices/src/tests.rs new file mode 100644 index 00000000..f54baa08 --- /dev/null +++ b/src/rust/lqos_network_devices/src/tests.rs @@ -0,0 +1,97 @@ +use crate::{ShapedDevicesCatalog, resolve_parent_node_reference, with_network_json_write}; +use lqos_config::{ConfigShapedDevices, NetworkJsonNode, ShapedDevice}; +use lqos_utils::rtt::RttBuffer; +use lqos_utils::units::DownUpOrder; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use std::sync::Arc; + +static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +fn make_node( + name: &str, + id: Option<&str>, + active_attachment_name: Option<&str>, +) -> NetworkJsonNode { + NetworkJsonNode { + name: name.to_string(), + id: id.map(|value| value.to_string()), + virtual_node: false, + max_throughput: (0.0, 0.0), + current_throughput: DownUpOrder::zeroed(), + current_packets: DownUpOrder::zeroed(), + current_tcp_packets: DownUpOrder::zeroed(), + current_udp_packets: DownUpOrder::zeroed(), + current_icmp_packets: DownUpOrder::zeroed(), + current_tcp_retransmits: DownUpOrder::zeroed(), + current_tcp_retransmit_packets: DownUpOrder::zeroed(), + current_marks: DownUpOrder::zeroed(), + current_drops: DownUpOrder::zeroed(), + rtt_buffer: RttBuffer::default(), + parents: Vec::new(), + immediate_parent: None, + node_type: None, + latitude: None, + longitude: None, + active_attachment_name: active_attachment_name.map(|value| value.to_string()), + heatmap: None, + qoq_heatmap: None, + } +} + +#[test] +fn catalog_device_by_hashes_prefers_device_hash() { + let _guard = TEST_LOCK.lock(); + + let mut a = ShapedDevice::default(); + a.circuit_id = "circuit-a".into(); + a.device_id = "device-a".into(); + a.circuit_hash = 10; + a.device_hash = 100; + + let mut b = ShapedDevice::default(); + b.circuit_id = "circuit-b".into(); + b.device_id = "device-b".into(); + b.circuit_hash = 20; + b.device_hash = 200; + + let mut shaped = ConfigShapedDevices::default(); + shaped.replace_with_new_data(vec![a.clone(), b.clone()]); + + let catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped)); + + let resolved = catalog + .device_by_hashes(Some(a.device_hash), Some(b.circuit_hash)) + .expect("Expected shaped device match"); + assert_eq!(resolved.device_id, a.device_id); + + let fallback = catalog + .device_by_hashes(Some(999), Some(b.circuit_hash)) + .expect("Expected circuit-hash fallback match"); + assert_eq!(fallback.device_id, b.device_id); +} + +#[test] +fn resolve_parent_node_reference_prefers_id_then_name_then_alias() { + let _guard = TEST_LOCK.lock(); + + with_network_json_write(|net_json| { + net_json.nodes = vec![ + make_node("Root", Some("root"), None), + make_node("Site A", Some("node-a"), None), + make_node("Site B", Some("node-b"), Some("B-alias")), + ]; + }); + + let by_id = resolve_parent_node_reference("ignored", Some("node-a")) + .expect("Expected node id lookup to resolve"); + assert_eq!(by_id.name, "Site A"); + + let by_name = resolve_parent_node_reference("Site B", None) + .expect("Expected node name lookup to resolve"); + assert_eq!(by_name.id.as_deref(), Some("node-b")); + + let by_alias = resolve_parent_node_reference("B-alias", None) + .expect("Expected active attachment alias to resolve"); + assert_eq!(by_alias.name, "Site B"); +} diff --git a/src/rust/lqos_network_devices/src/topology.rs b/src/rust/lqos_network_devices/src/topology.rs new file mode 100644 index 00000000..b8141be9 --- /dev/null +++ b/src/rust/lqos_network_devices/src/topology.rs @@ -0,0 +1,64 @@ +use crate::state; +use lqos_config::NetworkJsonNode; + +/// Canonical parent-node metadata resolved from `network.json`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedParentNode { + /// Canonical node name from `network.json`. + pub name: String, + /// Optional stable node identifier from `network.json` metadata. + pub id: Option, +} + +/// Resolve a shaped-device parent reference into canonical `network.json` +/// parent metadata, preferring a stable node ID when one is available. +pub fn resolve_parent_node_reference( + parent_node: &str, + parent_node_id: Option<&str>, +) -> Option { + let trimmed_id = parent_node_id.map(str::trim).filter(|id| !id.is_empty()); + let trimmed = parent_node.trim(); + if trimmed.is_empty() && trimmed_id.is_none() { + return None; + } + + state::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + + if let Some(parent_node_id) = trimmed_id + && let Some(node) = find_node_by_id(nodes, parent_node_id) + { + return Some(ResolvedParentNode { + name: node.name.clone(), + id: node.id.clone(), + }); + } + + if let Some(node) = nodes.iter().find(|node| node.name == trimmed) { + return Some(ResolvedParentNode { + name: node.name.clone(), + id: node.id.clone(), + }); + } + + nodes.iter().find_map(|node| { + node.active_attachment_name + .as_deref() + .filter(|alias| alias.trim() == trimmed) + .map(|_| ResolvedParentNode { + name: node.name.clone(), + id: node.id.clone(), + }) + }) + }) +} + +/// Resolve a shaped-device parent node or active attachment alias into canonical `network.json` +/// parent metadata. +pub fn resolve_parent_node(parent_node: &str) -> Option { + resolve_parent_node_reference(parent_node, None) +} + +fn find_node_by_id<'a>(nodes: &'a [NetworkJsonNode], id: &str) -> Option<&'a NetworkJsonNode> { + nodes.iter().find(|node| node.id.as_deref() == Some(id)) +} diff --git a/src/rust/lqosd/src/lts2_sys/control_channel.rs b/src/rust/lqosd/src/lts2_sys/control_channel.rs index ede8a9f1..2697faf3 100644 --- a/src/rust/lqosd/src/lts2_sys/control_channel.rs +++ b/src/rust/lqosd/src/lts2_sys/control_channel.rs @@ -1435,17 +1435,15 @@ async fn circuit_snapshot_streaming( } } - // Load "shaped devices" snapshot and pick devices in the target circuit - let shaped = lqos_network_devices::shaped_devices_snapshot(); - let shaped_cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); - let mut device_indexes: Vec = Vec::new(); - for (idx, dev) in shaped.devices.iter().enumerate() { - if dev.circuit_hash == circuit_hash { - device_indexes.push(idx); - } - } + // Load shaped-devices catalog and pick devices in the target circuit. + let catalog = lqos_network_devices::shaped_devices_catalog(); + let circuit_devices = catalog + .iter_devices() + .filter(|dev| dev.circuit_hash == circuit_hash) + .cloned() + .collect::>(); - // Aggregation holders per device index + // Aggregation holders per device hash use lqos_utils::units::{DownUpOrder, down_up_divide}; struct Agg { bps_bytes: DownUpOrder, @@ -1453,18 +1451,7 @@ async fn circuit_snapshot_streaming( tcp_retries: DownUpOrder, rtts: Vec, } - let mut aggregates: std::collections::HashMap = std::collections::HashMap::new(); - for idx in &device_indexes { - aggregates.insert( - *idx, - Agg { - bps_bytes: DownUpOrder::zeroed(), - tcp_packets: DownUpOrder::zeroed(), - tcp_retries: DownUpOrder::zeroed(), - rtts: Vec::new(), - }, - ); - } + let mut aggregates: std::collections::HashMap = std::collections::HashMap::new(); // Walk raw throughput data and fold into devices of this circuit { @@ -1478,17 +1465,21 @@ async fn circuit_snapshot_streaming( } // retire_check is local; use the same heuristic: require most_recent_cycle >= tp_cycle - RETIRE_AFTER_SECONDS // We don't have RETIRE_AFTER_SECONDS here; accept all entries for snapshot. - if let Some(device_hash) = te.device_hash - && let Some(id) = shaped_cache.index_by_device_hash(shaped.as_ref(), device_hash) - && let Some(agg) = aggregates.get_mut(&id) - { - // bytes_per_second -> later convert to bits - agg.bps_bytes += te.bytes_per_second; - agg.tcp_packets += te.tcp_packets; - agg.tcp_retries += te.tcp_retransmits; - if let Some(rtt) = te.median_latency() { - agg.rtts.push(rtt); - } + let Some(device_hash) = te.device_hash else { + continue; + }; + let agg = aggregates.entry(device_hash).or_insert_with(|| Agg { + bps_bytes: DownUpOrder::zeroed(), + tcp_packets: DownUpOrder::zeroed(), + tcp_retries: DownUpOrder::zeroed(), + rtts: Vec::new(), + }); + // bytes_per_second -> later convert to bits + agg.bps_bytes += te.bytes_per_second; + agg.tcp_packets += te.tcp_packets; + agg.tcp_retries += te.tcp_retransmits; + if let Some(rtt) = te.median_latency() { + agg.rtts.push(rtt); } } // raw lock dropped here @@ -1496,9 +1487,8 @@ async fn circuit_snapshot_streaming( // Build device snapshots let mut devices_out: Vec = Vec::new(); - for idx in device_indexes { - let dev = &shaped.devices[idx]; - let agg = aggregates.remove(&idx).unwrap_or(Agg { + for dev in circuit_devices { + let agg = aggregates.remove(&dev.device_hash).unwrap_or(Agg { bps_bytes: DownUpOrder::zeroed(), tcp_packets: DownUpOrder::zeroed(), tcp_retries: DownUpOrder::zeroed(), diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index 9184931e..293dda2f 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -1160,13 +1160,12 @@ fn circuit_capacity_data() -> Vec { } }); - let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); circuits .iter() .filter_map(|(circuit_id, accumulator)| { - shaped_devices - .devices - .iter() + catalog + .iter_devices() .find(|sd| sd.circuit_id == *circuit_id) .map(|device| { let down_mbps = (accumulator.bytes.down as f64 * 8.0) / 1_000_000.0; diff --git a/src/rust/lqosd/src/network_devices_hooks.rs b/src/rust/lqosd/src/network_devices_hooks.rs index 36ec9434..5a119508 100644 --- a/src/rust/lqosd/src/network_devices_hooks.rs +++ b/src/rust/lqosd/src/network_devices_hooks.rs @@ -20,4 +20,3 @@ impl lqos_network_devices::DaemonHooks for LqosdNetworkDevicesHooks { }); } } - diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit.rs b/src/rust/lqosd/src/node_manager/local_api/circuit.rs index 85c4c917..695587c2 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit.rs @@ -1,7 +1,5 @@ use crate::node_manager::local_api::ethernet_caps::ethernet_advisory_for_circuit; -use crate::shaped_devices_tracker::{ - effective_parent_for_circuit, resolve_parent_node_reference, -}; +use crate::shaped_devices_tracker::effective_parent_for_circuit; use lqos_config::{CircuitEthernetMetadata, ShapedDevice}; use serde::{Deserialize, Serialize}; @@ -40,9 +38,10 @@ fn canonical_parent_node(devices: &mut [ShapedDevice]) -> Option Option { let safe_id = id.to_lowercase().trim().to_string(); - let reader = lqos_network_devices::shaped_devices_snapshot(); - let mut devices: Vec = reader - .devices - .iter() - .filter(|d| d.circuit_id.to_lowercase().trim() == safe_id) - .cloned() - .collect(); + let catalog = lqos_network_devices::shaped_devices_catalog(); + let mut devices: Vec = catalog.devices_for_circuit_id(&safe_id); if devices.is_empty() { None diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs index 44526a60..f16e2ab9 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs @@ -163,13 +163,13 @@ fn sanitized_plan_ceiling_bps(plan_mbps: f32) -> u32 { } fn circuit_display_rate_ceiling_bps( - shaped: &lqos_config::ConfigShapedDevices, + catalog: &lqos_network_devices::ShapedDevicesCatalog, circuit_hash: i64, ) -> Option> { let mut max_down_mbps = 0.0_f32; let mut max_up_mbps = 0.0_f32; - for device in &shaped.devices { + for device in catalog.iter_devices() { if device.circuit_hash != circuit_hash { continue; } @@ -219,9 +219,8 @@ fn flow_qoo(local: &FlowbeeLocalData) -> DownUpOrder> { fn flow_snapshot_rows(circuit_id: &str) -> Vec { let circuit_hash = hash_to_i64(circuit_id); - let shaped = lqos_network_devices::shaped_devices_snapshot(); - let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); - let display_rate_ceiling = circuit_display_rate_ceiling_bps(&shaped, circuit_hash); + let catalog = lqos_network_devices::shaped_devices_catalog(); + let display_rate_ceiling = circuit_display_rate_ceiling_bps(&catalog, circuit_hash); let Ok(now) = time_since_boot() else { return Vec::new(); }; @@ -240,11 +239,9 @@ fn flow_snapshot_rows(circuit_id: &str) -> Vec { return None; } - let device_name = local - .device_hash - .and_then(|hash| cache.index_by_device_hash(shaped.as_ref(), hash)) - .and_then(|idx| shaped.as_ref().devices.get(idx)) - .map(|d| d.device_name.clone()) + let device_name = catalog + .device_by_hashes(local.device_hash, local.circuit_hash) + .map(|device| device.device_name.clone()) .unwrap_or_else(|| "Unknown".to_string()); let geo = get_asn_name_and_country(key.remote_ip.as_ip()); diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs index 6a3d2c20..8070438e 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs @@ -35,22 +35,18 @@ pub fn circuit_count_data() -> CircuitCount { .collect(); // Get configured circuits from ShapedDevices - let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); - let configured_circuits: HashSet<&str> = shaped_devices - .devices - .iter() - .map(|device| device.circuit_id.as_str()) - .collect(); + let configured_count = + lqos_network_devices::shaped_devices_catalog().configured_circuit_count(); // Use active count if > 0, otherwise fall back to configured count let count = if !active_circuits.is_empty() { active_circuits.len() } else { - configured_circuits.len() + configured_count }; CircuitCount { count, - configured_count: configured_circuits.len(), + configured_count, } } diff --git a/src/rust/lqosd/src/node_manager/local_api/config.rs b/src/rust/lqosd/src/node_manager/local_api/config.rs index 81def6e3..946b2340 100644 --- a/src/rust/lqosd/src/node_manager/local_api/config.rs +++ b/src/rust/lqosd/src/node_manager/local_api/config.rs @@ -99,7 +99,7 @@ pub fn network_json_data() -> Value { } pub fn all_shaped_devices_data() -> Vec { - lqos_network_devices::shaped_devices_snapshot().devices.clone() + lqos_network_devices::shaped_devices_catalog().clone_all_devices() } /// Returns the enabled integration names that act as the source of truth for @@ -343,8 +343,11 @@ fn persist_shaped_devices(mut devices: Vec) -> Result<(), String> copied .write_csv("ShapedDevices.csv") .map_err(|e| format!("Unable to write ShapedDevices.csv: {e}"))?; - lqos_network_devices::apply_shaped_devices_snapshot("node_manager:persist_shaped_devices", copied) - .map_err(|e| format!("Unable to publish ShapedDevices.csv snapshot: {e}"))?; + lqos_network_devices::apply_shaped_devices_snapshot( + "node_manager:persist_shaped_devices", + copied, + ) + .map_err(|e| format!("Unable to publish ShapedDevices.csv snapshot: {e}"))?; Ok(()) } @@ -415,9 +418,8 @@ pub fn get_shaped_device_data( return Err(StatusCode::FORBIDDEN); } let wanted = device_id.trim(); - Ok(lqos_network_devices::shaped_devices_snapshot() - .devices - .iter() + Ok(lqos_network_devices::shaped_devices_catalog() + .iter_devices() .find(|device| device.device_id == wanted) .cloned()) } @@ -434,7 +436,7 @@ pub fn create_shaped_device_data( return Err("Unauthorized".to_string()); } ensure_topology_editor_unlocked()?; - let mut devices = lqos_network_devices::shaped_devices_snapshot().devices.clone(); + let mut devices = lqos_network_devices::shaped_devices_catalog().clone_all_devices(); devices.push(device.clone()); persist_shaped_devices(devices)?; let created = get_shaped_device_data(login, device.device_id.clone()) @@ -457,7 +459,7 @@ pub fn update_shaped_device_data( return Err("Unauthorized".to_string()); } ensure_topology_editor_unlocked()?; - let mut devices = lqos_network_devices::shaped_devices_snapshot().devices.clone(); + let mut devices = lqos_network_devices::shaped_devices_catalog().clone_all_devices(); let wanted = original_device_id.trim(); let Some(index) = devices.iter().position(|row| row.device_id == wanted) else { return Err("Not found".to_string()); @@ -481,7 +483,7 @@ pub fn delete_shaped_device_data(login: LoginResult, device_id: String) -> Resul } ensure_topology_editor_unlocked()?; let wanted = device_id.trim(); - let mut devices = lqos_network_devices::shaped_devices_snapshot().devices.clone(); + let mut devices = lqos_network_devices::shaped_devices_catalog().clone_all_devices(); let before = devices.len(); devices.retain(|device| device.device_id != wanted); if devices.len() == before { diff --git a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs index ea11e8e8..31be8a95 100644 --- a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs @@ -559,10 +559,10 @@ fn derive_runtime_node_placements( } fn direct_circuit_counts_by_node() -> HashMap { - let shaped = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut circuits_by_node: HashMap> = HashMap::new(); - for device in &shaped.devices { + for device in catalog.iter_devices() { let node_name = device.parent_node.trim(); if node_name.is_empty() { continue; diff --git a/src/rust/lqosd/src/node_manager/local_api/device_counts.rs b/src/rust/lqosd/src/node_manager/local_api/device_counts.rs index 7e0cc887..960e0190 100644 --- a/src/rust/lqosd/src/node_manager/local_api/device_counts.rs +++ b/src/rust/lqosd/src/node_manager/local_api/device_counts.rs @@ -9,7 +9,7 @@ pub struct DeviceCount { pub fn device_count() -> DeviceCount { DeviceCount { - shaped_devices: lqos_network_devices::shaped_devices_snapshot().devices.len(), + shaped_devices: lqos_network_devices::shaped_devices_catalog().devices_len(), unknown_ips: get_unknown_ips().len(), } } diff --git a/src/rust/lqosd/src/node_manager/local_api/directories.rs b/src/rust/lqosd/src/node_manager/local_api/directories.rs index 54981629..16197a70 100644 --- a/src/rust/lqosd/src/node_manager/local_api/directories.rs +++ b/src/rust/lqosd/src/node_manager/local_api/directories.rs @@ -84,10 +84,10 @@ pub fn circuit_directory_page(query: CircuitDirectoryQuery) -> CircuitDirectoryP let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); - let devices = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut circuits: BTreeMap = BTreeMap::new(); - for device in &devices.devices { + for device in catalog.iter_devices() { let circuit_id = device.circuit_id.trim().to_string(); if circuit_id.is_empty() { continue; @@ -209,10 +209,10 @@ pub fn treeguard_metadata_summary() -> TreeGuardMetadataSummary { }) .count(); - let devices = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut circuit_ids = BTreeSet::new(); let mut fq_codel_circuit_ids = BTreeSet::new(); - for device in &devices.devices { + for device in catalog.iter_devices() { let circuit_id = device.circuit_id.trim().to_string(); if circuit_id.is_empty() { continue; diff --git a/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs b/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs index fb9f1c0a..e4dd8cd9 100644 --- a/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs +++ b/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs @@ -142,9 +142,9 @@ fn load_advisory_file() -> Option { } fn parent_node_by_circuit_id() -> HashMap { - let devices = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut parent_nodes = HashMap::new(); - for device in &devices.devices { + for device in catalog.iter_devices() { let circuit_id = device.circuit_id.trim(); if circuit_id.is_empty() { continue; diff --git a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs index d1612663..9793eb00 100644 --- a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs +++ b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs @@ -686,16 +686,8 @@ fn entity_snapshots( fn build_oversubscribed_sites( entities: &[ExecutiveEntitySnapshot], ) -> Vec { - let devices = lqos_network_devices::shaped_devices_snapshot(); - let mut circuit_map: std::collections::HashMap = - std::collections::HashMap::new(); - for device in &devices.devices { - let entry = circuit_map - .entry(device.circuit_id.clone()) - .or_insert_with(|| (device.parent_node.clone(), 0.0_f32, 0.0_f32)); - entry.1 = entry.1.max(device.download_max_mbps); - entry.2 = entry.2.max(device.upload_max_mbps); - } + let circuit_caps = + lqos_network_devices::shaped_devices_catalog().circuit_rate_caps_by_circuit_id(); let entity_map = entities .iter() @@ -750,10 +742,10 @@ fn build_oversubscribed_sites( let mut sub_down = 0.0_f32; let mut sub_up = 0.0_f32; - for (parent_name, circuit_down, circuit_up) in circuit_map.values() { - if descendant_names.contains(parent_name) { - sub_down += *circuit_down; - sub_up += *circuit_up; + for caps in circuit_caps.values() { + if descendant_names.contains(&caps.parent_node) { + sub_down += caps.download_max_mbps; + sub_up += caps.upload_max_mbps; } } diff --git a/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs b/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs index 03a35aa2..d3d81ea5 100644 --- a/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs +++ b/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs @@ -1,8 +1,8 @@ +use crate::throughput_tracker::THROUGHPUT_TRACKER; use crate::throughput_tracker::flow_data::{ AsnCountryListEntry, AsnListEntry, AsnProtocolListEntry, FlowAnalysis, FlowbeeLocalData, RECENT_FLOWS, RttData, }; -use crate::throughput_tracker::THROUGHPUT_TRACKER; use lqos_sys::flowbee_data::FlowbeeKey; use lqos_utils::units::DownUpOrder; use lqos_utils::unix_time::{time_since_boot, unix_now}; @@ -54,8 +54,7 @@ fn all_flows_to_transport( boot_time: u64, all_flows_for_asn: Vec<(FlowbeeKey, FlowbeeLocalData, FlowAnalysis)>, ) -> Vec { - let shaped = lqos_network_devices::shaped_devices_snapshot(); - let shaped_cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); all_flows_for_asn .iter() @@ -70,14 +69,7 @@ fn all_flows_to_transport( if let Some(id) = &te.circuit_id { circuit_id = id.clone(); } - let shaped_device = te - .device_hash - .and_then(|hash| shaped_cache.index_by_device_hash(shaped.as_ref(), hash)) - .or_else(|| { - te.circuit_hash - .and_then(|hash| shaped_cache.index_by_circuit_hash(shaped.as_ref(), hash)) - }) - .and_then(|idx| shaped.as_ref().devices.get(idx)); + let shaped_device = catalog.device_by_hashes(te.device_hash, te.circuit_hash); if let Some(device) = shaped_device { if circuit_id.is_empty() { circuit_id = device.circuit_id.clone(); diff --git a/src/rust/lqosd/src/node_manager/local_api/search.rs b/src/rust/lqosd/src/node_manager/local_api/search.rs index e991da9b..81a90e51 100644 --- a/src/rust/lqosd/src/node_manager/local_api/search.rs +++ b/src/rust/lqosd/src/node_manager/local_api/search.rs @@ -1,4 +1,5 @@ use ip_network::IpNetwork; +use lqos_utils::XdpIpAddress; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::net::{IpAddr, Ipv4Addr}; @@ -77,6 +78,7 @@ pub fn search_results(search: SearchRequest) -> Vec { let term_lc = raw_term.to_lowercase(); let exact_ip: Option = raw_term.parse::().ok(); let looks_like_ip_prefix = raw_term.contains('.') || raw_term.contains(':'); + let catalog = lqos_network_devices::shaped_devices_catalog(); // Helper to add results with de-dup and cap fn push_result( @@ -102,14 +104,8 @@ pub fn search_results(search: SearchRequest) -> Vec { // First pass: exact IP matches using the LPM trie if let Some(ip) = exact_ip { - let sd_reader = lqos_network_devices::shaped_devices_snapshot(); - let query_v6 = match ip { - IpAddr::V4(v4) => v4.to_ipv6_mapped(), - IpAddr::V6(v6) => v6, - }; - if let Some((net, &idx)) = sd_reader.trie.longest_match(query_v6) - && let Some(dev) = sd_reader.devices.get(idx) - { + let xdp_ip = XdpIpAddress::from_ip(ip); + if let Some((net, dev)) = catalog.device_longest_match_for_ip(&xdp_ip) { let name = format!("{} ({})", dev.device_name, pretty_net(&net)); push_result( &mut results, @@ -142,14 +138,11 @@ pub fn search_results(search: SearchRequest) -> Vec { IpNetwork::V6(v6net) => Some(IpNetwork::V6(v6net)), }; if let Some(query_v6) = net_v6.as_ref() { - let sd_reader = lqos_network_devices::shaped_devices_snapshot(); - for (n, &idx) in sd_reader.trie.iter() { + for (n, dev) in catalog.iter_ip_mappings() { if results.len() >= MAX_RESULTS { break; } - if ipv6_overlap(&n, query_v6) - && let Some(dev) = sd_reader.devices.get(idx) - { + if ipv6_overlap(&n, query_v6) { let name = format!("{} ({})", dev.device_name, pretty_net(&n)); push_result( &mut results, @@ -167,15 +160,12 @@ pub fn search_results(search: SearchRequest) -> Vec { } } else { // Fallback: textual prefix (e.g., "10.1.") - let sd_reader = lqos_network_devices::shaped_devices_snapshot(); - for (n, &idx) in sd_reader.trie.iter() { + for (n, dev) in catalog.iter_ip_mappings() { if results.len() >= MAX_RESULTS { break; } let s = pretty_net(&n); - if s.starts_with(raw_term) - && let Some(dev) = sd_reader.devices.get(idx) - { + if s.starts_with(raw_term) { let name = format!("{} ({})", dev.device_name, s); push_result( &mut results, @@ -194,8 +184,7 @@ pub fn search_results(search: SearchRequest) -> Vec { // Third pass: Circuit/Device name substring matches if results.len() < MAX_RESULTS && term_lc.len() >= 3 { - let sd_reader = lqos_network_devices::shaped_devices_snapshot(); - for sd in sd_reader.devices.iter() { + for sd in catalog.iter_devices() { if results.len() >= MAX_RESULTS { break; } diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs index 7168070c..bb559fc9 100644 --- a/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs @@ -1,5 +1,5 @@ use lqos_config::ShapedDevice; pub fn all_shaped_devices_data() -> Vec { - lqos_network_devices::shaped_devices_snapshot().devices.clone() + lqos_network_devices::shaped_devices_catalog().clone_all_devices() } diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs index f5af6352..7ca8a1eb 100644 --- a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs @@ -52,11 +52,10 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); - let devices = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); - let mut filtered: Vec = devices - .devices - .iter() + let mut filtered: Vec = catalog + .iter_devices() .filter(|device| { if search.is_empty() { return true; diff --git a/src/rust/lqosd/src/node_manager/template.rs b/src/rust/lqosd/src/node_manager/template.rs index 169597f9..b54a6116 100644 --- a/src/rust/lqosd/src/node_manager/template.rs +++ b/src/rust/lqosd/src/node_manager/template.rs @@ -10,7 +10,6 @@ use axum::http::{HeaderValue, Request, Response, StatusCode}; use axum::middleware::Next; use axum::response::IntoResponse; use axum_extra::extract::CookieJar; -use itertools::Itertools; use lqos_config::{RttThresholds, load_config}; use lqos_utils::unix_time::unix_now; use std::path::Path; @@ -152,13 +151,8 @@ pub async fn apply_templates( let week_ago = now - (7 * 24 * 60 * 60); let fl = FIRST_LOAD.load(Relaxed); if fl != 0 && fl < week_ago { - let sd = lqos_network_devices::shaped_devices_snapshot(); - let num_circuits = sd - .devices - .iter() - .sorted_by(|a, b| a.circuit_hash.cmp(&b.circuit_hash)) - .dedup() - .count(); + let num_circuits = + lqos_network_devices::shaped_devices_catalog().configured_circuit_count(); if num_circuits > 1_000 && !script_has_insight { show_modal = "true"; show_modal_number = num_circuits.to_string(); diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs index 26f9f225..63daea89 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs @@ -16,15 +16,15 @@ fn qoo_score_for_circuit(circuit: &str) -> Option { let circuit_hash = hash_to_i64(circuit.trim()); let qoq_heatmaps = THROUGHPUT_TRACKER.circuit_qoq_heatmaps.lock(); qoq_heatmaps.get(&circuit_hash).and_then(|heatmap| { - let blocks = heatmap.blocks(); - let dl = blocks.download_total.last().copied().flatten(); - let ul = blocks.upload_total.last().copied().flatten(); - match (dl, ul) { - (Some(d), Some(u)) => Some(d.min(u)), - (Some(d), None) => Some(d), - (None, Some(u)) => Some(u), - (None, None) => None, - } + let blocks = heatmap.blocks(); + let dl = blocks.download_total.last().copied().flatten(); + let ul = blocks.upload_total.last().copied().flatten(); + match (dl, ul) { + (Some(d), Some(u)) => Some(d.min(u)), + (Some(d), None) => Some(d), + (None, Some(u)) => Some(u), + (None, None) => None, + } }) } diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs b/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs index 0b23ce0f..50ec05db 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs @@ -46,14 +46,13 @@ pub async fn circuit_capacity(channels: Arc) { }); // Map circuits to capacities - let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let capacities: Vec = { circuits .iter() .filter_map(|(circuit_id, accumulator)| { - if let Some(device) = shaped_devices - .devices - .iter() + if let Some(device) = catalog + .iter_devices() .find(|sd| sd.circuit_id == *circuit_id) { let down_mbps = (accumulator.bytes.down as f64 * 8.0) / 1_000_000.0; diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs b/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs index 0adcab17..e5529904 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs @@ -32,10 +32,9 @@ impl From<&IpStats> for IpStatsWithPlan { }; if !result.circuit_id.is_empty() { - let shaped_devices_reader = lqos_network_devices::shaped_devices_snapshot(); - if let Some(circuit) = shaped_devices_reader - .devices - .iter() + let catalog = lqos_network_devices::shaped_devices_catalog(); + if let Some(circuit) = catalog + .iter_devices() .find(|sd| sd.circuit_id == result.circuit_id) { let name = if circuit.circuit_name.chars().count() > 20 { diff --git a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs index 9d5e5353..6f806cd1 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs @@ -103,20 +103,11 @@ pub fn rebuild_circuit_live_snapshot() -> Arc { }; let kernel_now: std::time::Duration = kernel_now.into(); - let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); - let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut by_circuit_id: FxHashMap = FxHashMap::default(); for (ip_key, data) in THROUGHPUT_TRACKER.raw_data.lock().iter() { - let device = data - .device_hash - .and_then(|device_hash| cache.index_by_device_hash(&shaped_devices, device_hash)) - .or_else(|| { - data.circuit_hash.and_then(|circuit_hash| { - cache.index_by_circuit_hash(&shaped_devices, circuit_hash) - }) - }) - .and_then(|idx| shaped_devices.devices.get(idx)); + let device = catalog.device_by_hashes(data.device_hash, data.circuit_hash); let Some(device) = device else { continue; }; diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index 0bf9dae4..d8b041fd 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -5,8 +5,8 @@ use arc_swap::ArcSwap; use fxhash::{FxHashMap, FxHashSet}; use lqos_bus::{BusResponse, Circuit}; use lqos_config::{ - ConfigShapedDevices, NetworkJsonNode, NetworkJsonTransport, - TopologyShapingInputsFile, load_config, topology_shaping_inputs_path, + NetworkJsonNode, NetworkJsonTransport, TopologyShapingInputsFile, load_config, + topology_shaping_inputs_path, }; use lqos_queue_tracker::EFFECTIVE_NODE_RATES; use lqos_utils::file_watcher::FileWatcher; @@ -171,7 +171,7 @@ fn node_to_transport_with_summary( fn build_network_tree_summaries( nodes: &[NetworkJsonNode], - shaped_devices: &ConfigShapedDevices, + shaped_devices: &lqos_network_devices::ShapedDevicesCatalog, ) -> Vec { let mut summaries = vec![NetworkTreeSummary::default(); nodes.len()]; let mut direct_circuits = vec![FxHashSet::default(); nodes.len()]; @@ -182,7 +182,7 @@ fn build_network_tree_summaries( node_index_by_name.entry(node.name.as_str()).or_insert(idx); } - for device in &shaped_devices.devices { + for device in shaped_devices.iter_devices() { let Some(node_idx) = node_index_by_name.get(device.parent_node.as_str()).copied() else { continue; }; @@ -217,8 +217,8 @@ fn build_network_tree_summaries( pub fn get_one_network_map_layer(parent_idx: usize) -> BusResponse { lqos_network_devices::with_network_json_read(|net_json| { let nodes_ref = net_json.get_nodes_when_ready(); - let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); - let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); + let shaped_devices = lqos_network_devices::shaped_devices_catalog(); + let summaries = build_network_tree_summaries(nodes_ref, &shaped_devices); if let Some(parent) = nodes_ref.get(parent_idx) { let mut nodes = vec![( parent_idx, @@ -252,15 +252,18 @@ pub fn get_one_network_map_layer(parent_idx: usize) -> BusResponse { pub fn full_network_map_snapshot() -> Vec<(usize, NetworkJsonTransport)> { lqos_network_devices::with_network_json_read(|net_json| { let nodes = net_json.get_nodes_when_ready(); - let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); - let summaries = build_network_tree_summaries(nodes, shaped_devices.as_ref()); + let shaped_devices = lqos_network_devices::shaped_devices_catalog(); + let summaries = build_network_tree_summaries(nodes, &shaped_devices); nodes .iter() .enumerate() .map(|(i, n)| { ( i, - node_to_transport_with_summary(n, summaries.get(i).copied().unwrap_or_default()), + node_to_transport_with_summary( + n, + summaries.get(i).copied().unwrap_or_default(), + ), ) }) .collect() @@ -344,8 +347,8 @@ pub fn get_full_network_map() -> BusResponse { pub fn get_top_n_root_queues(n_queues: usize) -> BusResponse { lqos_network_devices::with_network_json_read(|net_json| { let nodes_ref = net_json.get_nodes_when_ready(); - let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); - let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); + let shaped_devices = lqos_network_devices::shaped_devices_catalog(); + let summaries = build_network_tree_summaries(nodes_ref, &shaped_devices); if let Some(parent) = nodes_ref.first() { let mut nodes = vec![( 0, @@ -381,67 +384,67 @@ pub fn get_top_n_root_queues(n_queues: usize) -> BusResponse { if nodes.len() > n_queues { let mut other_bw = (0, 0); let mut other_packets = (0, 0); - let mut other_tcp_packets = (0, 0); - let mut other_tcp_retransmit_packets = (0, 0); - let mut other_udp_packets = (0, 0); - let mut other_icmp_packets = (0, 0); - let mut other_xmit = (0, 0); - let mut other_marks = (0, 0); - let mut other_drops = (0, 0); - nodes.drain(n_queues..).for_each(|n| { - other_bw.0 += n.1.current_throughput.0; - other_bw.1 += n.1.current_throughput.1; - other_packets.0 += n.1.current_packets.0; - other_packets.1 += n.1.current_packets.1; - other_tcp_packets.0 += n.1.current_tcp_packets.0; - other_tcp_packets.1 += n.1.current_tcp_packets.1; - other_tcp_retransmit_packets.0 += n.1.current_tcp_retransmit_packets.0; - other_tcp_retransmit_packets.1 += n.1.current_tcp_retransmit_packets.1; - other_udp_packets.0 += n.1.current_udp_packets.0; - other_udp_packets.1 += n.1.current_udp_packets.1; - other_icmp_packets.0 += n.1.current_icmp_packets.0; - other_icmp_packets.1 += n.1.current_icmp_packets.1; - other_xmit.0 += n.1.current_retransmits.0; - other_xmit.1 += n.1.current_retransmits.1; - other_marks.0 += n.1.current_marks.0; - other_marks.1 += n.1.current_marks.1; - other_drops.0 += n.1.current_drops.0; - other_drops.1 += n.1.current_drops.1; - }); + let mut other_tcp_packets = (0, 0); + let mut other_tcp_retransmit_packets = (0, 0); + let mut other_udp_packets = (0, 0); + let mut other_icmp_packets = (0, 0); + let mut other_xmit = (0, 0); + let mut other_marks = (0, 0); + let mut other_drops = (0, 0); + nodes.drain(n_queues..).for_each(|n| { + other_bw.0 += n.1.current_throughput.0; + other_bw.1 += n.1.current_throughput.1; + other_packets.0 += n.1.current_packets.0; + other_packets.1 += n.1.current_packets.1; + other_tcp_packets.0 += n.1.current_tcp_packets.0; + other_tcp_packets.1 += n.1.current_tcp_packets.1; + other_tcp_retransmit_packets.0 += n.1.current_tcp_retransmit_packets.0; + other_tcp_retransmit_packets.1 += n.1.current_tcp_retransmit_packets.1; + other_udp_packets.0 += n.1.current_udp_packets.0; + other_udp_packets.1 += n.1.current_udp_packets.1; + other_icmp_packets.0 += n.1.current_icmp_packets.0; + other_icmp_packets.1 += n.1.current_icmp_packets.1; + other_xmit.0 += n.1.current_retransmits.0; + other_xmit.1 += n.1.current_retransmits.1; + other_marks.0 += n.1.current_marks.0; + other_marks.1 += n.1.current_marks.1; + other_drops.0 += n.1.current_drops.0; + other_drops.1 += n.1.current_drops.1; + }); - nodes.push(( - 0, - NetworkJsonTransport { - name: "Others".into(), - id: None, - is_virtual: false, - runtime_virtualized: false, - max_throughput: (0.0, 0.0), - configured_max_throughput: (0.0, 0.0), - effective_max_throughput: None, - current_throughput: other_bw, - current_packets: other_packets, - current_tcp_packets: other_tcp_packets, - current_tcp_retransmit_packets: other_tcp_retransmit_packets, - current_udp_packets: other_udp_packets, - current_icmp_packets: other_icmp_packets, - current_retransmits: other_xmit, - current_marks: other_marks, - current_drops: other_drops, - rtts: Vec::new(), - qoo: (None, None), - parents: Vec::new(), - immediate_parent: None, - node_type: None, - latitude: None, - longitude: None, - active_attachment_name: None, - subtree_site_count: 0, - subtree_circuit_count: 0, - subtree_device_count: 0, - }, - )); - } + nodes.push(( + 0, + NetworkJsonTransport { + name: "Others".into(), + id: None, + is_virtual: false, + runtime_virtualized: false, + max_throughput: (0.0, 0.0), + configured_max_throughput: (0.0, 0.0), + effective_max_throughput: None, + current_throughput: other_bw, + current_packets: other_packets, + current_tcp_packets: other_tcp_packets, + current_tcp_retransmit_packets: other_tcp_retransmit_packets, + current_udp_packets: other_udp_packets, + current_icmp_packets: other_icmp_packets, + current_retransmits: other_xmit, + current_marks: other_marks, + current_drops: other_drops, + rtts: Vec::new(), + qoo: (None, None), + parents: Vec::new(), + immediate_parent: None, + node_type: None, + latitude: None, + longitude: None, + active_attachment_name: None, + subtree_site_count: 0, + subtree_circuit_count: 0, + subtree_device_count: 0, + }, + )); + } BusResponse::NetworkMap(nodes) } else { BusResponse::Fail("No such node".to_string()) @@ -461,67 +464,8 @@ pub fn map_node_names(nodes: &[usize]) -> BusResponse { }) } -/// Canonical parent-node metadata resolved from `network.json`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ResolvedParentNode { - /// Canonical node name from `network.json`. - pub name: String, - /// Optional stable node identifier from `network.json` metadata. - pub id: Option, -} - -/// Resolve a shaped-device parent reference into canonical `network.json` -/// parent metadata, preferring a stable node ID when one is available. -pub fn resolve_parent_node_reference( - parent_node: &str, - parent_node_id: Option<&str>, -) -> Option { - let trimmed_id = parent_node_id.map(str::trim).filter(|id| !id.is_empty()); - let trimmed = parent_node.trim(); - if trimmed.is_empty() && trimmed_id.is_none() { - return None; - } - lqos_network_devices::with_network_json_read(|net_json| { - let nodes = net_json.get_nodes_when_ready(); - - if let Some(parent_node_id) = trimmed_id - && let Some(node) = nodes - .iter() - .find(|node| node.id.as_deref() == Some(parent_node_id)) - { - return Some(ResolvedParentNode { - name: node.name.clone(), - id: node.id.clone(), - }); - } - - if let Some(node) = nodes.iter().find(|node| node.name == trimmed) { - return Some(ResolvedParentNode { - name: node.name.clone(), - id: node.id.clone(), - }); - } - - nodes.iter().find_map(|node| { - node.active_attachment_name - .as_deref() - .filter(|alias| alias.trim() == trimmed) - .map(|_| ResolvedParentNode { - name: node.name.clone(), - id: node.id.clone(), - }) - }) - }) -} - -/// Resolve a shaped-device parent node or active attachment alias into canonical `network.json` -/// parent metadata. -pub fn resolve_parent_node(parent_node: &str) -> Option { - resolve_parent_node_reference(parent_node, None) -} - pub fn resolve_parent_node_alias(parent_node: &str) -> Option { - resolve_parent_node(parent_node).map(|resolved| resolved.name) + lqos_network_devices::resolve_parent_node(parent_node).map(|resolved| resolved.name) } pub fn get_funnel(circuit_id: &str) -> BusResponse { @@ -535,7 +479,10 @@ pub fn get_funnel(circuit_id: &str) -> BusResponse { .rev() .skip(1) { - result.push((*idx, node_to_transport(&net_json.get_nodes_when_ready()[*idx]))); + result.push(( + *idx, + node_to_transport(&net_json.get_nodes_when_ready()[*idx]), + )); } return BusResponse::NetworkMap(result); } @@ -546,8 +493,7 @@ pub fn get_funnel(circuit_id: &str) -> BusResponse { pub fn get_all_circuits() -> BusResponse { if let Ok(kernel_now) = time_since_boot() { - let devices = lqos_network_devices::shaped_devices_snapshot(); - let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let data = THROUGHPUT_TRACKER .raw_data .lock() @@ -570,15 +516,7 @@ pub fn get_all_circuits() -> BusResponse { let mut parent_node = None; // Plan is expressed in Mbps as f32 let mut plan: DownUpOrder = DownUpOrder { down: 0.0, up: 0.0 }; - let device = v - .device_hash - .and_then(|device_hash| cache.index_by_device_hash(&devices, device_hash)) - .or_else(|| { - v.circuit_hash.and_then(|circuit_hash| { - cache.index_by_circuit_hash(&devices, circuit_hash) - }) - }) - .and_then(|idx| devices.devices.get(idx)); + let device = catalog.device_by_hashes(v.device_hash, v.circuit_hash); if let Some(device) = device { circuit_id = Some(device.circuit_id.clone()); circuit_name = Some(device.circuit_name.clone()); @@ -666,8 +604,7 @@ pub fn get_all_circuits() -> BusResponse { pub fn get_circuit_by_id(desired_circuit_id: String) -> BusResponse { if let Ok(kernel_now) = time_since_boot() { let desired_hash = hash_to_i64(&desired_circuit_id); - let devices = lqos_network_devices::shaped_devices_snapshot(); - let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let data = THROUGHPUT_TRACKER .raw_data .lock() @@ -693,15 +630,7 @@ pub fn get_circuit_by_id(desired_circuit_id: String) -> BusResponse { let mut parent_node = None; // Plan is expressed in Mbps as f32 let mut plan: DownUpOrder = DownUpOrder { down: 0.0, up: 0.0 }; - let device = v - .device_hash - .and_then(|device_hash| cache.index_by_device_hash(&devices, device_hash)) - .or_else(|| { - v.circuit_hash.and_then(|circuit_hash| { - cache.index_by_circuit_hash(&devices, circuit_hash) - }) - }) - .and_then(|idx| devices.devices.get(idx)); + let device = catalog.device_by_hashes(v.device_hash, v.circuit_hash); if let Some(device) = device { circuit_id = Some(device.circuit_id.clone()); circuit_name = Some(device.circuit_name.clone()); diff --git a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs index 13cb7e0a..6070720e 100644 --- a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs +++ b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs @@ -475,8 +475,7 @@ fn enqueue(key: FlowbeeKey, data: FlowbeeLocalData, analysis: FlowAnalysis) { .get(&key.local_ip) .and_then(|te| te.circuit_hash) .or_else(|| { - lqos_network_devices::shaped_devices_snapshot() - .get_circuit_hash_from_ip(&key.local_ip) + lqos_network_devices::shaped_devices_catalog().circuit_hash_for_ip(&key.local_ip) }) }); diff --git a/src/rust/lqosd/src/throughput_tracker/mod.rs b/src/rust/lqosd/src/throughput_tracker/mod.rs index 6e06acd2..b9cedc61 100644 --- a/src/rust/lqosd/src/throughput_tracker/mod.rs +++ b/src/rust/lqosd/src/throughput_tracker/mod.rs @@ -192,8 +192,7 @@ fn throughput_task( lqos_network_devices::with_network_json_write(|net_json_calc| { timer_metrics.update_cycle = timer_metrics.start.elapsed().as_secs_f64(); net_json_calc.zero_throughput_and_rtt(); - timer_metrics.zero_throughput_and_rtt = - timer_metrics.start.elapsed().as_secs_f64(); + timer_metrics.zero_throughput_and_rtt = timer_metrics.start.elapsed().as_secs_f64(); THROUGHPUT_TRACKER.copy_previous_and_reset_rtt(); timer_metrics.copy_previous_and_reset_rtt = timer_metrics.start.elapsed().as_secs_f64(); @@ -454,9 +453,9 @@ pub fn circuit_heatmaps() -> BusResponse { return BusResponse::CircuitHeatmaps(Vec::new()); } - let devices = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut circuit_meta: FxHashMap = FxHashMap::default(); - devices.devices.iter().for_each(|device| { + catalog.iter_devices().for_each(|device| { circuit_meta .entry(device.circuit_hash) .or_insert_with(|| (device.circuit_id.clone(), device.circuit_name.clone())); @@ -893,14 +892,13 @@ fn current_host_counts() -> (u32, u32) { /// Gather headline metrics for the Executive Summary header cards. pub fn executive_summary_header() -> BusResponse { - let devices = lqos_network_devices::shaped_devices_snapshot(); - let circuit_count = devices - .devices - .iter() + let catalog = lqos_network_devices::shaped_devices_catalog(); + let circuit_count = catalog + .iter_devices() .map(|device| device.circuit_hash) .collect::>() .len() as u64; - let device_count = devices.devices.len() as u64; + let device_count = catalog.devices_len() as u64; let site_count = lqos_network_devices::with_network_json_read(|net_json| { let total_nodes = net_json.get_nodes_when_ready().len(); @@ -1110,8 +1108,7 @@ pub fn top_flows(n: u32, flow_type: TopFlowType) -> BusResponse { } } - let shaped = lqos_network_devices::shaped_devices_snapshot(); - let shaped_cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); let result = table @@ -1126,14 +1123,7 @@ pub fn top_flows(n: u32, flow_type: TopFlowType) -> BusResponse { if let Some(id) = &te.circuit_id { circuit_id = id.clone(); } - let shaped_device = te - .device_hash - .and_then(|hash| shaped_cache.index_by_device_hash(&shaped, hash)) - .or_else(|| { - te.circuit_hash - .and_then(|hash| shaped_cache.index_by_circuit_hash(&shaped, hash)) - }) - .and_then(|idx| shaped.devices.get(idx)); + let shaped_device = catalog.device_by_hashes(te.device_hash, te.circuit_hash); if let Some(device) = shaped_device { if circuit_id.is_empty() { circuit_id = device.circuit_id.clone(); @@ -1182,8 +1172,7 @@ pub fn flows_by_ip(ip: &str) -> BusResponse { let ip = XdpIpAddress::from_ip(ip); let lock = ALL_FLOWS.lock(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); - let shaped = lqos_network_devices::shaped_devices_snapshot(); - let shaped_cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let (circuit_id, circuit_name) = { let mut circuit_id = String::new(); let mut circuit_name = String::new(); @@ -1191,14 +1180,7 @@ pub fn flows_by_ip(ip: &str) -> BusResponse { if let Some(id) = &te.circuit_id { circuit_id = id.clone(); } - let shaped_device = te - .device_hash - .and_then(|hash| shaped_cache.index_by_device_hash(&shaped, hash)) - .or_else(|| { - te.circuit_hash - .and_then(|hash| shaped_cache.index_by_circuit_hash(&shaped, hash)) - }) - .and_then(|idx| shaped.devices.get(idx)); + let shaped_device = catalog.device_by_hashes(te.device_hash, te.circuit_hash); if let Some(device) = shaped_device { if circuit_id.is_empty() { circuit_id = device.circuit_id.clone(); diff --git a/src/rust/lqosd/src/throughput_tracker/stats_submission.rs b/src/rust/lqosd/src/throughput_tracker/stats_submission.rs index 007cd8c9..21076c24 100644 --- a/src/rust/lqosd/src/throughput_tracker/stats_submission.rs +++ b/src/rust/lqosd/src/throughput_tracker/stats_submission.rs @@ -287,11 +287,10 @@ pub(crate) fn submit_throughput_stats( let mut circuit_throughput: FxHashMap = FxHashMap::default(); let mut circuit_retransmits: FxHashMap> = FxHashMap::default(); - let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); const CRAZY_LIMIT: u64 = 8; // 8x the max bandwidth - let plan_lookup: FxHashMap = shaped_devices - .devices - .iter() + let plan_lookup: FxHashMap = catalog + .iter_devices() // Bandwidth: mbps * 1_000_000 to bytes .map(|d| { ( @@ -452,8 +451,9 @@ pub(crate) fn submit_throughput_stats( } // Network tree stats - let tree = - lqos_network_devices::with_network_json_read(|net_json| net_json.get_nodes_when_ready().clone()); + let tree = lqos_network_devices::with_network_json_read(|net_json| { + net_json.get_nodes_when_ready().clone() + }); let mut site_throughput: Vec = Vec::new(); let mut site_retransmits: Vec = Vec::new(); let mut site_rtt: Vec = Vec::new(); diff --git a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs index d7c48e55..35ac738b 100644 --- a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs +++ b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs @@ -171,10 +171,10 @@ impl ThroughputTracker { return; } - let shaped_devices = lqos_network_devices::shaped_devices_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut capacity_lookup: FxHashMap = FxHashMap::default(); - capacity_lookup.reserve(shaped_devices.devices.len()); - shaped_devices.devices.iter().for_each(|device| { + capacity_lookup.reserve(catalog.devices_len()); + catalog.iter_devices().for_each(|device| { let entry = capacity_lookup .entry(device.circuit_hash) .or_insert((device.download_max_mbps, device.upload_max_mbps)); @@ -402,47 +402,30 @@ impl ThroughputTracker { }); } - fn shaped_device_for_hashes<'a>( - shaped: &'a lqos_config::ConfigShapedDevices, - cache: &lqos_network_devices::ShapedDeviceHashCache, + fn shaped_device_for_hashes( + catalog: &lqos_network_devices::ShapedDevicesCatalog, device_hash: Option, circuit_hash: Option, - ) -> Option<&'a lqos_config::ShapedDevice> { - if let Some(device_hash) = device_hash - && let Some(idx) = cache.index_by_device_hash(shaped, device_hash) - { - return shaped.devices.get(idx); - } - if let Some(circuit_hash) = circuit_hash - && let Some(idx) = cache.index_by_circuit_hash(shaped, circuit_hash) - { - return shaped.devices.get(idx); - } - None + ) -> Option<&lqos_config::ShapedDevice> { + catalog.device_by_hashes(device_hash, circuit_hash) } fn lookup_network_parents_from_hashes( - shaped: &lqos_config::ConfigShapedDevices, - cache: &lqos_network_devices::ShapedDeviceHashCache, + catalog: &lqos_network_devices::ShapedDevicesCatalog, device_hash: Option, circuit_hash: Option, lock: &NetworkJson, ) -> Option> { - Self::shaped_device_for_hashes(shaped, cache, device_hash, circuit_hash) + Self::shaped_device_for_hashes(catalog, device_hash, circuit_hash) .and_then(|device| lock.get_parents_for_circuit_id(&device.parent_node)) } pub(crate) fn refresh_circuit_ids(&self, lock: &NetworkJson) { - let shaped = lqos_network_devices::shaped_devices_snapshot(); - let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut raw_data = self.raw_data.lock(); raw_data.iter_mut().for_each(|(_key, data)| { - let shaped_device = Self::shaped_device_for_hashes( - &shaped, - &cache, - data.device_hash, - data.circuit_hash, - ); + let shaped_device = + Self::shaped_device_for_hashes(&catalog, data.device_hash, data.circuit_hash); if data.circuit_hash.is_none() && let Some(device) = shaped_device { @@ -462,8 +445,7 @@ impl ThroughputTracker { let mut changed_circuits = HashSet::new(); let self_cycle = self.cycle.load(std::sync::atomic::Ordering::Relaxed); - let shaped = lqos_network_devices::shaped_devices_snapshot(); - let cache = lqos_network_devices::shaped_device_hash_cache_snapshot(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut raw_data = self.raw_data.lock(); throughput_for_each(&mut |xdp_ip, counts| { let reduced = ReducedHostCounters::from_counters(counts); @@ -484,8 +466,7 @@ impl ThroughputTracker { entry.device_hash = reduced.device_hash; if hashes_changed { let shaped_device = Self::shaped_device_for_hashes( - &shaped, - &cache, + &catalog, entry.device_hash, entry.circuit_hash, ); @@ -555,7 +536,7 @@ impl ThroughputTracker { let circuit_hash = reduced.circuit_hash; let device_hash = reduced.device_hash; let shaped_device = - Self::shaped_device_for_hashes(&shaped, &cache, device_hash, circuit_hash); + Self::shaped_device_for_hashes(&catalog, device_hash, circuit_hash); let circuit_id = shaped_device.map(|d| d.circuit_id.clone()); // Call the Bakery Queue Creation for new circuits if let Some(circuit_hash) = circuit_hash @@ -580,8 +561,7 @@ impl ThroughputTracker { circuit_hash, device_hash, network_json_parents: Self::lookup_network_parents_from_hashes( - &shaped, - &cache, + &catalog, device_hash, circuit_hash, net_json_calc, diff --git a/src/rust/lqosd/src/treeguard/actor.rs b/src/rust/lqosd/src/treeguard/actor.rs index 8bc93ae8..be8799be 100644 --- a/src/rust/lqosd/src/treeguard/actor.rs +++ b/src/rust/lqosd/src/treeguard/actor.rs @@ -16,14 +16,14 @@ use crate::treeguard::{bakery, decisions, overrides}; use crossbeam_channel::{Receiver, Sender}; use fxhash::{FxHashMap, FxHashSet}; use lqos_bakery::{BakeryRuntimeNodeOperationFailureReason, BakeryRuntimeNodeOperationStatus}; -use lqos_config::{NetworkJsonNode, ShapedDevice, load_config}; +use lqos_config::{NetworkJsonNode, load_config}; use lqos_overrides::{NetworkAdjustment, OverrideFile, OverrideLayer, OverrideStore}; use lqos_utils::hash_to_i64; use lqos_utils::units::DownUpOrder; use lqos_utils::unix_time::{time_since_boot, unix_now}; use parking_lot::RwLock; use std::collections::{BTreeMap, VecDeque}; -use std::sync::{Arc, OnceLock}; +use std::sync::OnceLock; use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; @@ -277,17 +277,7 @@ fn current_topology_totals() -> (usize, usize) { }) }; - let total_circuits = { - let shaped = lqos_network_devices::shaped_devices_snapshot(); - let mut circuits: FxHashSet<&str> = FxHashSet::default(); - for d in shaped.devices.iter() { - let id = d.circuit_id.trim(); - if !id.is_empty() { - circuits.insert(id); - } - } - circuits.len() - }; + let total_circuits = lqos_network_devices::shaped_devices_catalog().configured_circuit_count(); (total_nodes, total_circuits) } @@ -304,9 +294,11 @@ fn direct_child_site_counts_by_node(nodes: &[NetworkJsonNode]) -> Vec { counts } -fn direct_circuit_counts_by_node(shaped_devices: &[ShapedDevice]) -> FxHashMap { +fn direct_circuit_counts_by_node( + shaped_devices: &lqos_network_devices::ShapedDevicesCatalog, +) -> FxHashMap { let mut circuits_by_node: FxHashMap> = FxHashMap::default(); - for device in shaped_devices { + for device in shaped_devices.iter_devices() { let node_name = device.parent_node.trim(); let circuit_id = device.circuit_id.trim(); if node_name.is_empty() || circuit_id.is_empty() { @@ -452,7 +444,7 @@ struct CircuitInventoryEntry { #[derive(Clone, Debug, Default)] struct CircuitInventory { - shaped_devices_ptr: usize, + shaped_devices_generation: u64, circuit_ids: Vec, entries: FxHashMap, all_device_ids: FxHashSet, @@ -606,21 +598,23 @@ fn select_link_virtualization_candidates( fn ensure_circuit_inventory( runtime_state: &mut TreeguardRuntimeState, - shaped: &Arc, + shaped: &lqos_network_devices::ShapedDevicesCatalog, ) { - let shaped_devices_ptr = Arc::as_ptr(shaped) as usize; - if runtime_state.circuit_inventory.shaped_devices_ptr == shaped_devices_ptr { + let generation = shaped.generation(); + if runtime_state.circuit_inventory.shaped_devices_generation == generation { return; } - runtime_state.circuit_inventory = build_circuit_inventory(shaped.as_ref()); + runtime_state.circuit_inventory = build_circuit_inventory(shaped); runtime_state.circuit_batch_cursor = 0; } -fn build_circuit_inventory(shaped: &lqos_config::ConfigShapedDevices) -> CircuitInventory { +fn build_circuit_inventory( + shaped: &lqos_network_devices::ShapedDevicesCatalog, +) -> CircuitInventory { let mut circuits_by_device_id: FxHashMap> = FxHashMap::default(); - circuits_by_device_id.reserve(shaped.devices.len()); - for device in shaped.devices.iter() { + circuits_by_device_id.reserve(shaped.devices_len()); + for device in shaped.iter_devices() { let device_id = device.device_id.trim(); let circuit_id = device.circuit_id.trim(); if device_id.is_empty() || circuit_id.is_empty() { @@ -645,10 +639,10 @@ fn build_circuit_inventory(shaped: &lqos_config::ConfigShapedDevices) -> Circuit .collect(); let mut by_circuit_id: FxHashMap> = FxHashMap::default(); - by_circuit_id.reserve(shaped.devices.len()); + by_circuit_id.reserve(shaped.devices_len()); let mut all_device_ids = FxHashSet::default(); - all_device_ids.reserve(shaped.devices.len()); - for device in shaped.devices.iter() { + all_device_ids.reserve(shaped.devices_len()); + for device in shaped.iter_devices() { if device.circuit_id.trim().is_empty() { continue; } @@ -724,7 +718,7 @@ fn build_circuit_inventory(shaped: &lqos_config::ConfigShapedDevices) -> Circuit } CircuitInventory { - shaped_devices_ptr: shaped as *const _ as usize, + shaped_devices_generation: shaped.generation(), circuit_ids, entries, all_device_ids, @@ -837,7 +831,7 @@ fn run_tick( return; } - let shaped = lqos_network_devices::shaped_devices_snapshot(); + let shaped = lqos_network_devices::shaped_devices_catalog(); ensure_circuit_inventory(runtime_state, &shaped); let link_states = &mut runtime_state.link_states; @@ -1031,396 +1025,418 @@ fn run_tick( } } else { lqos_network_devices::with_network_json_read(|net_json| { - let top_level_nodes: FxHashSet = if top_level_auto_virtualize && !tg.links.all_nodes - { - net_json - .get_nodes_when_ready() - .iter() - .filter(|n| n.name != "Root" && n.immediate_parent == Some(0)) - .map(|n| n.name.clone()) - .collect() - } else { - FxHashSet::default() - }; + let top_level_nodes: FxHashSet = + if top_level_auto_virtualize && !tg.links.all_nodes { + net_json + .get_nodes_when_ready() + .iter() + .filter(|n| n.name != "Root" && n.immediate_parent == Some(0)) + .map(|n| n.name.clone()) + .collect() + } else { + FxHashSet::default() + }; - // Reconcile nodes removed from allowlist, or removed from network.json. - let treeguard_nodes_with_overrides: FxHashSet = treeguard_overrides_snapshot - .as_ref() - .map(|of| { - of.network_adjustments() + // Reconcile nodes removed from allowlist, or removed from network.json. + let treeguard_nodes_with_overrides: FxHashSet = treeguard_overrides_snapshot + .as_ref() + .map(|of| { + of.network_adjustments() + .iter() + .filter_map(|adj| match adj { + NetworkAdjustment::SetNodeVirtual { node_name, .. } => { + Some(node_name.clone()) + } + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + + let mut removed: FxHashSet = if tg.links.all_nodes { + let current: FxHashSet<&str> = net_json + .get_nodes_when_ready() .iter() - .filter_map(|adj| match adj { - NetworkAdjustment::SetNodeVirtual { node_name, .. } => { - Some(node_name.clone()) - } - _ => None, - }) - .collect() - }) - .unwrap_or_default(); - - let mut removed: FxHashSet = if tg.links.all_nodes { - let current: FxHashSet<&str> = net_json - .get_nodes_when_ready() - .iter() - .filter(|n| n.name != "Root") - .map(|n| n.name.as_str()) - .collect(); - treeguard_nodes_with_overrides - .iter() - .filter(|n| !current.contains(n.as_str())) - .cloned() - .collect() - } else { - treeguard_nodes_with_overrides - .iter() - .filter(|n| !allowlisted_nodes.contains(*n) && !top_level_nodes.contains(*n)) - .cloned() - .collect() - }; - if tg.links.all_nodes { - let current: FxHashSet<&str> = net_json - .get_nodes_when_ready() - .iter() - .filter(|n| n.name != "Root") - .map(|n| n.name.as_str()) - .collect(); - removed.extend( - runtime_virtualized_nodes + .filter(|n| n.name != "Root") + .map(|n| n.name.as_str()) + .collect(); + treeguard_nodes_with_overrides .iter() .filter(|n| !current.contains(n.as_str())) - .cloned(), - ); - } else { - removed.extend( - runtime_virtualized_nodes + .cloned() + .collect() + } else { + treeguard_nodes_with_overrides .iter() .filter(|n| !allowlisted_nodes.contains(*n) && !top_level_nodes.contains(*n)) - .cloned(), - ); - } - for node_name in removed { - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - &node_name, - "Node removed from allowlist", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - &node_name, - "Node removed from allowlist", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(&node_name); - link_states.remove(&node_name); - } - let nodes = net_json.get_nodes_when_ready(); - let parent_by_index: Vec> = - nodes.iter().map(|node| node.immediate_parent).collect(); - let subtree_node_counts = build_subtree_node_counts(&parent_by_index); - let direct_child_site_counts = direct_child_site_counts_by_node(nodes); - let direct_circuit_counts = direct_circuit_counts_by_node(&shaped.devices); - let retained_runtime_branch_nodes: FxHashSet = - lqos_bakery::bakery_runtime_node_branch_snapshots() - .into_iter() - .map(|snapshot| snapshot.site_name) - .collect(); - let existing_virtualized_indices: FxHashSet = nodes - .iter() - .enumerate() - .filter_map(|(index, node)| { - (runtime_virtualized_nodes.contains(&node.name) - || retained_runtime_branch_nodes.contains(&node.name)) - .then_some(index) - }) - .collect(); - - let mut enrolled_nodes: Vec = if tg.links.all_nodes { - nodes - .iter() - .filter(|node| node.name != "Root") - .map(|node| node.name.clone()) - .collect() - } else { - let mut enrolled = tg.links.nodes.clone(); - if top_level_auto_virtualize { - enrolled.extend(top_level_nodes.iter().cloned()); + .cloned() + .collect() + }; + if tg.links.all_nodes { + let current: FxHashSet<&str> = net_json + .get_nodes_when_ready() + .iter() + .filter(|n| n.name != "Root") + .map(|n| n.name.as_str()) + .collect(); + removed.extend( + runtime_virtualized_nodes + .iter() + .filter(|n| !current.contains(n.as_str())) + .cloned(), + ); + } else { + removed.extend( + runtime_virtualized_nodes + .iter() + .filter(|n| { + !allowlisted_nodes.contains(*n) && !top_level_nodes.contains(*n) + }) + .cloned(), + ); } - enrolled.sort(); - enrolled.dedup(); - enrolled - }; + for node_name in removed { + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + &node_name, + "Node removed from allowlist", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + &node_name, + "Node removed from allowlist", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(&node_name); + link_states.remove(&node_name); + } + let nodes = net_json.get_nodes_when_ready(); + let parent_by_index: Vec> = + nodes.iter().map(|node| node.immediate_parent).collect(); + let subtree_node_counts = build_subtree_node_counts(&parent_by_index); + let direct_child_site_counts = direct_child_site_counts_by_node(nodes); + let direct_circuit_counts = direct_circuit_counts_by_node(&shaped); + let retained_runtime_branch_nodes: FxHashSet = + lqos_bakery::bakery_runtime_node_branch_snapshots() + .into_iter() + .map(|snapshot| snapshot.site_name) + .collect(); + let existing_virtualized_indices: FxHashSet = nodes + .iter() + .enumerate() + .filter_map(|(index, node)| { + (runtime_virtualized_nodes.contains(&node.name) + || retained_runtime_branch_nodes.contains(&node.name)) + .then_some(index) + }) + .collect(); - if tg.links.all_nodes { - enrolled_nodes.sort(); - } + let mut enrolled_nodes: Vec = if tg.links.all_nodes { + nodes + .iter() + .filter(|node| node.name != "Root") + .map(|node| node.name.clone()) + .collect() + } else { + let mut enrolled = tg.links.nodes.clone(); + if top_level_auto_virtualize { + enrolled.extend(top_level_nodes.iter().cloned()); + } + enrolled.sort(); + enrolled.dedup(); + enrolled + }; - let mut pending_link_decisions = Vec::new(); + if tg.links.all_nodes { + enrolled_nodes.sort(); + } - for node_name in enrolled_nodes.iter() { - if operator_virtual_node_overrides.contains(node_name) { - status.warnings.push(format!( + let mut pending_link_decisions = Vec::new(); + + for node_name in enrolled_nodes.iter() { + if operator_virtual_node_overrides.contains(node_name) { + status.warnings.push(format!( "TreeGuard links: node '{node_name}' has an operator virtual override; TreeGuard will not manage it." )); - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - node_name, - "Operator override present; TreeGuard will not manage this node.", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - node_name, - "Operator override present; TreeGuard will not manage this node.", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(node_name); - link_states.remove(node_name); - continue; - } + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + node_name, + "Operator override present; TreeGuard will not manage this node.", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + node_name, + "Operator override present; TreeGuard will not manage this node.", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(node_name); + link_states.remove(node_name); + continue; + } - let Some(index) = net_json.get_index_for_name(node_name) else { - status.warnings.push(format!( - "TreeGuard links allowlist: node '{node_name}' not found in network.json." - )); - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - node_name, - "Node no longer exists in network.json", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - node_name, - "Node no longer exists in network.json", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(node_name); - link_states.remove(node_name); - continue; - }; - let Some(node) = nodes.get(index) else { - status.warnings.push(format!( - "TreeGuard links allowlist: node '{node_name}' index not present." - )); - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - node_name, - "Node index no longer exists in network.json", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - node_name, - "Node index no longer exists in network.json", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(node_name); - link_states.remove(node_name); - continue; - }; + let Some(index) = net_json.get_index_for_name(node_name) else { + status.warnings.push(format!( + "TreeGuard links allowlist: node '{node_name}' not found in network.json." + )); + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + node_name, + "Node no longer exists in network.json", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + node_name, + "Node no longer exists in network.json", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(node_name); + link_states.remove(node_name); + continue; + }; + let Some(node) = nodes.get(index) else { + status.warnings.push(format!( + "TreeGuard links allowlist: node '{node_name}' index not present." + )); + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + node_name, + "Node index no longer exists in network.json", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + node_name, + "Node index no longer exists in network.json", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(node_name); + link_states.remove(node_name); + continue; + }; - if node.virtual_node { - status.warnings.push(format!( + if node.virtual_node { + status.warnings.push(format!( "TreeGuard links: node '{node_name}' is marked virtual in base network.json; TreeGuard will not manage it." )); - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - node_name, - "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - node_name, - "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(node_name); - link_states.remove(node_name); - continue; - } + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + node_name, + "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + node_name, + "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(node_name); + link_states.remove(node_name); + continue; + } - let cap_down = node.max_throughput.0; - let cap_up = node.max_throughput.1; - if cap_down <= 0.0 || cap_up <= 0.0 { - status.warnings.push(format!( + let cap_down = node.max_throughput.0; + let cap_up = node.max_throughput.1; + if cap_down <= 0.0 || cap_up <= 0.0 { + status.warnings.push(format!( "TreeGuard links: node '{node_name}' has unknown capacity; no changes will be made." )); - continue; - } - - let bytes_down = node.current_throughput.get_down() as f64; - let bytes_up = node.current_throughput.get_up() as f64; - let mbps_down = (bytes_down * 8.0) / 1_000_000.0; - let mbps_up = (bytes_up * 8.0) / 1_000_000.0; - let util_down_pct = (mbps_down / cap_down) * 100.0; - let util_up_pct = (mbps_up / cap_up) * 100.0; - - let state = link_states.entry(node_name.clone()).or_insert_with(|| { - let mut state = LinkState::default(); - if runtime_virtualized_nodes.contains(node_name) { - state.desired = LinkVirtualState::Virtual; + continue; } - state - }); - prune_recent_changes(&mut state.recent_changes_unix, now_unix); - let topology_fingerprint = LinkTopologyFingerprint { - direct_child_sites: direct_child_site_counts.get(index).copied().unwrap_or(0), - direct_circuits: direct_circuit_counts.get(node_name).copied().unwrap_or(0), - }; - if clear_structural_ineligible_if_topology_changed(state, topology_fingerprint) { - link_virtualization_backoff_until_unix.remove(node_name); - } - state.topology_fingerprint = topology_fingerprint; - let ewma_down = state - .down - .util_ewma_pct - .update(util_down_pct, UTIL_EWMA_ALPHA); - let ewma_up = state.up.util_ewma_pct.update(util_up_pct, UTIL_EWMA_ALPHA); + let bytes_down = node.current_throughput.get_down() as f64; + let bytes_up = node.current_throughput.get_up() as f64; + let mbps_down = (bytes_down * 8.0) / 1_000_000.0; + let mbps_up = (bytes_up * 8.0) / 1_000_000.0; + let util_down_pct = (mbps_down / cap_down) * 100.0; + let util_up_pct = (mbps_up / cap_up) * 100.0; - update_idle_since( - &mut state.down.idle_since_unix, - now_unix, - ewma_down, - tg.links.idle_util_pct as f64, - ); - update_idle_since( - &mut state.up.idle_since_unix, - now_unix, - ewma_up, - tg.links.idle_util_pct as f64, - ); - - let sustained_idle = is_sustained_idle( - now_unix, - state.down.idle_since_unix, - state.up.idle_since_unix, - tg.links.idle_min_minutes, - ); - - let is_top_level = top_level_auto_virtualize && node.immediate_parent == Some(0); - let top_level_safe_util_pct = tg.links.top_level_safe_util_pct.clamp(0.0, 100.0) as f64; - if is_top_level { - update_below_since( - &mut state.down.top_level_safe_since_unix, - now_unix, - ewma_down, - top_level_safe_util_pct, - ); - update_below_since( - &mut state.up.top_level_safe_since_unix, - now_unix, - ewma_up, - top_level_safe_util_pct, - ); - update_above_since( - &mut state.down.top_level_emergency_since_unix, - now_unix, - ewma_down, - TOP_LEVEL_EMERGENCY_UTIL_PCT, - ); - update_above_since( - &mut state.up.top_level_emergency_since_unix, - now_unix, - ewma_up, - TOP_LEVEL_EMERGENCY_UTIL_PCT, - ); - } - - let rtt_missing = match now_nanos_since_boot { - None => true, - Some(now_nanos) => { - if node.rtt_buffer.last_seen == 0 { - true - } else { - let age_nanos = now_nanos.saturating_sub(node.rtt_buffer.last_seen); - age_nanos - >= u64::from(tg.links.rtt_missing_seconds).saturating_mul(1_000_000_000) + let state = link_states.entry(node_name.clone()).or_insert_with(|| { + let mut state = LinkState::default(); + if runtime_virtualized_nodes.contains(node_name) { + state.desired = LinkVirtualState::Virtual; } - } - }; - - let qoo = node - .qoq_heatmap - .as_ref() - .map(|heatmap| { - let blocks = heatmap.blocks(); - let latest = |values: &[Option]| values.iter().rev().find_map(|v| *v); - DownUpOrder { - down: latest(&blocks.download_total), - up: latest(&blocks.upload_total), - } - }) - .unwrap_or(DownUpOrder { - down: None, - up: None, + state }); + prune_recent_changes(&mut state.recent_changes_unix, now_unix); + let topology_fingerprint = LinkTopologyFingerprint { + direct_child_sites: direct_child_site_counts.get(index).copied().unwrap_or(0), + direct_circuits: direct_circuit_counts.get(node_name).copied().unwrap_or(0), + }; + if clear_structural_ineligible_if_topology_changed(state, topology_fingerprint) { + link_virtualization_backoff_until_unix.remove(node_name); + } + state.topology_fingerprint = topology_fingerprint; - let util_ewma_pct = DownUpOrder { - down: ewma_down, - up: ewma_up, - }; - - let decision = if is_top_level { - let sustained_safe = is_sustained_window( - now_unix, - state.down.top_level_safe_since_unix, - state.up.top_level_safe_since_unix, - TOP_LEVEL_SAFE_SUSTAIN_MINUTES, - ); - let emergency_util_sustained = state + let ewma_down = state .down - .top_level_emergency_since_unix - .is_some_and(|since| { - now_unix.saturating_sub(since) >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS + .util_ewma_pct + .update(util_down_pct, UTIL_EWMA_ALPHA); + let ewma_up = state.up.util_ewma_pct.update(util_up_pct, UTIL_EWMA_ALPHA); + + update_idle_since( + &mut state.down.idle_since_unix, + now_unix, + ewma_down, + tg.links.idle_util_pct as f64, + ); + update_idle_since( + &mut state.up.idle_since_unix, + now_unix, + ewma_up, + tg.links.idle_util_pct as f64, + ); + + let sustained_idle = is_sustained_idle( + now_unix, + state.down.idle_since_unix, + state.up.idle_since_unix, + tg.links.idle_min_minutes, + ); + + let is_top_level = top_level_auto_virtualize && node.immediate_parent == Some(0); + let top_level_safe_util_pct = + tg.links.top_level_safe_util_pct.clamp(0.0, 100.0) as f64; + if is_top_level { + update_below_since( + &mut state.down.top_level_safe_since_unix, + now_unix, + ewma_down, + top_level_safe_util_pct, + ); + update_below_since( + &mut state.up.top_level_safe_since_unix, + now_unix, + ewma_up, + top_level_safe_util_pct, + ); + update_above_since( + &mut state.down.top_level_emergency_since_unix, + now_unix, + ewma_down, + TOP_LEVEL_EMERGENCY_UTIL_PCT, + ); + update_above_since( + &mut state.up.top_level_emergency_since_unix, + now_unix, + ewma_up, + TOP_LEVEL_EMERGENCY_UTIL_PCT, + ); + } + + let rtt_missing = match now_nanos_since_boot { + None => true, + Some(now_nanos) => { + if node.rtt_buffer.last_seen == 0 { + true + } else { + let age_nanos = now_nanos.saturating_sub(node.rtt_buffer.last_seen); + age_nanos + >= u64::from(tg.links.rtt_missing_seconds) + .saturating_mul(1_000_000_000) + } + } + }; + + let qoo = node + .qoq_heatmap + .as_ref() + .map(|heatmap| { + let blocks = heatmap.blocks(); + let latest = |values: &[Option]| values.iter().rev().find_map(|v| *v); + DownUpOrder { + down: latest(&blocks.download_total), + up: latest(&blocks.upload_total), + } }) - || state - .up + .unwrap_or(DownUpOrder { + down: None, + up: None, + }); + + let util_ewma_pct = DownUpOrder { + down: ewma_down, + up: ewma_up, + }; + + let decision = if is_top_level { + let sustained_safe = is_sustained_window( + now_unix, + state.down.top_level_safe_since_unix, + state.up.top_level_safe_since_unix, + TOP_LEVEL_SAFE_SUSTAIN_MINUTES, + ); + let emergency_util_sustained = state + .down .top_level_emergency_since_unix .is_some_and(|since| { now_unix.saturating_sub(since) >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS - }); - decisions::decide_top_level_link_virtualization( - decisions::TopLevelLinkVirtualizationInput { + }) + || state + .up + .top_level_emergency_since_unix + .is_some_and(|since| { + now_unix.saturating_sub(since) + >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS + }); + decisions::decide_top_level_link_virtualization( + decisions::TopLevelLinkVirtualizationInput { + now_unix, + cpu_max_pct, + cpu_cfg: &tg.cpu, + links_cfg: &tg.links, + qoo_cfg: &tg.qoo, + rtt_missing, + qoo, + util_ewma_pct, + safe_util_pct: top_level_safe_util_pct, + sustained_safe, + emergency_util_sustained, + state, + }, + ) + } else { + decisions::decide_link_virtualization(decisions::LinkVirtualizationInput { now_unix, + allowlisted: tg.links.all_nodes || allowlisted_nodes.contains(node_name), cpu_max_pct, cpu_cfg: &tg.cpu, links_cfg: &tg.links, @@ -1428,34 +1444,17 @@ fn run_tick( rtt_missing, qoo, util_ewma_pct, - safe_util_pct: top_level_safe_util_pct, - sustained_safe, - emergency_util_sustained, + sustained_idle, state, - }, - ) - } else { - decisions::decide_link_virtualization(decisions::LinkVirtualizationInput { - now_unix, - allowlisted: tg.links.all_nodes || allowlisted_nodes.contains(node_name), - cpu_max_pct, - cpu_cfg: &tg.cpu, - links_cfg: &tg.links, - qoo_cfg: &tg.qoo, - rtt_missing, - qoo, - util_ewma_pct, - sustained_idle, - state, - }) - }; + }) + }; - if let decisions::LinkVirtualDecision::Set(target) = decision - && target != state.desired - { - if let Some(reason) = latched_structural_ineligible_reason(state, target) { - let details = structural_failure_reason_label(reason); - warning_limiter.push( + if let decisions::LinkVirtualDecision::Set(target) = decision + && target != state.desired + { + if let Some(reason) = latched_structural_ineligible_reason(state, target) { + let details = structural_failure_reason_label(reason); + warning_limiter.push( status, format!( "runtime_structural_ineligible|{}|{:?}", @@ -1468,103 +1467,102 @@ fn run_tick( "TreeGuard runtime structural-ineligible warnings for reason={details}" ), ); - continue; - } - let reason = if is_top_level { - match target { - LinkVirtualState::Virtual => format!( - "Top-level safe: sustained utilization below {:.1}% for {} minutes", - top_level_safe_util_pct, TOP_LEVEL_SAFE_SUSTAIN_MINUTES - ), - LinkVirtualState::Physical => { - if state - .down - .top_level_emergency_since_unix - .is_some_and(|since| { - now_unix.saturating_sub(since) - >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS - }) - || state - .up + continue; + } + let reason = if is_top_level { + match target { + LinkVirtualState::Virtual => format!( + "Top-level safe: sustained utilization below {:.1}% for {} minutes", + top_level_safe_util_pct, TOP_LEVEL_SAFE_SUSTAIN_MINUTES + ), + LinkVirtualState::Physical => { + if state + .down .top_level_emergency_since_unix .is_some_and(|since| { now_unix.saturating_sub(since) >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS }) - { - format!( - "Top-level emergency restore: utilization >= {:.1}% for {}s", - TOP_LEVEL_EMERGENCY_UTIL_PCT, - TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS - ) - } else { - format!( - "Top-level restore: utilization above {:.1}%", - top_level_safe_util_pct - ) + || state.up.top_level_emergency_since_unix.is_some_and( + |since| { + now_unix.saturating_sub(since) + >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS + }, + ) + { + format!( + "Top-level emergency restore: utilization >= {:.1}% for {}s", + TOP_LEVEL_EMERGENCY_UTIL_PCT, + TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS + ) + } else { + format!( + "Top-level restore: utilization above {:.1}%", + top_level_safe_util_pct + ) + } } } - } - } else { - "Decision policy matched".to_string() - }; - pending_link_decisions.push(PendingLinkVirtualizationDecision { - node_name: node_name.clone(), - node_index: index, - target, - reason, - subtree_nodes: subtree_node_counts[index], - current_subtree_throughput_mbps: mbps_down.max(mbps_up), - explicit_allowlist: allowlisted_nodes.contains(node_name), - is_top_level, - value_score: link_virtualization_value_score( + } else { + "Decision policy matched".to_string() + }; + pending_link_decisions.push(PendingLinkVirtualizationDecision { + node_name: node_name.clone(), + node_index: index, + target, + reason, + subtree_nodes: subtree_node_counts[index], + current_subtree_throughput_mbps: mbps_down.max(mbps_up), + explicit_allowlist: allowlisted_nodes.contains(node_name), is_top_level, - subtree_node_counts[index], - cap_down, - cap_up, - ), - }); + value_score: link_virtualization_value_score( + is_top_level, + subtree_node_counts[index], + cap_down, + cap_up, + ), + }); + } + + managed_nodes.insert(node_name.clone()); } - managed_nodes.insert(node_name.clone()); - } - - let (selected_link_decisions, deferred_link_decisions, skipped_low_value_decisions) = - select_link_virtualization_candidates( - pending_link_decisions, - &parent_by_index, - &existing_virtualized_indices, - runtime_virtualized_nodes.len(), - ); - if deferred_link_decisions > 0 { - status.warnings.push(format!( + let (selected_link_decisions, deferred_link_decisions, skipped_low_value_decisions) = + select_link_virtualization_candidates( + pending_link_decisions, + &parent_by_index, + &existing_virtualized_indices, + runtime_virtualized_nodes.len(), + ); + if deferred_link_decisions > 0 { + status.warnings.push(format!( "TreeGuard links: deferred {deferred_link_decisions} lower-value or over-budget node virtualization changes this tick." )); - } - if skipped_low_value_decisions > 0 { - status.warnings.push(format!( + } + if skipped_low_value_decisions > 0 { + status.warnings.push(format!( "TreeGuard links: skipped {skipped_low_value_decisions} low-value automatic node virtualization candidates this tick because the subtree was too small for its current throughput." )); - } + } - for decision in selected_link_decisions { - let Some(state) = link_states.get_mut(&decision.node_name) else { - continue; - }; - apply_link_virtualization_decision( - status, - activity, - now_unix, - &decision.node_name, - decision.target, - decision.is_top_level, - decision.reason, - tg.dry_run, - state, - pending_link_operations, - link_virtualization_backoff_until_unix, - ); - } + for decision in selected_link_decisions { + let Some(state) = link_states.get_mut(&decision.node_name) else { + continue; + }; + apply_link_virtualization_decision( + status, + activity, + now_unix, + &decision.node_name, + decision.target, + decision.is_top_level, + decision.reason, + tg.dry_run, + state, + pending_link_operations, + link_virtualization_backoff_until_unix, + ); + } }); } From 776a2e6b00d6b522e067ff4415d829474812051f Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 11 Apr 2026 09:20:21 -0500 Subject: [PATCH 03/20] feat: dynamic circuit overlay scaffolding --- src/rust/lqos_network_devices/src/actor.rs | 89 ++++++++++++++ src/rust/lqos_network_devices/src/dynamic.rs | 25 ++++ src/rust/lqos_network_devices/src/lib.rs | 16 +++ src/rust/lqos_network_devices/src/state.rs | 40 +++++++ src/rust/lqos_network_devices/src/tests.rs | 41 ++++++- .../src/throughput_tracker/tracking_data.rs | 113 +++++++++++++++++- 6 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 src/rust/lqos_network_devices/src/dynamic.rs diff --git a/src/rust/lqos_network_devices/src/actor.rs b/src/rust/lqos_network_devices/src/actor.rs index 77254c14..f3066a5b 100644 --- a/src/rust/lqos_network_devices/src/actor.rs +++ b/src/rust/lqos_network_devices/src/actor.rs @@ -1,8 +1,10 @@ +use crate::dynamic::CircuitObservation; use crate::state; use crate::{DaemonHooks, load_network_json, load_shaped_devices}; use anyhow::{Result, anyhow}; use crossbeam_channel::{Receiver, Sender}; use once_cell::sync::OnceCell; +use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; use tokio::sync::oneshot; @@ -72,6 +74,19 @@ pub(crate) fn apply_shaped_devices_snapshot( .map_err(|_| anyhow!("Apply shaped devices reply channel closed"))? } +pub(crate) fn report_observations(observations: &[CircuitObservation]) { + if observations.is_empty() { + return; + } + + let Some(sender) = ACTOR_SENDER.get().cloned() else { + return; + }; + let _ = sender.try_send(NetworkDevicesCommand::ReportObservations { + observations: observations.to_vec(), + }); +} + enum NetworkDevicesCommand { ReloadShapedDevices { reason: String, @@ -86,6 +101,9 @@ enum NetworkDevicesCommand { shaped: Box, reply: oneshot::Sender>, }, + ReportObservations { + observations: Vec, + }, } fn actor_loop(rx: Receiver, hooks: Option>) { @@ -120,12 +138,83 @@ fn actor_loop(rx: Receiver, hooks: Option { + handle_observations(&observations); + } } } error!("lqos_network_devices actor stopped"); } +fn handle_observations(observations: &[CircuitObservation]) { + if observations.is_empty() { + return; + } + + let catalog = state::shaped_devices_catalog(); + let dynamic_snapshot = state::dynamic_circuits_snapshot(); + let mut dynamic_device_hashes: HashSet = HashSet::new(); + let mut dynamic_circuit_hashes: HashSet = HashSet::new(); + dynamic_device_hashes.reserve(dynamic_snapshot.len()); + dynamic_circuit_hashes.reserve(dynamic_snapshot.len()); + for circuit in dynamic_snapshot.iter() { + dynamic_device_hashes.insert(circuit.shaped.device_hash); + dynamic_circuit_hashes.insert(circuit.shaped.circuit_hash); + } + + let mut seen_device_hashes: HashSet = HashSet::new(); + let mut seen_circuit_hashes: HashSet = HashSet::new(); + let mut unknown_candidates: Vec = Vec::new(); + + for observation in observations { + if catalog + .device_by_hashes(observation.device_hash, observation.circuit_hash) + .is_some() + { + continue; + } + + if let Some(device_hash) = observation.device_hash + && dynamic_device_hashes.contains(&device_hash) + { + seen_device_hashes.insert(device_hash); + continue; + } + + if let Some(circuit_hash) = observation.circuit_hash + && dynamic_circuit_hashes.contains(&circuit_hash) + { + seen_circuit_hashes.insert(circuit_hash); + continue; + } + + unknown_candidates.push(*observation); + } + + if (!seen_device_hashes.is_empty() || !seen_circuit_hashes.is_empty()) + && let Ok(now_unix) = lqos_utils::unix_time::unix_now() + { + state::refresh_dynamic_circuits_last_seen_for_hashes( + &seen_device_hashes, + &seen_circuit_hashes, + now_unix, + ); + } + + if !unknown_candidates.is_empty() { + // TODO(dynamic-circuits): This is where unknown observations will be evaluated + // and potentially turned into dynamic circuit overlay entries. + // + // Unknown candidates are kernel observations whose hashes do not match static shaped + // devices and also do not match any existing dynamic circuits. + debug!( + "Received {} unknown circuit observations (dynamic circuit auto-create not implemented yet)", + unknown_candidates.len() + ); + } +} + fn reload_shaped_devices_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> { for attempt in 1..=RELOAD_ATTEMPTS { match load_shaped_devices() { diff --git a/src/rust/lqos_network_devices/src/dynamic.rs b/src/rust/lqos_network_devices/src/dynamic.rs new file mode 100644 index 00000000..5904a35f --- /dev/null +++ b/src/rust/lqos_network_devices/src/dynamic.rs @@ -0,0 +1,25 @@ +use lqos_config::ShapedDevice; +use lqos_utils::XdpIpAddress; + +/// Runtime-only dynamic circuit overlay entry. +/// +/// This is an overlay on top of `ShapedDevices.csv` and never mutates the static file. +/// It is intended for circuits/devices that are created and managed dynamically at runtime. +#[derive(Clone, Debug, PartialEq)] +pub struct DynamicCircuit { + /// Shaped-device-like fields describing the dynamic circuit/device. + pub shaped: ShapedDevice, + /// Unix timestamp (seconds) when this dynamic circuit was last observed active. + pub last_seen_unix: u64, +} + +/// Minimal kernel observation used to drive dynamic-circuit activity updates. +/// +/// Hash fields come from the kernel IP map; when present they should be preferred over +/// any trie-derived lookups. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CircuitObservation { + pub ip: XdpIpAddress, + pub device_hash: Option, + pub circuit_hash: Option, +} diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs index e8c41767..95e11ea2 100644 --- a/src/rust/lqos_network_devices/src/lib.rs +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -8,6 +8,7 @@ mod actor; mod catalog; mod directory_watcher; +mod dynamic; mod hash_cache; mod state; mod topology; @@ -21,6 +22,7 @@ use std::sync::Arc; use tracing::debug; pub use catalog::{CircuitRateCaps, ShapedDevicesCatalog}; +pub use dynamic::{CircuitObservation, DynamicCircuit}; pub use hash_cache::ShapedDeviceHashCache; pub use topology::{ResolvedParentNode, resolve_parent_node, resolve_parent_node_reference}; @@ -70,6 +72,13 @@ pub fn shaped_device_hash_cache_snapshot() -> Arc { state::shaped_device_hash_cache_snapshot() } +/// Returns the current in-memory dynamic circuit overlay snapshot. +/// +/// Note: This is a runtime-only overlay and does not mutate `ShapedDevices.csv`. +pub fn dynamic_circuits_snapshot() -> Arc> { + state::dynamic_circuits_snapshot() +} + /// Run a closure with a read-only view of the current in-memory `NetworkJson`. pub fn with_network_json_read(f: impl FnOnce(&NetworkJson) -> R) -> R { state::with_network_json_read(f) @@ -97,6 +106,13 @@ pub fn apply_shaped_devices_snapshot(reason: &str, shaped: ConfigShapedDevices) actor::apply_shaped_devices_snapshot(reason, shaped) } +/// Reports kernel observations that may indicate dynamic circuit activity. +/// +/// This function is not pure: it sends a message to the runtime actor when running. +pub fn report_observations(observations: &[CircuitObservation]) { + actor::report_observations(observations) +} + /// Replaces the in-memory `ShapedDevices.csv` snapshot without involving the runtime actor. /// /// Side effects: diff --git a/src/rust/lqos_network_devices/src/state.rs b/src/rust/lqos_network_devices/src/state.rs index 9d449304..769cbf52 100644 --- a/src/rust/lqos_network_devices/src/state.rs +++ b/src/rust/lqos_network_devices/src/state.rs @@ -1,9 +1,11 @@ use crate::catalog::ShapedDevicesCatalog; +use crate::dynamic::DynamicCircuit; use crate::hash_cache::ShapedDeviceHashCache; use arc_swap::ArcSwap; use lqos_config::{ConfigShapedDevices, NetworkJson}; use once_cell::sync::Lazy; use parking_lot::RwLock; +use std::collections::HashSet; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -23,6 +25,8 @@ static SHAPED_STATE: Lazy> = Lazy::new(|| { generation: 0, })) }); +static DYNAMIC_CIRCUITS: Lazy>> = + Lazy::new(|| ArcSwap::new(Arc::new(Vec::new()))); static NETWORK_JSON: Lazy> = Lazy::new(|| RwLock::new(NetworkJson::default())); pub(crate) fn shaped_devices_snapshot() -> Arc { @@ -38,6 +42,10 @@ pub(crate) fn shaped_devices_catalog() -> ShapedDevicesCatalog { ShapedDevicesCatalog::new(state.shaped.clone(), state.cache.clone(), state.generation) } +pub(crate) fn dynamic_circuits_snapshot() -> Arc> { + DYNAMIC_CIRCUITS.load_full() +} + pub(crate) fn with_network_json_read(f: impl FnOnce(&NetworkJson) -> R) -> R { let reader = NETWORK_JSON.read(); f(&reader) @@ -77,3 +85,35 @@ pub(crate) fn publish_network_json(new_file: NetworkJson) { let mut writer = NETWORK_JSON.write(); *writer = new_file; } + +pub(crate) fn publish_dynamic_circuits_snapshot(new_snapshot: Vec) { + DYNAMIC_CIRCUITS.store(Arc::new(new_snapshot)); +} + +pub(crate) fn refresh_dynamic_circuits_last_seen_for_hashes( + seen_device_hashes: &HashSet, + seen_circuit_hashes: &HashSet, + now_unix: u64, +) -> bool { + let snapshot = dynamic_circuits_snapshot(); + if snapshot.is_empty() { + return false; + } + + let mut updated = snapshot.as_ref().clone(); + let mut changed = false; + for circuit in updated.iter_mut() { + let is_seen = seen_device_hashes.contains(&circuit.shaped.device_hash) + || seen_circuit_hashes.contains(&circuit.shaped.circuit_hash); + if is_seen && circuit.last_seen_unix != now_unix { + circuit.last_seen_unix = now_unix; + changed = true; + } + } + + if changed { + publish_dynamic_circuits_snapshot(updated); + } + + changed +} diff --git a/src/rust/lqos_network_devices/src/tests.rs b/src/rust/lqos_network_devices/src/tests.rs index f54baa08..a48bff82 100644 --- a/src/rust/lqos_network_devices/src/tests.rs +++ b/src/rust/lqos_network_devices/src/tests.rs @@ -1,9 +1,12 @@ -use crate::{ShapedDevicesCatalog, resolve_parent_node_reference, with_network_json_write}; +use crate::{ + DynamicCircuit, ShapedDevicesCatalog, resolve_parent_node_reference, with_network_json_write, +}; use lqos_config::{ConfigShapedDevices, NetworkJsonNode, ShapedDevice}; use lqos_utils::rtt::RttBuffer; use lqos_utils::units::DownUpOrder; use once_cell::sync::Lazy; use parking_lot::Mutex; +use std::collections::HashSet; use std::sync::Arc; static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); @@ -95,3 +98,39 @@ fn resolve_parent_node_reference_prefers_id_then_name_then_alias() { .expect("Expected active attachment alias to resolve"); assert_eq!(by_alias.name, "Site B"); } + +#[test] +fn dynamic_circuit_last_seen_updates_for_seen_hashes() { + let _guard = TEST_LOCK.lock(); + + let original = crate::state::dynamic_circuits_snapshot(); + + let mut shaped = ShapedDevice::default(); + shaped.circuit_id = "dynamic-circuit".into(); + shaped.device_id = "dynamic-device".into(); + shaped.circuit_hash = 10; + shaped.device_hash = 100; + + crate::state::publish_dynamic_circuits_snapshot(vec![DynamicCircuit { + shaped: shaped.clone(), + last_seen_unix: 0, + }]); + + let mut seen_device_hashes: HashSet = HashSet::new(); + seen_device_hashes.insert(shaped.device_hash); + let seen_circuit_hashes: HashSet = HashSet::new(); + let now_unix = 1234; + + let changed = crate::state::refresh_dynamic_circuits_last_seen_for_hashes( + &seen_device_hashes, + &seen_circuit_hashes, + now_unix, + ); + assert!(changed); + + let updated = crate::state::dynamic_circuits_snapshot(); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].last_seen_unix, now_unix); + + crate::state::publish_dynamic_circuits_snapshot(original.as_ref().clone()); +} diff --git a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs index 35ac738b..a4d9c5f6 100644 --- a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs +++ b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs @@ -11,7 +11,7 @@ use crate::{ stats::HIGH_WATERMARK, throughput_tracker::flow_data::{FlowbeeEffectiveDirection, expire_rtt_flows, flowbee_rtt_map}, }; -use fxhash::FxHashMap; +use fxhash::{FxHashMap, FxHashSet}; use lqos_bakery::BakeryCommands; use lqos_bus::TcHandle; use lqos_config::NetworkJson; @@ -442,11 +442,67 @@ impl ThroughputTracker { net_json_calc: &mut NetworkJson, bakery_sender: crossbeam_channel::Sender, ) { + const MAX_UNKNOWN_OBSERVATIONS_PER_TICK: usize = 256; + + let dynamic_snapshot = lqos_network_devices::dynamic_circuits_snapshot(); + let mut dynamic_device_hashes: FxHashSet = FxHashSet::default(); + let mut dynamic_circuit_hashes: FxHashSet = FxHashSet::default(); + dynamic_device_hashes.reserve(dynamic_snapshot.len()); + dynamic_circuit_hashes.reserve(dynamic_snapshot.len()); + for circuit in dynamic_snapshot.iter() { + dynamic_device_hashes.insert(circuit.shaped.device_hash); + dynamic_circuit_hashes.insert(circuit.shaped.circuit_hash); + } + + let mut reported_dynamic_device_hashes: FxHashSet = FxHashSet::default(); + let mut reported_dynamic_circuit_hashes: FxHashSet = FxHashSet::default(); + let mut observations: Vec = Vec::new(); + let mut changed_circuits = HashSet::new(); let self_cycle = self.cycle.load(std::sync::atomic::Ordering::Relaxed); let catalog = lqos_network_devices::shaped_devices_catalog(); let mut raw_data = self.raw_data.lock(); + + #[allow(clippy::too_many_arguments)] + fn record_dynamic_observation( + observations: &mut Vec, + reported_dynamic_device_hashes: &mut FxHashSet, + reported_dynamic_circuit_hashes: &mut FxHashSet, + dynamic_device_hashes: &FxHashSet, + dynamic_circuit_hashes: &FxHashSet, + ip: XdpIpAddress, + device_hash: Option, + circuit_hash: Option, + ) { + if dynamic_device_hashes.is_empty() && dynamic_circuit_hashes.is_empty() { + return; + } + + if let Some(device_hash) = device_hash + && dynamic_device_hashes.contains(&device_hash) + && reported_dynamic_device_hashes.insert(device_hash) + { + observations.push(lqos_network_devices::CircuitObservation { + ip, + device_hash: Some(device_hash), + circuit_hash, + }); + return; + } + + if let Some(circuit_hash) = circuit_hash + && dynamic_circuit_hashes.contains(&circuit_hash) + && reported_dynamic_circuit_hashes.insert(circuit_hash) + { + observations.push(lqos_network_devices::CircuitObservation { + ip, + device_hash, + circuit_hash: Some(circuit_hash), + }); + } + } + throughput_for_each(&mut |xdp_ip, counts| { let reduced = ReducedHostCounters::from_counters(counts); if let Some(entry) = raw_data.get_mut(xdp_ip) { @@ -474,6 +530,26 @@ impl ThroughputTracker { entry.network_json_parents = shaped_device.and_then(|device| { net_json_calc.get_parents_for_circuit_id(&device.parent_node) }); + + if shaped_device.is_none() { + record_dynamic_observation( + &mut observations, + &mut reported_dynamic_device_hashes, + &mut reported_dynamic_circuit_hashes, + &dynamic_device_hashes, + &dynamic_circuit_hashes, + *xdp_ip, + entry.device_hash, + entry.circuit_hash, + ); + if observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK { + observations.push(lqos_network_devices::CircuitObservation { + ip: *xdp_ip, + device_hash: entry.device_hash, + circuit_hash: entry.circuit_hash, + }); + } + } } if entry.packets != entry.prev_packets { entry.most_recent_cycle = self_cycle; @@ -482,6 +558,17 @@ impl ThroughputTracker { changed_circuits.insert(circuit_hash); } + record_dynamic_observation( + &mut observations, + &mut reported_dynamic_device_hashes, + &mut reported_dynamic_circuit_hashes, + &dynamic_device_hashes, + &dynamic_circuit_hashes, + *xdp_ip, + entry.device_hash, + entry.circuit_hash, + ); + if let Some(parents) = &entry.network_json_parents { net_json_calc.add_throughput_cycle( parents, @@ -538,6 +625,26 @@ impl ThroughputTracker { let shaped_device = Self::shaped_device_for_hashes(&catalog, device_hash, circuit_hash); let circuit_id = shaped_device.map(|d| d.circuit_id.clone()); + + record_dynamic_observation( + &mut observations, + &mut reported_dynamic_device_hashes, + &mut reported_dynamic_circuit_hashes, + &dynamic_device_hashes, + &dynamic_circuit_hashes, + *xdp_ip, + device_hash, + circuit_hash, + ); + if shaped_device.is_none() && observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK + { + observations.push(lqos_network_devices::CircuitObservation { + ip: *xdp_ip, + device_hash, + circuit_hash, + }); + } + // Call the Bakery Queue Creation for new circuits if let Some(circuit_hash) = circuit_hash && let Ok(config) = lqos_config::load_config() @@ -596,6 +703,10 @@ impl ThroughputTracker { } }); + if !observations.is_empty() { + lqos_network_devices::report_observations(&observations); + } + if !changed_circuits.is_empty() && let Err(e) = bakery_sender.send(BakeryCommands::OnCircuitActivity { circuit_ids: changed_circuits, From b13cf09741ec079ba27505126e03642bbf26e50b Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 11 Apr 2026 09:48:54 -0500 Subject: [PATCH 04/20] feat: add dynamic circuits config + web editor --- docs/v2.0/configuration-advanced.md | 26 ++ src/rust/lqos_config/src/etc/mod.rs | 8 +- .../src/etc/v15/dynamic_circuits.rs | 168 +++++++++ src/rust/lqos_config/src/etc/v15/mod.rs | 2 + .../lqos_config/src/etc/v15/top_config.rs | 10 + src/rust/lqos_config/src/lib.rs | 10 +- .../src/node_manager/js_build/entrypoints.txt | 1 + .../js_build/src/config/config_helper.js | 1 + .../js_build/src/config_dynamic_circuits.js | 336 ++++++++++++++++++ .../static2/config_dynamic_circuits.html | 83 +++++ .../lqosd/src/node_manager/static_pages.rs | 1 + 11 files changed, 637 insertions(+), 9 deletions(-) create mode 100644 src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js create mode 100644 src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index a45c8de9..88d17bdc 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -95,6 +95,32 @@ This can be used to exclude entire subscriber populations from LibreQoS shaping Use this carefully: `ignore_subnets` is broader than a billing-only toggle. The same setting also affects other LibreQoS/Insight IP-policy handling. +#### Dynamic circuits (optional) + +LibreQoS can be configured with an optional `[dynamic_circuits]` section. This is intended for the dynamic-circuit overlay layer (including unknown-IP promotion workflows). + +Example: + +```toml +[dynamic_circuits] +enabled = false +ttl_seconds = 300 +enable_unknown_ip_promotion = false + +[[dynamic_circuits.ranges]] +name = "Default" +ip_range = "0.0.0.0" # shorthand for 0.0.0.0/0 +download_min_mbps = 10.0 +upload_min_mbps = 10.0 +download_max_mbps = 100.0 +upload_max_mbps = 100.0 +attach_to = "" # optional network.json node name +``` + +Notes: +- `ip_range` must be a CIDR. `0.0.0.0` (and `::`) are allowed shorthands for the match-all `/0` networks. +- `attach_to` is a `network.json` node name (optional; empty is allowed). + #### CRM/NMS Integrations Learn more about [configuring integrations here](integrations.md). diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs index 77eeb215..590dd3e8 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -19,10 +19,10 @@ mod python_migration; pub mod test_data; mod v15; pub use v15::{ - BridgeConfig, IntegrationConfig, LazyQueueMode, QueueMode, RttThresholds, - SingleInterfaceConfig, StormguardConfig, StormguardStrategy, TreeguardCircuitsConfig, - TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, - TreeguardQooConfig, Tunables, + BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode, + QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig, StormguardStrategy, + TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, + TreeguardLinksConfig, TreeguardQooConfig, Tunables, }; static CONFIG: Lazy>>> = Lazy::new(|| ArcSwap::from_pointee(None)); diff --git a/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs new file mode 100644 index 00000000..d766f336 --- /dev/null +++ b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs @@ -0,0 +1,168 @@ +//! Dynamic circuit configuration. +//! +//! This module defines the configuration schema for the "dynamic circuits" +//! subsystem. Runtime behavior is implemented elsewhere; for now this is +//! configuration + validation only. + +use allocative::Allocative; +use ip_network::IpNetwork; +use serde::{Deserialize, Serialize}; + +fn default_ttl_seconds() -> u64 { + 300 +} + +fn deserialize_ip_network_allow_default<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + let trimmed = raw.trim(); + let normalized = match trimmed { + "0.0.0.0" => "0.0.0.0/0", + "::" => "::/0", + other => other, + }; + normalized + .parse::() + .map_err(serde::de::Error::custom) +} + +fn serialize_ip_network(value: &IpNetwork, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +/// One rule that applies a default set of rates + optional attachment to an IP range. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)] +pub struct DynamicCircuitRangeRule { + /// Human-readable rule name used in logs and diagnostics. + pub name: String, + /// IP network matched by this rule (CIDR notation). + /// + /// For convenience, `0.0.0.0` and `::` are accepted shorthands for the default + /// routes (`0.0.0.0/0` and `::/0`). + #[allocative(skip)] + #[serde( + serialize_with = "serialize_ip_network", + deserialize_with = "deserialize_ip_network_allow_default" + )] + pub ip_range: IpNetwork, + /// Minimum guaranteed downstream rate in Mbps. + pub download_min_mbps: f32, + /// Minimum guaranteed upstream rate in Mbps. + pub upload_min_mbps: f32, + /// Maximum downstream rate in Mbps. + pub download_max_mbps: f32, + /// Maximum upstream rate in Mbps. + pub upload_max_mbps: f32, + /// Optional attachment target in `network.json` (node name). Empty means no attachment. + #[serde(default)] + pub attach_to: String, +} + +/// Dynamic circuits configuration. +/// +/// This section is optional in the top-level config so older installations can +/// upgrade without needing to add a new config section. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)] +pub struct DynamicCircuitsConfig { + /// Master switch for the dynamic circuits feature set. + #[serde(default)] + pub enabled: bool, + /// Time-to-live in seconds for runtime dynamic circuit state. + #[serde(default = "default_ttl_seconds")] + pub ttl_seconds: u64, + /// Enable promotion of unknown IPs into dynamic circuits (when a rule matches). + #[serde(default)] + pub enable_unknown_ip_promotion: bool, + /// Rules evaluated against unknown IPs (and future dynamic overlay logic). + #[serde(default)] + pub ranges: Vec, +} + +impl Default for DynamicCircuitsConfig { + fn default() -> Self { + Self { + enabled: false, + ttl_seconds: default_ttl_seconds(), + enable_unknown_ip_promotion: false, + ranges: Vec::new(), + } + } +} + +impl DynamicCircuitsConfig { + /// Validates configured dynamic circuit settings. + pub fn validate(&self) -> Result<(), String> { + if self.ttl_seconds == 0 { + return Err("dynamic_circuits.ttl_seconds must be > 0".to_string()); + } + + for (index, rule) in self.ranges.iter().enumerate() { + let label = if rule.name.trim().is_empty() { + format!("dynamic_circuits.ranges[{index}]") + } else { + format!("dynamic_circuits.ranges[{index}] ('{}')", rule.name.trim()) + }; + + if rule.name.trim().is_empty() { + return Err(format!("{label}: name must not be empty")); + } + + if !rule.download_min_mbps.is_finite() || rule.download_min_mbps < 0.1 { + return Err(format!("{label}: download_min_mbps must be >= 0.1")); + } + if !rule.upload_min_mbps.is_finite() || rule.upload_min_mbps < 0.1 { + return Err(format!("{label}: upload_min_mbps must be >= 0.1")); + } + if !rule.download_max_mbps.is_finite() || rule.download_max_mbps < 0.2 { + return Err(format!("{label}: download_max_mbps must be >= 0.2")); + } + if !rule.upload_max_mbps.is_finite() || rule.upload_max_mbps < 0.2 { + return Err(format!("{label}: upload_max_mbps must be >= 0.2")); + } + if rule.download_min_mbps > rule.download_max_mbps { + return Err(format!("{label}: download_min_mbps must be <= download_max_mbps")); + } + if rule.upload_min_mbps > rule.upload_max_mbps { + return Err(format!("{label}: upload_min_mbps must be <= upload_max_mbps")); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_default_ipv4_shorthand() { + #[derive(Deserialize)] + struct Wrapper { + #[serde(deserialize_with = "deserialize_ip_network_allow_default")] + ip_range: IpNetwork, + } + + let parsed: Wrapper = + toml::from_str(r#"ip_range = "0.0.0.0""#).expect("parse ip_range shorthand"); + assert_eq!(parsed.ip_range.to_string(), "0.0.0.0/0"); + } + + #[test] + fn parses_default_ipv6_shorthand() { + #[derive(Deserialize)] + struct Wrapper { + #[serde(deserialize_with = "deserialize_ip_network_allow_default")] + ip_range: IpNetwork, + } + + let parsed: Wrapper = + toml::from_str(r#"ip_range = "::""#).expect("parse ip_range shorthand"); + assert_eq!(parsed.ip_range.to_string(), "::/0"); + } +} diff --git a/src/rust/lqos_config/src/etc/v15/mod.rs b/src/rust/lqos_config/src/etc/v15/mod.rs index 10232418..0981c9c5 100644 --- a/src/rust/lqos_config/src/etc/v15/mod.rs +++ b/src/rust/lqos_config/src/etc/v15/mod.rs @@ -4,6 +4,7 @@ mod top_config; pub use top_config::Config; pub use top_config::RttThresholds; mod bridge; +mod dynamic_circuits; mod flows; pub mod influxdb; mod integration_common; @@ -22,6 +23,7 @@ mod visp_integration; mod wispgate; pub use bridge::*; +pub use dynamic_circuits::*; pub use integration_common::IntegrationConfig; pub use long_term_stats::LongTermStats; pub use queues::{LazyQueueMode, QueueMode}; diff --git a/src/rust/lqos_config/src/etc/v15/top_config.rs b/src/rust/lqos_config/src/etc/v15/top_config.rs index 5713076c..ac8e48d0 100644 --- a/src/rust/lqos_config/src/etc/v15/top_config.rs +++ b/src/rust/lqos_config/src/etc/v15/top_config.rs @@ -106,6 +106,12 @@ pub struct Config { /// IP Range definitions pub ip_ranges: super::ip_ranges::IpRanges, + /// Dynamic circuits configuration. + /// + /// Optional so older configs without this section still deserialize cleanly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic_circuits: Option, + /// Network flows configuration pub flows: Option, @@ -248,6 +254,9 @@ impl Config { stormguard.validate()?; } self.treeguard.validate()?; + if let Some(dynamic_circuits) = &self.dynamic_circuits { + dynamic_circuits.validate()?; + } Ok(()) } @@ -320,6 +329,7 @@ impl Default for Config { queues: super::queues::QueueConfig::default(), long_term_stats: super::long_term_stats::LongTermStats::default(), ip_ranges: super::ip_ranges::IpRanges::default(), + dynamic_circuits: None, integration_common: super::integration_common::IntegrationConfig::default(), splynx_integration: super::splynx_integration::SplynxIntegration::default(), netzur_integration: Some(super::netzur_integration::NetzurIntegration::default()), diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index b206a782..934abdf4 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -35,11 +35,11 @@ pub use cpu_topology::{ CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus, }; pub use etc::{ - BridgeConfig, Config, LazyQueueMode, QueueMode, RttThresholds, SingleInterfaceConfig, - StormguardConfig, StormguardStrategy, TreeguardCircuitsConfig, TreeguardConfig, - TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, - clear_cached_config, disable_xdp_bridge, enable_long_term_stats, load_config, - treeguard_cpu_mode_migration_notice, update_config, + BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode, QueueMode, + RttThresholds, SingleInterfaceConfig, StormguardConfig, StormguardStrategy, + TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, + TreeguardLinksConfig, TreeguardQooConfig, Tunables, clear_cached_config, disable_xdp_bridge, + enable_long_term_stats, load_config, treeguard_cpu_mode_migration_notice, update_config, }; pub use ethernet_port_limits::{ DEFAULT_ETHERNET_PORT_LIMIT_MULTIPLIER, EthernetPortLimitPolicy, EthernetPortObservation, diff --git a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt index 350ada34..3854a804 100644 --- a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt +++ b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt @@ -25,6 +25,7 @@ config_treeguard.js config_lts.js config_iprange.js config_flows.js +config_dynamic_circuits.js config_integration.js config_splynx.js config_netzur.js diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index 7a149f99..7569b6ca 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -479,6 +479,7 @@ export function renderConfigMenu(currentPage) { { href: "config_lts.html", icon: "fa-line-chart", text: "LibreQoS Insight", id: "lts" }, { href: "config_iprange.html", icon: "fa-address-card", text: "IP Ranges", id: "iprange" }, { href: "config_flows.html", icon: "fa-arrow-circle-down", text: "Flow Tracking", id: "flows" }, + { href: "config_dynamic_circuits.html", icon: "fa-layer-group", text: "Dynamic Circuits", id: "dynamic_circuits" }, { href: "config_integration.html", icon: "fa-link", text: "Integration - Common", id: "integration" }, { href: "config_splynx.html", icon: "fa-link", text: "Splynx", id: "splynx" }, { href: "config_netzur.html", icon: "fa-link", text: "Netzur", id: "netzur" }, diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js b/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js new file mode 100644 index 00000000..e941a230 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js @@ -0,0 +1,336 @@ +import { + loadConfig, + loadNetworkJson, + renderConfigMenu, + saveConfig, + validNodeList, +} from "./config/config_helper"; + +const DEFAULT_TTL_SECONDS = 300; + +function normalizeIpRangeInput(value) { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + if (raw === "0.0.0.0") return "0.0.0.0/0"; + if (raw === "::") return "::/0"; + return raw; +} + +function isValidIPv4(ip) { + if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false; + const parts = ip.split(".").map((p) => parseInt(p, 10)); + return parts.length === 4 && !parts.some((p) => Number.isNaN(p) || p < 0 || p > 255); +} + +function isValidIPv6(ip) { + return ip.includes(":") && /^[0-9a-fA-F:]+$/.test(ip); +} + +function isValidCIDR(cidr) { + try { + const [ip, mask, extra] = String(cidr).trim().split("/"); + if (!ip || !mask || extra !== undefined) return false; + + if (ip.includes(":")) { + if (!isValidIPv6(ip)) return false; + } else if (!isValidIPv4(ip)) { + return false; + } + + const maskNum = parseInt(mask, 10); + if (Number.isNaN(maskNum)) return false; + if (ip.includes(":")) { + if (maskNum < 0 || maskNum > 128) return false; + } else { + if (maskNum < 0 || maskNum > 32) return false; + } + + return true; + } catch { + return false; + } +} + +function parseFiniteFloat(value) { + const num = parseFloat(String(value ?? "").trim()); + return Number.isFinite(num) ? num : null; +} + +function parsePositiveInt(value) { + const num = parseInt(String(value ?? "").trim(), 10); + if (!Number.isFinite(num)) return null; + if (num <= 0) return null; + return num; +} + +function getDefaultRule() { + return { + name: "", + ip_range: "0.0.0.0/0", + download_min_mbps: "10.0", + upload_min_mbps: "10.0", + download_max_mbps: "100.0", + upload_max_mbps: "100.0", + attach_to: "", + }; +} + +function ensureDynamicCircuitsConfig(config) { + if (!config || typeof config !== "object") return; + if (!config.dynamic_circuits || typeof config.dynamic_circuits !== "object") { + config.dynamic_circuits = { + enabled: false, + ttl_seconds: DEFAULT_TTL_SECONDS, + enable_unknown_ip_promotion: false, + ranges: [], + }; + return; + } + + const dyn = config.dynamic_circuits; + if (typeof dyn.enabled !== "boolean") dyn.enabled = false; + if (!Number.isFinite(Number(dyn.ttl_seconds))) dyn.ttl_seconds = DEFAULT_TTL_SECONDS; + if (typeof dyn.enable_unknown_ip_promotion !== "boolean") dyn.enable_unknown_ip_promotion = false; + if (!Array.isArray(dyn.ranges)) dyn.ranges = []; +} + +function setNodeDatalist(networkJson) { + const datalist = document.getElementById("networkNodesDatalist"); + if (!datalist) return; + datalist.innerHTML = ""; + + if (!networkJson || typeof networkJson !== "object") { + return; + } + + for (const name of validNodeList(networkJson)) { + const option = document.createElement("option"); + option.value = name; + datalist.appendChild(option); + } +} + +function renderRangesTable() { + const tbody = document.getElementById("dynamicRangesBody"); + if (!tbody) return; + tbody.innerHTML = ""; + + const ranges = window.config?.dynamic_circuits?.ranges; + if (!Array.isArray(ranges) || ranges.length === 0) { + const empty = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = 8; + cell.className = "text-muted"; + cell.textContent = "No rules configured."; + empty.appendChild(cell); + tbody.appendChild(empty); + return; + } + + ranges.forEach((rule, index) => { + const tr = document.createElement("tr"); + + const mkText = (value, onChange, placeholder = "") => { + const input = document.createElement("input"); + input.type = "text"; + input.className = "form-control form-control-sm"; + input.placeholder = placeholder; + input.value = String(value ?? ""); + input.addEventListener("input", (ev) => onChange(ev.target.value)); + return input; + }; + + const mkNumber = (value, onChange, step = "0.1") => { + const input = document.createElement("input"); + input.type = "number"; + input.className = "form-control form-control-sm"; + input.step = step; + input.value = String(value ?? ""); + input.addEventListener("input", (ev) => onChange(ev.target.value)); + return input; + }; + + const addCell = (child) => { + const td = document.createElement("td"); + td.appendChild(child); + tr.appendChild(td); + }; + + addCell( + mkText(rule.name, (v) => { + window.config.dynamic_circuits.ranges[index].name = v; + }, "Rule name"), + ); + addCell( + mkText(rule.ip_range, (v) => { + window.config.dynamic_circuits.ranges[index].ip_range = v; + }, "192.168.0.0/24"), + ); + addCell( + mkNumber(rule.download_min_mbps, (v) => { + window.config.dynamic_circuits.ranges[index].download_min_mbps = v; + }), + ); + addCell( + mkNumber(rule.upload_min_mbps, (v) => { + window.config.dynamic_circuits.ranges[index].upload_min_mbps = v; + }), + ); + addCell( + mkNumber(rule.download_max_mbps, (v) => { + window.config.dynamic_circuits.ranges[index].download_max_mbps = v; + }), + ); + addCell( + mkNumber(rule.upload_max_mbps, (v) => { + window.config.dynamic_circuits.ranges[index].upload_max_mbps = v; + }), + ); + const attach = mkText(rule.attach_to, (v) => { + window.config.dynamic_circuits.ranges[index].attach_to = v; + }, "network.json node (optional)"); + attach.setAttribute("list", "networkNodesDatalist"); + addCell(attach); + + const removeTd = document.createElement("td"); + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "btn btn-sm btn-outline-danger"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", () => { + window.config.dynamic_circuits.ranges.splice(index, 1); + renderRangesTable(); + }); + removeTd.appendChild(removeBtn); + tr.appendChild(removeTd); + + tbody.appendChild(tr); + }); +} + +function validateConfig() { + const errors = []; + + const dyn = window.config?.dynamic_circuits; + if (!dyn || typeof dyn !== "object") { + errors.push("dynamic_circuits section is missing"); + } else { + const ttl = parsePositiveInt(dyn.ttl_seconds); + if (ttl === null) { + errors.push("Time to live must be a positive integer (seconds)"); + } + + const ranges = Array.isArray(dyn.ranges) ? dyn.ranges : []; + ranges.forEach((rule, index) => { + const label = (rule?.name ?? "").trim() + ? `Rule '${String(rule.name).trim()}'` + : `Rule #${index + 1}`; + + if (!String(rule?.name ?? "").trim()) { + errors.push(`${label}: Name is required`); + } + + const ipRange = normalizeIpRangeInput(rule?.ip_range); + if (!ipRange) { + errors.push(`${label}: IP Range is required`); + } else if (!isValidCIDR(ipRange)) { + errors.push(`${label}: IP Range must be a valid CIDR (e.g. 192.168.1.0/24). 0.0.0.0 and :: are allowed.`); + } + + const dmin = parseFiniteFloat(rule?.download_min_mbps); + const umin = parseFiniteFloat(rule?.upload_min_mbps); + const dmax = parseFiniteFloat(rule?.download_max_mbps); + const umax = parseFiniteFloat(rule?.upload_max_mbps); + + if (dmin === null || umin === null || dmax === null || umax === null) { + errors.push(`${label}: All rate fields must be valid numbers`); + return; + } + + if (dmin < 0.1) errors.push(`${label}: Min Download must be >= 0.1 Mbps`); + if (umin < 0.1) errors.push(`${label}: Min Upload must be >= 0.1 Mbps`); + if (dmax < 0.2) errors.push(`${label}: Max Download must be >= 0.2 Mbps`); + if (umax < 0.2) errors.push(`${label}: Max Upload must be >= 0.2 Mbps`); + if (dmin > dmax) errors.push(`${label}: Min Download must be <= Max Download`); + if (umin > umax) errors.push(`${label}: Min Upload must be <= Max Upload`); + }); + } + + if (errors.length === 0) { + return true; + } + + alert("Validation errors:\n" + errors.join("\n")); + return false; +} + +function updateConfigFromUi() { + const dyn = window.config.dynamic_circuits; + dyn.enabled = !!document.getElementById("dynamicCircuitsEnabled")?.checked; + dyn.enable_unknown_ip_promotion = !!document.getElementById("unknownIpPromotionEnabled")?.checked; + + const ttlValue = document.getElementById("dynamicCircuitsTtlSeconds")?.value; + dyn.ttl_seconds = parsePositiveInt(ttlValue) ?? DEFAULT_TTL_SECONDS; + + // Normalize IP ranges in-place so saves canonicalize shorthand. + if (Array.isArray(dyn.ranges)) { + dyn.ranges = dyn.ranges.map((rule) => ({ + ...rule, + name: String(rule?.name ?? "").trim(), + ip_range: normalizeIpRangeInput(rule?.ip_range), + attach_to: String(rule?.attach_to ?? "").trim(), + })); + } +} + +function finalizeConfigForSave() { + const dyn = window.config.dynamic_circuits; + if (!dyn || typeof dyn !== "object") return; + if (!Array.isArray(dyn.ranges)) return; + + dyn.ranges = dyn.ranges.map((rule) => ({ + ...rule, + download_min_mbps: parseFloat(String(rule?.download_min_mbps ?? "").trim()), + upload_min_mbps: parseFloat(String(rule?.upload_min_mbps ?? "").trim()), + download_max_mbps: parseFloat(String(rule?.download_max_mbps ?? "").trim()), + upload_max_mbps: parseFloat(String(rule?.upload_max_mbps ?? "").trim()), + })); +} + +// Render the configuration menu. +renderConfigMenu("dynamic_circuits"); + +loadConfig(() => { + ensureDynamicCircuitsConfig(window.config); + + const dyn = window.config.dynamic_circuits; + document.getElementById("dynamicCircuitsEnabled").checked = dyn.enabled ?? false; + document.getElementById("dynamicCircuitsTtlSeconds").value = dyn.ttl_seconds ?? DEFAULT_TTL_SECONDS; + document.getElementById("unknownIpPromotionEnabled").checked = dyn.enable_unknown_ip_promotion ?? false; + + // Load network nodes for the attach_to datalist (UX-only). + loadNetworkJson( + (networkJson) => { + if (typeof networkJson === "object" && networkJson !== null) { + setNodeDatalist(networkJson); + } + }, + () => {}, + ); + + renderRangesTable(); + + document.getElementById("addDynamicRange").addEventListener("click", () => { + window.config.dynamic_circuits.ranges.push(getDefaultRule()); + renderRangesTable(); + }); + + document.getElementById("saveButton").addEventListener("click", () => { + updateConfigFromUi(); + if (!validateConfig()) return; + finalizeConfigForSave(); + saveConfig(() => { + alert("Configuration saved successfully!"); + }); + }); +}); diff --git a/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html b/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html new file mode 100644 index 00000000..56933632 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html @@ -0,0 +1,83 @@ +
+
+ +
+
+
+
+
Dynamic Circuits
+
+ Configure dynamic circuit promotion and default shaping rates for unknown IPs. This section is optional and safe to omit on systems that do not use dynamic circuits yet. +
+
+
+ +
+
+
+
Operation
+
Enable the subsystem and choose retention and promotion behavior.
+ +
+ + +
Master switch for dynamic circuit tracking and promotion.
+
+ +
+ + +
How long dynamic circuit state remains valid without being seen.
+
+ +
+ + +
Allow unknown IPs to be promoted into dynamic circuits when a range rule matches.
+
+
+
+ +
+
+
Range Rules
+
Define the IP networks that should receive default rates and an optional topology attachment.
+ + + +
+ + + + + + + + + + + + + + +
NameIP RangeMin DownMin UpMax DownMax UpAttach ToActions
+
+ + + +
+ IP Range accepts CIDR notation (for example 192.168.200.0/24). 0.0.0.0 and :: are allowed and mean match-all. +
+
+
+
+
+ +
+ +
+
+
+ + + diff --git a/src/rust/lqosd/src/node_manager/static_pages.rs b/src/rust/lqosd/src/node_manager/static_pages.rs index f59fd50c..ab8286a2 100644 --- a/src/rust/lqosd/src/node_manager/static_pages.rs +++ b/src/rust/lqosd/src/node_manager/static_pages.rs @@ -65,6 +65,7 @@ pub(super) fn static_routes() -> Result { "config_lts.html", "config_iprange.html", "config_flows.html", + "config_dynamic_circuits.html", "config_integration.html", "config_splynx.html", "config_netzur.html", From 37092b3f0a166d33e678a1bb01576218281e7cb1 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 11 Apr 2026 10:58:18 -0500 Subject: [PATCH 05/20] feat: dynamic circuits bus + bakery overlay --- src/rust/Cargo.lock | 4 + src/rust/lqos_bakery/src/commands.rs | 22 + src/rust/lqos_bakery/src/lib.rs | 548 +++++++++++++++++- src/rust/lqos_bus/src/bus/request.rs | 17 + .../src/etc/v15/dynamic_circuits.rs | 8 +- src/rust/lqos_network_devices/Cargo.toml | 2 + src/rust/lqos_network_devices/src/actor.rs | 109 +++- src/rust/lqos_network_devices/src/dynamic.rs | 3 +- .../lqos_network_devices/src/dynamic_store.rs | 123 ++++ src/rust/lqos_network_devices/src/lib.rs | 23 + src/rust/lqos_overrides/Cargo.toml | 2 + src/rust/lqos_overrides/src/main.rs | 478 ++++++++------- src/rust/lqosd/src/dynamic_circuits.rs | 101 ++++ src/rust/lqosd/src/main.rs | 7 + 14 files changed, 1233 insertions(+), 214 deletions(-) create mode 100644 src/rust/lqos_network_devices/src/dynamic_store.rs create mode 100644 src/rust/lqosd/src/dynamic_circuits.rs diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 700f8e58..c62f5fec 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2122,6 +2122,8 @@ dependencies = [ "lqos_utils", "once_cell", "parking_lot", + "serde", + "serde_json", "tokio", "tracing", ] @@ -2132,10 +2134,12 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "lqos_bus", "lqos_config", "nix", "serde", "serde_json", + "tokio", ] [[package]] diff --git a/src/rust/lqos_bakery/src/commands.rs b/src/rust/lqos_bakery/src/commands.rs index 66d6adc6..0d834ad2 100644 --- a/src/rust/lqos_bakery/src/commands.rs +++ b/src/rust/lqos_bakery/src/commands.rs @@ -231,6 +231,28 @@ pub enum BakeryCommands { /// Optional per-circuit SQM override: "cake" or "fq_codel" sqm_override: Option, }, + /// Create or update a runtime-only dynamic circuit overlay entry. + /// + /// This does not mutate `ShapedDevices.csv`. Overlay circuits are retained across full reloads + /// and are applied to the active shaping tree when possible. + UpsertDynamicCircuitOverlay { + /// Shaped-device-like definition for the dynamic circuit/device. + shaped_device: Box, + /// Optional synchronous reply channel for immediate acceptance reporting. + #[allocative(skip)] + reply: Option>>, + }, + /// Remove a runtime-only dynamic circuit overlay entry by circuit identifier. + /// + /// This does not mutate `ShapedDevices.csv`. When the circuit exists in the active shaping + /// tree, it is removed live when possible. + RemoveDynamicCircuitOverlay { + /// Stable circuit identifier to remove. + circuit_id: String, + /// Optional synchronous reply channel for immediate acceptance reporting. + #[allocative(skip)] + reply: Option>>, + }, /// Change a specific HTB class rate on-the-fly; optionally dry-run. StormGuardAdjustment { /// If true, log the tc command instead of executing it. diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs index 739ae48c..03c8a5a0 100644 --- a/src/rust/lqos_bakery/src/lib.rs +++ b/src/rust/lqos_bakery/src/lib.rs @@ -29,7 +29,7 @@ use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; use parking_lot::RwLock; use std::collections::VecDeque; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; @@ -70,6 +70,7 @@ use lqos_config::{ plan_top_level_assignments, }; use qdisc_handles::MqDeviceLayout; +use serde::Deserialize; use serde_json::{Map, Value}; const TEST_FAULT_ONCE_PATH: &str = "/tmp/lqos_bakery_fail_purpose_once.txt"; @@ -481,6 +482,508 @@ fn parse_ip_list(s: &str) -> Vec { .collect() } +const DYNAMIC_CIRCUITS_FILENAME: &str = "dynamic_circuits.json"; + +#[derive(Clone, Debug)] +struct DynamicCircuitOverlayEntry { + shaped_device: lqos_config::ShapedDevice, + class_minor: Option, +} + +#[derive(Debug, Deserialize)] +struct PersistedDynamicCircuit { + shaped: lqos_config::ShapedDevice, + #[allow(dead_code)] + #[serde(default)] + last_seen_unix: u64, +} + +#[derive(Debug, Default, Deserialize)] +struct PersistedDynamicCircuitsFile { + #[serde(default)] + #[allow(dead_code)] + schema_version: u32, + #[serde(default)] + circuits: Vec, +} + +fn dynamic_circuits_path(config: &Config) -> PathBuf { + Path::new(&config.lqos_directory).join(DYNAMIC_CIRCUITS_FILENAME) +} + +fn recompute_shaped_hashes(device: &mut lqos_config::ShapedDevice) { + device.circuit_hash = runtime_hash_to_i64(&device.circuit_id); + device.device_hash = runtime_hash_to_i64(&device.device_id); + device.parent_hash = runtime_hash_to_i64(&device.parent_node); +} + +fn load_dynamic_circuit_overlays_from_disk( + config: &Config, +) -> HashMap { + let path = dynamic_circuits_path(config); + if !path.exists() { + return HashMap::new(); + } + + let raw = match std::fs::read_to_string(&path) { + Ok(value) => value, + Err(err) => { + warn!( + "Bakery: unable to read dynamic circuits file {} ({err}); treating as empty", + path.display() + ); + return HashMap::new(); + } + }; + + if raw.trim().is_empty() { + return HashMap::new(); + } + + let parsed: PersistedDynamicCircuitsFile = match serde_json::from_str(&raw) { + Ok(value) => value, + Err(err) => { + warn!( + "Bakery: unable to parse dynamic circuits file {} ({err}); treating as empty", + path.display() + ); + return HashMap::new(); + } + }; + + let mut overlays = HashMap::new(); + for entry in parsed.circuits { + let mut shaped = entry.shaped; + if shaped.circuit_id.trim().is_empty() { + continue; + } + if shaped.device_id.trim().is_empty() { + continue; + } + recompute_shaped_hashes(&mut shaped); + let circuit_hash = shaped.circuit_hash; + overlays.insert( + circuit_hash, + DynamicCircuitOverlayEntry { + shaped_device: shaped, + class_minor: None, + }, + ); + } + + overlays +} + +fn ip_list_from_shaped_device(device: &lqos_config::ShapedDevice) -> String { + let mut ips = Vec::new(); + for (ip, prefix) in device.ipv4.iter() { + ips.push(format!("{ip}/{prefix}")); + } + for (ip, prefix) in device.ipv6.iter() { + ips.push(format!("{ip}/{prefix}")); + } + ips.sort(); + ips.dedup(); + ips.join(",") +} + +fn normalize_circuit_id_key(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn find_free_dynamic_circuit_minor( + used_down: &HashSet, + used_up: &HashSet, +) -> Option { + let start = ACTIVE_RUNTIME_MINOR_START.min(u16::MAX as u32) as u16; + (start..=0xFFFEu16).find(|m| !used_down.contains(m) && !used_up.contains(m)) +} + +fn append_dynamic_circuit_overlays_to_batch( + batch: &mut Vec>, + overlays: &mut HashMap, + migrations: &HashMap, +) { + if overlays.is_empty() { + return; + } + + let planned_sites: HashMap> = batch + .iter() + .filter_map(|cmd| match cmd.as_ref() { + BakeryCommands::AddSite { site_hash, .. } => Some((*site_hash, Arc::clone(cmd))), + _ => None, + }) + .collect(); + let planned_circuits: HashMap> = batch + .iter() + .filter_map(|cmd| match cmd.as_ref() { + BakeryCommands::AddCircuit { circuit_hash, .. } => { + Some((*circuit_hash, Arc::clone(cmd))) + } + _ => None, + }) + .collect(); + + let mut overlay_keys: Vec = overlays.keys().copied().collect(); + overlay_keys.sort_unstable(); + + let mut used_by_major: HashMap<(u16, u16), (HashSet, HashSet)> = HashMap::new(); + + for circuit_hash in overlay_keys { + let Some(entry) = overlays.get_mut(&circuit_hash) else { + continue; + }; + + if entry.shaped_device.circuit_id.trim().is_empty() + || entry.shaped_device.device_id.trim().is_empty() + { + continue; + } + + recompute_shaped_hashes(&mut entry.shaped_device); + let site_hash = runtime_hash_to_i64(&entry.shaped_device.parent_node); + let Some(site_cmd) = planned_sites.get(&site_hash) else { + warn!( + "Bakery: skipping dynamic circuit overlay {} because parent node '{}' is not present in the current batch sites", + entry.shaped_device.circuit_id, entry.shaped_device.parent_node + ); + continue; + }; + let Some((down_parent, up_parent)) = site_class_handles(site_cmd.as_ref()) else { + warn!( + "Bakery: skipping dynamic circuit overlay {} because parent node '{}' did not resolve to a valid site class", + entry.shaped_device.circuit_id, entry.shaped_device.parent_node + ); + continue; + }; + + let down_major = down_parent.get_major_minor().0; + let up_major = up_parent.get_major_minor().0; + + let (used_down, used_up) = + used_by_major + .entry((down_major, up_major)) + .or_insert_with(|| { + let (mut used_down, mut used_up) = + used_site_minors_for_majors(&planned_sites, down_major, up_major); + let (used_circuit_down, used_circuit_up) = + used_circuit_minors_for_majors(&planned_circuits, down_major, up_major); + used_down.extend(used_circuit_down); + used_up.extend(used_circuit_up); + let (used_shadow_down, used_shadow_up) = + used_pending_migration_shadow_minors_for_majors( + migrations, down_major, up_major, + ); + used_down.extend(used_shadow_down); + used_up.extend(used_shadow_up); + (used_down, used_up) + }); + + let mut desired_minor = entry.class_minor; + if let Some(minor) = desired_minor + && (minor < ACTIVE_RUNTIME_MINOR_START as u16 + || used_down.contains(&minor) + || used_up.contains(&minor)) + { + desired_minor = None; + } + + let class_minor = match desired_minor + .or_else(|| find_free_dynamic_circuit_minor(used_down, used_up)) + { + Some(minor) => minor, + None => { + warn!( + "Bakery: unable to allocate a free class_minor for dynamic circuit overlay {} (down_major=0x{:x}, up_major=0x{:x})", + entry.shaped_device.circuit_id, down_major, up_major + ); + continue; + } + }; + entry.class_minor = Some(class_minor); + used_down.insert(class_minor); + used_up.insert(class_minor); + + // Ensure the overlay command is the only circuit entry for this hash. + batch.retain(|cmd| match cmd.as_ref() { + BakeryCommands::AddCircuit { + circuit_hash: h, .. + } => *h != circuit_hash, + _ => true, + }); + + let ip_addresses = ip_list_from_shaped_device(&entry.shaped_device); + let circuit_name = (!entry.shaped_device.circuit_name.trim().is_empty()) + .then(|| entry.shaped_device.circuit_name.clone()); + let site_name = (!entry.shaped_device.parent_node.trim().is_empty()) + .then(|| entry.shaped_device.parent_node.clone()); + + batch.push(Arc::new(BakeryCommands::AddCircuit { + circuit_hash, + circuit_name, + site_name, + parent_class_id: down_parent, + up_parent_class_id: up_parent, + class_minor, + download_bandwidth_min: entry.shaped_device.download_min_mbps, + upload_bandwidth_min: entry.shaped_device.upload_min_mbps, + download_bandwidth_max: entry.shaped_device.download_max_mbps, + upload_bandwidth_max: entry.shaped_device.upload_max_mbps, + class_major: down_major, + up_class_major: up_major, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses, + sqm_override: entry.shaped_device.sqm_override.clone(), + })); + } +} + +#[allow(clippy::too_many_arguments)] +fn handle_upsert_dynamic_circuit_overlay( + mut shaped_device: lqos_config::ShapedDevice, + overlays: &mut HashMap, + batch_in_progress: bool, + sites: &HashMap>, + circuits: &mut HashMap>, + live_circuits: &mut HashMap, + mq_layout: &Option, + qdisc_handles: &mut QdiscHandleState, + migrations: &HashMap, +) -> Result<(), String> { + if shaped_device.circuit_id.trim().is_empty() { + return Err("dynamic circuit requires circuit_id".to_string()); + } + if shaped_device.device_id.trim().is_empty() { + return Err("dynamic circuit requires device_id".to_string()); + } + if shaped_device.parent_node.trim().is_empty() { + return Err("dynamic circuit requires parent_node".to_string()); + } + + recompute_shaped_hashes(&mut shaped_device); + let circuit_hash = shaped_device.circuit_hash; + + let entry = overlays + .entry(circuit_hash) + .or_insert_with(|| DynamicCircuitOverlayEntry { + shaped_device: shaped_device.clone(), + class_minor: None, + }); + entry.shaped_device = shaped_device; + + if batch_in_progress { + // Avoid mutating live TC while a full batch is being assembled; the overlay will be + // applied when the batch is committed. + return Ok(()); + } + + let Ok(config) = lqos_config::load_config() else { + return Err("unable to load config".to_string()); + }; + + if config.queues.queue_mode.is_observe() { + return Ok(()); + } + + // If we don't yet have a baseline MQ layout or any sites, keep the overlay and let the next + // commit batch create the circuit. + if !MQ_CREATED.load(Ordering::Relaxed) || mq_layout.is_none() || sites.is_empty() { + return Ok(()); + } + + // If the circuit is already present, don't attempt to live-update it here yet. + if circuits.contains_key(&circuit_hash) { + return Ok(()); + } + + let site_hash = runtime_hash_to_i64(&entry.shaped_device.parent_node); + let Some(site_cmd) = sites.get(&site_hash) else { + return Err(format!( + "parent node '{}' is not present in current bakery site state", + entry.shaped_device.parent_node + )); + }; + let Some((down_parent, up_parent)) = site_class_handles(site_cmd.as_ref()) else { + return Err(format!( + "parent node '{}' did not resolve to a valid site class", + entry.shaped_device.parent_node + )); + }; + + let down_major = down_parent.get_major_minor().0; + let up_major = up_parent.get_major_minor().0; + + let (mut used_down, mut used_up) = used_site_minors_for_majors(sites, down_major, up_major); + let (used_circuit_down, used_circuit_up) = + used_circuit_minors_for_majors(circuits, down_major, up_major); + used_down.extend(used_circuit_down); + used_up.extend(used_circuit_up); + let (used_shadow_down, used_shadow_up) = + used_pending_migration_shadow_minors_for_majors(migrations, down_major, up_major); + used_down.extend(used_shadow_down); + used_up.extend(used_shadow_up); + + let mut desired_minor = entry.class_minor; + if let Some(minor) = desired_minor + && (minor < ACTIVE_RUNTIME_MINOR_START as u16 + || used_down.contains(&minor) + || used_up.contains(&minor)) + { + desired_minor = None; + } + let class_minor = desired_minor + .or_else(|| find_free_dynamic_circuit_minor(&used_down, &used_up)) + .ok_or_else(|| "unable to allocate free class_minor for dynamic circuit".to_string())?; + entry.class_minor = Some(class_minor); + + let ip_addresses = ip_list_from_shaped_device(&entry.shaped_device); + let candidate = Arc::new(BakeryCommands::AddCircuit { + circuit_hash, + circuit_name: (!entry.shaped_device.circuit_name.trim().is_empty()) + .then(|| entry.shaped_device.circuit_name.clone()), + site_name: (!entry.shaped_device.parent_node.trim().is_empty()) + .then(|| entry.shaped_device.parent_node.clone()), + parent_class_id: down_parent, + up_parent_class_id: up_parent, + class_minor, + download_bandwidth_min: entry.shaped_device.download_min_mbps, + upload_bandwidth_min: entry.shaped_device.upload_min_mbps, + download_bandwidth_max: entry.shaped_device.download_max_mbps, + upload_bandwidth_max: entry.shaped_device.upload_max_mbps, + class_major: down_major, + up_class_major: up_major, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses, + sqm_override: entry.shaped_device.sqm_override.clone(), + }); + + let mapped_limit = resolve_mapped_circuit_limit(); + if is_mapped_add_circuit(candidate.as_ref()) + && let Some(limit) = mapped_limit.effective_limit + && circuits + .values() + .filter(|c| is_mapped_add_circuit(c.as_ref())) + .count() + >= limit + { + let stats = MappedLimitStats { + enforced_limit: mapped_limit.effective_limit, + requested_mapped: 1, + allowed_mapped: 0, + dropped_mapped: 1, + }; + warn!( + "Bakery mapped circuit cap enforced (dynamic circuit addition): requested={}, allowed={}, dropped={}, limit={} (licensed={}, max_circuits={:?})", + stats.requested_mapped, + stats.allowed_mapped, + stats.dropped_mapped, + format_mapped_limit(mapped_limit.effective_limit), + mapped_limit.licensed, + mapped_limit.max_circuits + ); + maybe_emit_mapped_circuit_limit_urgent(&stats); + return Err("mapped circuit limit reached".to_string()); + } + + let Some(layout) = mq_layout.as_ref() else { + return Ok(()); + }; + let live_reserved_handles = + snapshot_live_qdisc_handle_majors_or_empty(&config, "dynamic circuit additions"); + let enriched = with_assigned_qdisc_handles_reserved( + &candidate, + &config, + layout, + qdisc_handles, + &live_reserved_handles, + ); + let commands = enriched + .to_commands(&config, ExecutionMode::Builder) + .unwrap_or_default(); + if !commands.is_empty() { + execute_and_record_live_change(&commands, "adding dynamic circuit"); + } + circuits.insert(circuit_hash, enriched); + live_circuits.remove(&circuit_hash); + qdisc_handles.save(&config); + update_queue_distribution_snapshot(sites, circuits); + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn handle_remove_dynamic_circuit_overlay( + circuit_id: &str, + overlays: &mut HashMap, + batch_in_progress: bool, + sites: &HashMap>, + circuits: &mut HashMap>, + live_circuits: &mut HashMap, + _mq_layout: &Option, + qdisc_handles: &mut QdiscHandleState, +) -> Result<(), String> { + let normalized = normalize_circuit_id_key(circuit_id); + let overlay_key = overlays + .iter() + .find(|(_, entry)| normalize_circuit_id_key(&entry.shaped_device.circuit_id) == normalized) + .map(|(k, _)| *k) + .or_else(|| { + let computed = runtime_hash_to_i64(circuit_id); + overlays.contains_key(&computed).then_some(computed) + }); + + if let Some(key) = overlay_key { + overlays.remove(&key); + } + + if batch_in_progress { + // Avoid mutating live TC while a full batch is being assembled; the next commit batch + // will reconcile the removal. + return Ok(()); + } + + let Ok(config) = lqos_config::load_config() else { + return Err("unable to load config".to_string()); + }; + + let circuit_hash = overlay_key.unwrap_or_else(|| runtime_hash_to_i64(circuit_id)); + if let Some(circuit) = circuits.remove(&circuit_hash) { + let was_activated = live_circuits.contains_key(&circuit_hash); + let commands = match config.queues.lazy_queues.as_ref() { + None | Some(LazyQueueMode::No) => circuit.to_prune(&config, true), + Some(LazyQueueMode::Htb) => { + if was_activated { + circuit.to_prune(&config, false) + } else { + None + } + } + Some(LazyQueueMode::Full) => { + if was_activated { + circuit.to_prune(&config, true) + } else { + None + } + } + }; + if let Some(cmd) = commands { + execute_and_record_live_change(&cmd, "removing dynamic circuit"); + } + live_circuits.remove(&circuit_hash); + qdisc_handles.release_circuit(&config.isp_interface(), circuit_hash); + if !config.on_a_stick_mode() { + qdisc_handles.release_circuit(&config.internet_interface(), circuit_hash); + } + qdisc_handles.save(&config); + update_queue_distribution_snapshot(sites, circuits); + } + + Ok(()) +} + /*fn parse_ip_and_prefix(ip: &str) -> (String, u32) { if let Some((addr, pfx)) = ip.split_once('/') { if let Ok(n) = pfx.parse::() { @@ -3089,6 +3592,11 @@ fn bakery_main(rx: Receiver, tx: Sender) { let mut virtualized_sites: HashMap = HashMap::new(); let mut runtime_node_operations: HashMap = HashMap::new(); let mut next_runtime_operation_id: u64 = 1; + let mut dynamic_circuit_overlays: HashMap = + lqos_config::load_config() + .ok() + .map(|config| load_dynamic_circuit_overlays_from_disk(&config)) + .unwrap_or_default(); // Mapping state #[derive(Clone, Hash, PartialEq, Eq, Debug)] @@ -4017,6 +4525,7 @@ fn bakery_main(rx: Receiver, tx: Sender) { &mut batch, &mut sites, &mut circuits, + &mut dynamic_circuit_overlays, &mut live_circuits, &mut mq_layout, &mut qdisc_handles, @@ -4055,6 +4564,40 @@ fn bakery_main(rx: Receiver, tx: Sender) { batch.push(Arc::new(command)); } } + BakeryCommands::UpsertDynamicCircuitOverlay { + shaped_device, + reply, + } => { + let result = handle_upsert_dynamic_circuit_overlay( + *shaped_device, + &mut dynamic_circuit_overlays, + batch.is_some(), + &sites, + &mut circuits, + &mut live_circuits, + &mq_layout, + &mut qdisc_handles, + &migrations, + ); + if let Some(reply) = reply { + let _ = reply.send(result); + } + } + BakeryCommands::RemoveDynamicCircuitOverlay { circuit_id, reply } => { + let result = handle_remove_dynamic_circuit_overlay( + &circuit_id, + &mut dynamic_circuit_overlays, + batch.is_some(), + &sites, + &mut circuits, + &mut live_circuits, + &mq_layout, + &mut qdisc_handles, + ); + if let Some(reply) = reply { + let _ = reply.send(result); + } + } BakeryCommands::OnCircuitActivity { circuit_ids } => { handle_circuit_activity(circuit_ids, &circuits, &mut live_circuits); } @@ -4199,6 +4742,7 @@ fn handle_commit_batch( batch: &mut Option>>, sites: &mut HashMap>, circuits: &mut HashMap>, + dynamic_circuit_overlays: &mut HashMap, live_circuits: &mut HashMap, mq_layout: &mut Option, qdisc_handles: &mut QdiscHandleState, @@ -4218,6 +4762,8 @@ fn handle_commit_batch( debug!("CommitBatch received without a batch to commit."); return; }; + let mut raw_batch = raw_batch; + append_dynamic_circuit_overlays_to_batch(&mut raw_batch, dynamic_circuit_overlays, migrations); let (baseline_sites, baseline_circuits) = reconstruct_structural_baseline_state(sites, circuits, virtualized_sites); let effective_new_batch = diff --git a/src/rust/lqos_bus/src/bus/request.rs b/src/rust/lqos_bus/src/bus/request.rs index 69f04f1d..ed9572b4 100644 --- a/src/rust/lqos_bus/src/bus/request.rs +++ b/src/rust/lqos_bus/src/bus/request.rs @@ -218,6 +218,23 @@ pub enum BusRequest { /// Requests that the configuration be updated UpdateLqosdConfig(Box), + /// Create or update a runtime-only dynamic circuit overlay entry. + /// + /// This does **not** mutate `ShapedDevices.csv`. The daemon may ignore this request when + /// dynamic circuits are disabled in the configuration. + CreateDynamicCircuit { + /// Shaped-device-like definition for the dynamic circuit/device. + shaped_device: Box, + }, + + /// Remove a runtime-only dynamic circuit overlay entry. + /// + /// The daemon may ignore this request when dynamic circuits are disabled in the configuration. + RemoveDynamicCircuit { + /// Stable circuit identifier to remove. + circuit_id: String, + }, + /// Invalidate cached Node Manager authentication state. InvalidateAuthCache, diff --git a/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs index d766f336..479e3aba 100644 --- a/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs +++ b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs @@ -125,10 +125,14 @@ impl DynamicCircuitsConfig { return Err(format!("{label}: upload_max_mbps must be >= 0.2")); } if rule.download_min_mbps > rule.download_max_mbps { - return Err(format!("{label}: download_min_mbps must be <= download_max_mbps")); + return Err(format!( + "{label}: download_min_mbps must be <= download_max_mbps" + )); } if rule.upload_min_mbps > rule.upload_max_mbps { - return Err(format!("{label}: upload_min_mbps must be <= upload_max_mbps")); + return Err(format!( + "{label}: upload_min_mbps must be <= upload_max_mbps" + )); } } diff --git a/src/rust/lqos_network_devices/Cargo.toml b/src/rust/lqos_network_devices/Cargo.toml index 0d2d4e80..011a1ba5 100644 --- a/src/rust/lqos_network_devices/Cargo.toml +++ b/src/rust/lqos_network_devices/Cargo.toml @@ -14,5 +14,7 @@ lqos_config = { path = "../lqos_config" } lqos_utils = { path = "../lqos_utils" } once_cell = { workspace = true } parking_lot = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/src/rust/lqos_network_devices/src/actor.rs b/src/rust/lqos_network_devices/src/actor.rs index f3066a5b..c73fe17d 100644 --- a/src/rust/lqos_network_devices/src/actor.rs +++ b/src/rust/lqos_network_devices/src/actor.rs @@ -1,7 +1,8 @@ use crate::dynamic::CircuitObservation; +use crate::dynamic_store::{load_dynamic_circuits_from_disk, persist_dynamic_circuits_to_disk}; use crate::state; use crate::{DaemonHooks, load_network_json, load_shaped_devices}; -use anyhow::{Result, anyhow}; +use anyhow::{Context, Result, anyhow}; use crossbeam_channel::{Receiver, Sender}; use once_cell::sync::OnceCell; use std::collections::HashSet; @@ -87,6 +88,28 @@ pub(crate) fn report_observations(observations: &[CircuitObservation]) { }); } +pub(crate) fn upsert_dynamic_circuit(shaped_device: lqos_config::ShapedDevice) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::UpsertDynamicCircuit { + shaped_device: Box::new(shaped_device), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("Upsert dynamic circuit reply channel closed"))? +} + +pub(crate) fn remove_dynamic_circuit(circuit_id: &str) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::RemoveDynamicCircuit { + circuit_id: circuit_id.to_string(), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("Remove dynamic circuit reply channel closed"))? +} + enum NetworkDevicesCommand { ReloadShapedDevices { reason: String, @@ -104,6 +127,14 @@ enum NetworkDevicesCommand { ReportObservations { observations: Vec, }, + UpsertDynamicCircuit { + shaped_device: Box, + reply: oneshot::Sender>, + }, + RemoveDynamicCircuit { + circuit_id: String, + reply: oneshot::Sender>, + }, } fn actor_loop(rx: Receiver, hooks: Option>) { @@ -115,6 +146,7 @@ fn actor_loop(rx: Receiver, hooks: Option, hooks: Option { handle_observations(&observations); } + NetworkDevicesCommand::UpsertDynamicCircuit { + shaped_device, + reply, + } => { + let result = upsert_dynamic_circuit_inner(*shaped_device); + let _ = reply.send(result); + } + NetworkDevicesCommand::RemoveDynamicCircuit { circuit_id, reply } => { + let result = remove_dynamic_circuit_inner(&circuit_id); + let _ = reply.send(result); + } } } error!("lqos_network_devices actor stopped"); } +fn normalize_circuit_id_key(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn recompute_hashes(device: &mut lqos_config::ShapedDevice) { + device.circuit_hash = lqos_utils::hash_to_i64(&device.circuit_id); + device.device_hash = lqos_utils::hash_to_i64(&device.device_id); + device.parent_hash = lqos_utils::hash_to_i64(&device.parent_node); +} + +fn reload_dynamic_circuits_inner(reason: &str) { + let circuits = load_dynamic_circuits_from_disk(); + debug!("Loaded {} dynamic circuits reason={reason}", circuits.len()); + state::publish_dynamic_circuits_snapshot(circuits); +} + +fn upsert_dynamic_circuit_inner(mut shaped_device: lqos_config::ShapedDevice) -> Result<()> { + recompute_hashes(&mut shaped_device); + let now_unix = lqos_utils::unix_time::unix_now().context("get unix time")?; + + let snapshot = state::dynamic_circuits_snapshot(); + let mut updated = snapshot.as_ref().clone(); + let key = normalize_circuit_id_key(&shaped_device.circuit_id); + + if let Some(pos) = updated + .iter() + .position(|c| normalize_circuit_id_key(&c.shaped.circuit_id) == key) + { + updated[pos].shaped = shaped_device; + updated[pos].last_seen_unix = now_unix; + } else { + updated.push(crate::DynamicCircuit { + shaped: shaped_device, + last_seen_unix: now_unix, + }); + } + + persist_dynamic_circuits_to_disk(&updated).context("persist dynamic circuits to disk")?; + state::publish_dynamic_circuits_snapshot(updated); + Ok(()) +} + +fn remove_dynamic_circuit_inner(circuit_id: &str) -> Result { + let snapshot = state::dynamic_circuits_snapshot(); + if snapshot.is_empty() { + return Ok(false); + } + + let key = normalize_circuit_id_key(circuit_id); + let updated: Vec<_> = snapshot + .iter() + .filter(|c| normalize_circuit_id_key(&c.shaped.circuit_id) != key) + .cloned() + .collect(); + + if updated.len() == snapshot.len() { + return Ok(false); + } + + persist_dynamic_circuits_to_disk(&updated).context("persist dynamic circuits to disk")?; + state::publish_dynamic_circuits_snapshot(updated); + Ok(true) +} + fn handle_observations(observations: &[CircuitObservation]) { if observations.is_empty() { return; diff --git a/src/rust/lqos_network_devices/src/dynamic.rs b/src/rust/lqos_network_devices/src/dynamic.rs index 5904a35f..832a5d2e 100644 --- a/src/rust/lqos_network_devices/src/dynamic.rs +++ b/src/rust/lqos_network_devices/src/dynamic.rs @@ -1,11 +1,12 @@ use lqos_config::ShapedDevice; use lqos_utils::XdpIpAddress; +use serde::{Deserialize, Serialize}; /// Runtime-only dynamic circuit overlay entry. /// /// This is an overlay on top of `ShapedDevices.csv` and never mutates the static file. /// It is intended for circuits/devices that are created and managed dynamically at runtime. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct DynamicCircuit { /// Shaped-device-like fields describing the dynamic circuit/device. pub shaped: ShapedDevice, diff --git a/src/rust/lqos_network_devices/src/dynamic_store.rs b/src/rust/lqos_network_devices/src/dynamic_store.rs new file mode 100644 index 00000000..27a7cb86 --- /dev/null +++ b/src/rust/lqos_network_devices/src/dynamic_store.rs @@ -0,0 +1,123 @@ +use crate::DynamicCircuit; +use anyhow::{Context, Result}; +use lqos_config::ShapedDevice; +use lqos_utils::hash_to_i64; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tracing::warn; + +const DYNAMIC_CIRCUITS_FILENAME: &str = "dynamic_circuits.json"; + +fn dynamic_circuits_path(config: &lqos_config::Config) -> PathBuf { + Path::new(&config.lqos_directory).join(DYNAMIC_CIRCUITS_FILENAME) +} + +fn default_dynamic_circuits_schema_version() -> u32 { + 1 +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +struct DynamicCircuitsFile { + #[serde(default = "default_dynamic_circuits_schema_version")] + schema_version: u32, + #[serde(default)] + circuits: Vec, +} + +fn recompute_hashes(device: &mut ShapedDevice) { + device.circuit_hash = hash_to_i64(&device.circuit_id); + device.device_hash = hash_to_i64(&device.device_id); + device.parent_hash = hash_to_i64(&device.parent_node); +} + +fn normalize_circuit_id_key(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn atomic_write_json(path: &Path, value: &T) -> Result<()> { + let raw = serde_json::to_string_pretty(value).context("serialize dynamic circuits JSON")?; + let temp_path = path.with_extension("tmp"); + let mut file = File::create(&temp_path) + .with_context(|| format!("create temp dynamic circuits file {}", temp_path.display()))?; + file.write_all(raw.as_bytes()) + .with_context(|| format!("write temp dynamic circuits file {}", temp_path.display()))?; + file.sync_all() + .with_context(|| format!("sync temp dynamic circuits file {}", temp_path.display()))?; + std::fs::rename(&temp_path, path).with_context(|| { + format!( + "rename temp dynamic circuits file {} to {}", + temp_path.display(), + path.display() + ) + })?; + Ok(()) +} + +pub(crate) fn load_dynamic_circuits_from_disk() -> Vec { + let Ok(config) = lqos_config::load_config() else { + warn!("Unable to load lqos config while loading dynamic circuits; treating as empty"); + return Vec::new(); + }; + + let path = dynamic_circuits_path(config.as_ref()); + if !path.exists() { + return Vec::new(); + } + + let Ok(raw) = std::fs::read_to_string(&path) else { + warn!( + "Unable to read dynamic circuits file {}; treating as empty", + path.display() + ); + return Vec::new(); + }; + + if raw.trim().is_empty() { + return Vec::new(); + } + + let mut parsed: DynamicCircuitsFile = match serde_json::from_str(&raw) { + Ok(value) => value, + Err(err) => { + warn!( + "Unable to parse dynamic circuits file {} ({err}); treating as empty", + path.display() + ); + return Vec::new(); + } + }; + + for circuit in &mut parsed.circuits { + recompute_hashes(&mut circuit.shaped); + } + + // De-duplicate by circuit_id (case-insensitive), keeping last entry. + parsed + .circuits + .into_iter() + .fold(Vec::::new(), |mut acc, circuit| { + let key = normalize_circuit_id_key(&circuit.shaped.circuit_id); + if let Some(pos) = acc + .iter() + .position(|c| normalize_circuit_id_key(&c.shaped.circuit_id) == key) + { + acc[pos] = circuit; + } else { + acc.push(circuit); + } + acc + }) +} + +pub(crate) fn persist_dynamic_circuits_to_disk(circuits: &[DynamicCircuit]) -> Result<()> { + let config = lqos_config::load_config().context("load lqos config")?; + let path = dynamic_circuits_path(config.as_ref()); + let file = DynamicCircuitsFile { + schema_version: default_dynamic_circuits_schema_version(), + circuits: circuits.to_vec(), + }; + atomic_write_json(&path, &file) + .with_context(|| format!("write dynamic circuits file {}", path.display())) +} diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs index 95e11ea2..9b6d424e 100644 --- a/src/rust/lqos_network_devices/src/lib.rs +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -9,6 +9,7 @@ mod actor; mod catalog; mod directory_watcher; mod dynamic; +mod dynamic_store; mod hash_cache; mod state; mod topology; @@ -113,6 +114,28 @@ pub fn report_observations(observations: &[CircuitObservation]) { actor::report_observations(observations) } +/// Creates or updates a runtime-only dynamic circuit overlay entry. +/// +/// Side effects: +/// - Updates the in-memory dynamic circuit snapshot. +/// - Writes `dynamic_circuits.json` in the LibreQoS directory. +/// +/// This function does **not** mutate `ShapedDevices.csv`. +pub fn upsert_dynamic_circuit(shaped_device: lqos_config::ShapedDevice) -> Result<()> { + actor::upsert_dynamic_circuit(shaped_device) +} + +/// Removes a runtime-only dynamic circuit overlay entry by circuit id. +/// +/// Side effects: +/// - Updates the in-memory dynamic circuit snapshot. +/// - Writes `dynamic_circuits.json` in the LibreQoS directory when a circuit is removed. +/// +/// This function does **not** mutate `ShapedDevices.csv`. +pub fn remove_dynamic_circuit(circuit_id: &str) -> Result { + actor::remove_dynamic_circuit(circuit_id) +} + /// Replaces the in-memory `ShapedDevices.csv` snapshot without involving the runtime actor. /// /// Side effects: diff --git a/src/rust/lqos_overrides/Cargo.toml b/src/rust/lqos_overrides/Cargo.toml index e7612296..1a343db2 100644 --- a/src/rust/lqos_overrides/Cargo.toml +++ b/src/rust/lqos_overrides/Cargo.toml @@ -9,4 +9,6 @@ serde_json.workspace = true anyhow.workspace = true nix.workspace = true lqos_config = { path = "../lqos_config" } +lqos_bus = { path = "../lqos_bus" } clap = { workspace = true, features = ["derive"] } +tokio.workspace = true diff --git a/src/rust/lqos_overrides/src/main.rs b/src/rust/lqos_overrides/src/main.rs index 09dd670e..fbc2cde2 100644 --- a/src/rust/lqos_overrides/src/main.rs +++ b/src/rust/lqos_overrides/src/main.rs @@ -3,6 +3,7 @@ use std::net::{Ipv4Addr, Ipv6Addr}; use anyhow::{Result, anyhow}; use clap::{Args, Parser, Subcommand}; +use lqos_bus::{BusRequest, BusResponse, LibreqosBusClient}; use lqos_config::ShapedDevice; use lqos_overrides::{CircuitAdjustment, NetworkAdjustment, OverrideFile}; @@ -36,6 +37,11 @@ enum Commands { #[command(subcommand)] command: UispCommand, }, + /// Manage runtime-only dynamic circuit overlay entries (via lqosd bus) + DynamicCircuits { + #[command(subcommand)] + command: DynamicCircuitsCommand, + }, } #[derive(Subcommand, Debug)] @@ -148,6 +154,17 @@ enum UispCommand { RouteList, } +#[derive(Subcommand, Debug)] +enum DynamicCircuitsCommand { + /// Add or update a dynamic circuit overlay entry + Add(Box), + /// Remove a dynamic circuit overlay entry by circuit ID + Remove { + #[arg(long)] + circuit_id: String, + }, +} + #[derive(Args, Debug)] struct AddArgs { #[arg(long)] @@ -318,222 +335,265 @@ struct AddSiteSpeedArgs { fn main() -> Result<()> { let cli = Cli::parse(); - // Load overrides file at start - let mut overrides = OverrideFile::load()?; + fn send_bus_request(requests: Vec) -> Result> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(async { + let mut bus = LibreqosBusClient::new().await?; + Ok(bus.request(requests).await?) + }) + } match cli.command { - Commands::PersistentDevices { command: cmd } => match cmd { - PersistentDevicesCommand::Add(args) => { - let device = args.into_device()?; - let changed = overrides.add_persistent_shaped_device_return_changed(device); - if changed { - overrides.save()?; - println!("Added device; overrides saved."); - } else { - println!("No changes (device already present)."); + Commands::DynamicCircuits { command: cmd } => match cmd { + DynamicCircuitsCommand::Add(args) => { + let device = (*args).into_device()?; + let mut replies = send_bus_request(vec![BusRequest::CreateDynamicCircuit { + shaped_device: Box::new(device), + }])?; + match replies.pop() { + Some(BusResponse::Ack) => println!("Dynamic circuit created/updated."), + Some(BusResponse::Fail(message)) => return Err(anyhow!(message)), + Some(other) => println!("Unexpected bus response: {other:?}"), + None => println!("No bus response."), } } - PersistentDevicesCommand::DeleteCircuitId { circuit_id } => { - let removed = - overrides.remove_persistent_shaped_device_by_circuit_count(&circuit_id); - if removed > 0 { - overrides.save()?; - println!("Removed {removed} device(s) by circuit_id; overrides saved."); - } else { - println!("No devices matched circuit_id {circuit_id}."); - } - } - PersistentDevicesCommand::DeleteDeviceId { device_id } => { - let removed = overrides.remove_persistent_shaped_device_by_device_count(&device_id); - if removed > 0 { - overrides.save()?; - println!("Removed {removed} device(s) by device_id; overrides saved."); - } else { - println!("No devices matched device_id {device_id}."); - } - } - PersistentDevicesCommand::List => { - let list = overrides.persistent_devices(); - println!("{}", serde_json::to_string_pretty(&list)?); - } - }, - Commands::Adjustments { command: cmd } => match cmd { - AdjustmentsCommand::AddCircuitSpeed(args) => { - let adj = CircuitAdjustment::CircuitAdjustSpeed { - circuit_id: args.circuit_id, - min_download_bandwidth: args.min_download_bandwidth, - max_download_bandwidth: args.max_download_bandwidth, - min_upload_bandwidth: args.min_upload_bandwidth, - max_upload_bandwidth: args.max_upload_bandwidth, - }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added circuit speed adjustment; overrides saved."); - } - AdjustmentsCommand::AddDeviceSpeed(args) => { - let adj = CircuitAdjustment::DeviceAdjustSpeed { - device_id: args.device_id, - min_download_bandwidth: args.min_download_bandwidth, - max_download_bandwidth: args.max_download_bandwidth, - min_upload_bandwidth: args.min_upload_bandwidth, - max_upload_bandwidth: args.max_upload_bandwidth, - }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added device speed adjustment; overrides saved."); - } - AdjustmentsCommand::AddRemoveCircuit { circuit_id } => { - let adj = CircuitAdjustment::RemoveCircuit { circuit_id }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added remove-circuit adjustment; overrides saved."); - } - AdjustmentsCommand::AddRemoveDevice { device_id } => { - let adj = CircuitAdjustment::RemoveDevice { device_id }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added remove-device adjustment; overrides saved."); - } - AdjustmentsCommand::AddReparentCircuit { - circuit_id, - parent_node, - } => { - let adj = CircuitAdjustment::ReparentCircuit { - circuit_id, - parent_node, - }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added reparent-circuit adjustment; overrides saved."); - } - AdjustmentsCommand::DeleteIndex { index } => { - let ok = overrides.remove_circuit_adjustment_by_index(index); - if ok { - overrides.save()?; - println!("Removed adjustment at index {index}; overrides saved."); - } else { - println!("No adjustment at index {index}."); - } - } - AdjustmentsCommand::List => { - let list = overrides.circuit_adjustments(); - println!("{}", serde_json::to_string_pretty(&list)?); - } - }, - Commands::NetworkAdjustments { command: cmd } => match cmd { - NetworkAdjustmentsCommand::AddSiteSpeed(args) => { - let adj = NetworkAdjustment::AdjustSiteSpeed { - node_id: args.node_id, - site_name: args.site_name, - download_bandwidth_mbps: args.download_bandwidth_mbps, - upload_bandwidth_mbps: args.upload_bandwidth_mbps, - }; - overrides.add_network_adjustment(adj); - overrides.save()?; - println!("Added site speed adjustment; overrides saved."); - } - NetworkAdjustmentsCommand::SetVirtual { - node_name, - virtual_node, - } => { - overrides.set_network_node_virtual(node_name, virtual_node); - overrides.save()?; - println!("Set node virtual flag; overrides saved."); - } - NetworkAdjustmentsCommand::DeleteVirtual { node_name } => { - let removed = overrides.remove_network_node_virtual_by_name_count(&node_name); - if removed > 0 { - overrides.save()?; - println!( - "Removed {removed} virtual override(s) for node '{node_name}'; overrides saved." - ); - } else { - println!("No virtual override found for node '{node_name}'."); - } - } - NetworkAdjustmentsCommand::DeleteIndex { index } => { - let ok = overrides.remove_network_adjustment_by_index(index); - if ok { - overrides.save()?; - println!("Removed network adjustment at index {index}; overrides saved."); - } else { - println!("No network adjustment at index {index}."); - } - } - NetworkAdjustmentsCommand::List => { - let list = overrides.network_adjustments(); - println!("{}", serde_json::to_string_pretty(&list)?); - } - }, - Commands::Uisp { command: cmd } => match cmd { - UispCommand::BandwidthSet { - site_name, - down, - up, - } => { - overrides.set_uisp_bandwidth_override(site_name, down, up); - overrides.save()?; - println!( - "Added deprecated UISP bandwidth override entry; overrides saved. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead." - ); - } - UispCommand::BandwidthRemove { site_name } => { - let removed = overrides.remove_uisp_bandwidth_override(&site_name); - if removed { - overrides.save()?; - println!( - "Removed deprecated UISP bandwidth override for {site_name}; overrides saved." - ); - } else { - println!("No UISP bandwidth override found for {site_name}."); - } - } - UispCommand::BandwidthList => { - println!( - "Deprecated UISP bandwidth override entries. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead." - ); - if let Some(uisp) = overrides.uisp() { - println!( - "{}", - serde_json::to_string_pretty(&uisp.bandwidth_overrides)? - ); - } else { - println!("{{}}"); - } - } - UispCommand::RouteAdd { - from_site, - to_site, - cost, - } => { - overrides.add_uisp_route_override(from_site, to_site, cost); - overrides.save()?; - println!( - "Added deprecated UISP route override entry; overrides saved. Current UISP builds ignore these overrides." - ); - } - UispCommand::RouteRemoveIndex { index } => { - let removed = overrides.remove_uisp_route_by_index(index); - if removed { - overrides.save()?; - println!( - "Removed deprecated UISP route override at index {index}; overrides saved." - ); - } else { - println!("No UISP route override at index {index}."); - } - } - UispCommand::RouteList => { - println!( - "Deprecated UISP route override entries. Current UISP builds ignore these overrides." - ); - if let Some(uisp) = overrides.uisp() { - println!("{}", serde_json::to_string_pretty(&uisp.route_overrides)?); - } else { - println!("[]"); + DynamicCircuitsCommand::Remove { circuit_id } => { + let mut replies = + send_bus_request(vec![BusRequest::RemoveDynamicCircuit { circuit_id }])?; + match replies.pop() { + Some(BusResponse::Ack) => println!("Dynamic circuit removed."), + Some(BusResponse::Fail(message)) => return Err(anyhow!(message)), + Some(other) => println!("Unexpected bus response: {other:?}"), + None => println!("No bus response."), } } }, + command => { + // All other commands operate on the overrides file. + let mut overrides = OverrideFile::load()?; + + match command { + Commands::PersistentDevices { command: cmd } => match cmd { + PersistentDevicesCommand::Add(args) => { + let device = args.into_device()?; + let changed = overrides.add_persistent_shaped_device_return_changed(device); + if changed { + overrides.save()?; + println!("Added device; overrides saved."); + } else { + println!("No changes (device already present)."); + } + } + PersistentDevicesCommand::DeleteCircuitId { circuit_id } => { + let removed = + overrides.remove_persistent_shaped_device_by_circuit_count(&circuit_id); + if removed > 0 { + overrides.save()?; + println!("Removed {removed} device(s) by circuit_id; overrides saved."); + } else { + println!("No devices matched circuit_id {circuit_id}."); + } + } + PersistentDevicesCommand::DeleteDeviceId { device_id } => { + let removed = + overrides.remove_persistent_shaped_device_by_device_count(&device_id); + if removed > 0 { + overrides.save()?; + println!("Removed {removed} device(s) by device_id; overrides saved."); + } else { + println!("No devices matched device_id {device_id}."); + } + } + PersistentDevicesCommand::List => { + let list = overrides.persistent_devices(); + println!("{}", serde_json::to_string_pretty(&list)?); + } + }, + Commands::Adjustments { command: cmd } => match cmd { + AdjustmentsCommand::AddCircuitSpeed(args) => { + let adj = CircuitAdjustment::CircuitAdjustSpeed { + circuit_id: args.circuit_id, + min_download_bandwidth: args.min_download_bandwidth, + max_download_bandwidth: args.max_download_bandwidth, + min_upload_bandwidth: args.min_upload_bandwidth, + max_upload_bandwidth: args.max_upload_bandwidth, + }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added circuit speed adjustment; overrides saved."); + } + AdjustmentsCommand::AddDeviceSpeed(args) => { + let adj = CircuitAdjustment::DeviceAdjustSpeed { + device_id: args.device_id, + min_download_bandwidth: args.min_download_bandwidth, + max_download_bandwidth: args.max_download_bandwidth, + min_upload_bandwidth: args.min_upload_bandwidth, + max_upload_bandwidth: args.max_upload_bandwidth, + }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added device speed adjustment; overrides saved."); + } + AdjustmentsCommand::AddRemoveCircuit { circuit_id } => { + let adj = CircuitAdjustment::RemoveCircuit { circuit_id }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added remove-circuit adjustment; overrides saved."); + } + AdjustmentsCommand::AddRemoveDevice { device_id } => { + let adj = CircuitAdjustment::RemoveDevice { device_id }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added remove-device adjustment; overrides saved."); + } + AdjustmentsCommand::AddReparentCircuit { + circuit_id, + parent_node, + } => { + let adj = CircuitAdjustment::ReparentCircuit { + circuit_id, + parent_node, + }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added reparent-circuit adjustment; overrides saved."); + } + AdjustmentsCommand::DeleteIndex { index } => { + let ok = overrides.remove_circuit_adjustment_by_index(index); + if ok { + overrides.save()?; + println!("Removed adjustment at index {index}; overrides saved."); + } else { + println!("No adjustment at index {index}."); + } + } + AdjustmentsCommand::List => { + let list = overrides.circuit_adjustments(); + println!("{}", serde_json::to_string_pretty(&list)?); + } + }, + Commands::NetworkAdjustments { command: cmd } => match cmd { + NetworkAdjustmentsCommand::AddSiteSpeed(args) => { + let adj = NetworkAdjustment::AdjustSiteSpeed { + node_id: args.node_id, + site_name: args.site_name, + download_bandwidth_mbps: args.download_bandwidth_mbps, + upload_bandwidth_mbps: args.upload_bandwidth_mbps, + }; + overrides.add_network_adjustment(adj); + overrides.save()?; + println!("Added site speed adjustment; overrides saved."); + } + NetworkAdjustmentsCommand::SetVirtual { + node_name, + virtual_node, + } => { + overrides.set_network_node_virtual(node_name, virtual_node); + overrides.save()?; + println!("Set node virtual flag; overrides saved."); + } + NetworkAdjustmentsCommand::DeleteVirtual { node_name } => { + let removed = + overrides.remove_network_node_virtual_by_name_count(&node_name); + if removed > 0 { + overrides.save()?; + println!( + "Removed {removed} virtual override(s) for node '{node_name}'; overrides saved." + ); + } else { + println!("No virtual override found for node '{node_name}'."); + } + } + NetworkAdjustmentsCommand::DeleteIndex { index } => { + let ok = overrides.remove_network_adjustment_by_index(index); + if ok { + overrides.save()?; + println!( + "Removed network adjustment at index {index}; overrides saved." + ); + } else { + println!("No network adjustment at index {index}."); + } + } + NetworkAdjustmentsCommand::List => { + let list = overrides.network_adjustments(); + println!("{}", serde_json::to_string_pretty(&list)?); + } + }, + Commands::Uisp { command: cmd } => match cmd { + UispCommand::BandwidthSet { + site_name, + down, + up, + } => { + overrides.set_uisp_bandwidth_override(site_name, down, up); + overrides.save()?; + println!( + "Added deprecated UISP bandwidth override entry; overrides saved. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead." + ); + } + UispCommand::BandwidthRemove { site_name } => { + let removed = overrides.remove_uisp_bandwidth_override(&site_name); + if removed { + overrides.save()?; + println!( + "Removed deprecated UISP bandwidth override for {site_name}; overrides saved." + ); + } else { + println!("No UISP bandwidth override found for {site_name}."); + } + } + UispCommand::BandwidthList => { + println!( + "Deprecated UISP bandwidth override entries. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead." + ); + if let Some(uisp) = overrides.uisp() { + println!( + "{}", + serde_json::to_string_pretty(&uisp.bandwidth_overrides)? + ); + } else { + println!("{{}}"); + } + } + UispCommand::RouteAdd { + from_site, + to_site, + cost, + } => { + overrides.add_uisp_route_override(from_site, to_site, cost); + overrides.save()?; + println!( + "Added deprecated UISP route override entry; overrides saved. Current UISP builds ignore these overrides." + ); + } + UispCommand::RouteRemoveIndex { index } => { + let removed = overrides.remove_uisp_route_by_index(index); + if removed { + overrides.save()?; + println!( + "Removed deprecated UISP route override at index {index}; overrides saved." + ); + } else { + println!("No UISP route override at index {index}."); + } + } + UispCommand::RouteList => { + println!( + "Deprecated UISP route override entries. Current UISP builds ignore these overrides." + ); + if let Some(uisp) = overrides.uisp() { + println!("{}", serde_json::to_string_pretty(&uisp.route_overrides)?); + } else { + println!("[]"); + } + } + }, + Commands::DynamicCircuits { .. } => unreachable!("dynamic circuits handled above"), + } + } } Ok(()) diff --git a/src/rust/lqosd/src/dynamic_circuits.rs b/src/rust/lqosd/src/dynamic_circuits.rs new file mode 100644 index 00000000..c19c4097 --- /dev/null +++ b/src/rust/lqosd/src/dynamic_circuits.rs @@ -0,0 +1,101 @@ +use lqos_bus::BusResponse; +use lqos_config::ShapedDevice; +use std::sync::mpsc; +use std::time::Duration; +use tracing::{error, warn}; + +fn dynamic_circuits_enabled() -> bool { + let Ok(config) = lqos_config::load_config() else { + return false; + }; + config + .dynamic_circuits + .as_ref() + .is_some_and(|cfg| cfg.enabled) +} + +pub(crate) fn create_dynamic_circuit(shaped_device: ShapedDevice) -> BusResponse { + if !dynamic_circuits_enabled() { + return BusResponse::Ack; + } + + if shaped_device.circuit_id.trim().is_empty() { + return BusResponse::Fail("dynamic circuit requires circuit_id".to_string()); + } + if shaped_device.device_id.trim().is_empty() { + return BusResponse::Fail("dynamic circuit requires device_id".to_string()); + } + if shaped_device.parent_node.trim().is_empty() { + return BusResponse::Fail("dynamic circuit requires parent_node".to_string()); + } + + if let Err(err) = lqos_network_devices::upsert_dynamic_circuit(shaped_device.clone()) { + error!("Dynamic circuit persist failed: {err:?}"); + return BusResponse::Fail(format!("dynamic circuit persist failed: {err}")); + } + + if let Err(err) = upsert_bakery_overlay(shaped_device.clone()) { + warn!("Dynamic circuit bakery apply failed: {err}"); + // Best-effort rollback: keep network_devices and bakery in sync. + let _ = lqos_network_devices::remove_dynamic_circuit(&shaped_device.circuit_id); + return BusResponse::Fail(err); + } + + BusResponse::Ack +} + +pub(crate) fn remove_dynamic_circuit(circuit_id: &str) -> BusResponse { + if !dynamic_circuits_enabled() { + return BusResponse::Ack; + } + + if circuit_id.trim().is_empty() { + return BusResponse::Fail("dynamic circuit removal requires circuit_id".to_string()); + } + + if let Err(err) = remove_bakery_overlay(circuit_id) { + warn!("Dynamic circuit bakery removal failed: {err}"); + return BusResponse::Fail(err); + } + + if let Err(err) = lqos_network_devices::remove_dynamic_circuit(circuit_id) { + error!("Dynamic circuit removal persist failed: {err:?}"); + return BusResponse::Fail(format!("dynamic circuit removal persist failed: {err}")); + } + + BusResponse::Ack +} + +fn upsert_bakery_overlay(shaped_device: ShapedDevice) -> Result<(), String> { + let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else { + return Err("Bakery not initialized".to_string()); + }; + + let (tx, rx) = mpsc::channel::>(); + sender + .send(lqos_bakery::BakeryCommands::UpsertDynamicCircuitOverlay { + shaped_device: Box::new(shaped_device), + reply: Some(tx), + }) + .map_err(|e| format!("send to bakery failed: {e}"))?; + + rx.recv_timeout(Duration::from_secs(10)) + .map_err(|e| format!("bakery reply timeout: {e}"))? +} + +fn remove_bakery_overlay(circuit_id: &str) -> Result<(), String> { + let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else { + return Err("Bakery not initialized".to_string()); + }; + + let (tx, rx) = mpsc::channel::>(); + sender + .send(lqos_bakery::BakeryCommands::RemoveDynamicCircuitOverlay { + circuit_id: circuit_id.to_string(), + reply: Some(tx), + }) + .map_err(|e| format!("send to bakery failed: {e}"))?; + + rx.recv_timeout(Duration::from_secs(10)) + .map_err(|e| format!("bakery reply timeout: {e}"))? +} diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index 293dda2f..4433a62a 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -4,6 +4,7 @@ #![deny(clippy::unwrap_used)] mod blackboard; +mod dynamic_circuits; mod file_lock; mod ip_mapping; #[cfg(feature = "equinix_tests")] @@ -532,6 +533,12 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec } BusResponse::Ack } + BusRequest::CreateDynamicCircuit { shaped_device } => { + crate::dynamic_circuits::create_dynamic_circuit((**shaped_device).clone()) + } + BusRequest::RemoveDynamicCircuit { circuit_id } => { + crate::dynamic_circuits::remove_dynamic_circuit(circuit_id) + } BusRequest::InvalidateAuthCache => { crate::node_manager::invalidate_auth_cache(); BusResponse::Ack From 8672d906e7f2f2164effc3b68ea403b914bd4140 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 11 Apr 2026 11:30:47 -0500 Subject: [PATCH 06/20] feat: prune expired dynamic circuits --- src/rust/lqos_network_devices/src/actor.rs | 144 ++++++++++++++----- src/rust/lqos_network_devices/src/dynamic.rs | 18 +++ src/rust/lqos_network_devices/src/lib.rs | 4 + src/rust/lqos_network_devices/src/tests.rs | 53 +++++++ src/rust/lqosd/src/network_devices_hooks.rs | 19 +++ 5 files changed, 204 insertions(+), 34 deletions(-) diff --git a/src/rust/lqos_network_devices/src/actor.rs b/src/rust/lqos_network_devices/src/actor.rs index c73fe17d..ac31b5e2 100644 --- a/src/rust/lqos_network_devices/src/actor.rs +++ b/src/rust/lqos_network_devices/src/actor.rs @@ -1,4 +1,4 @@ -use crate::dynamic::CircuitObservation; +use crate::dynamic::{CircuitObservation, expired_dynamic_circuit_ids}; use crate::dynamic_store::{load_dynamic_circuits_from_disk, persist_dynamic_circuits_to_disk}; use crate::state; use crate::{DaemonHooks, load_network_json, load_shaped_devices}; @@ -13,6 +13,7 @@ use tracing::{debug, error, warn}; const RELOAD_RETRY_DELAY_MS: u64 = 500; const RELOAD_ATTEMPTS: usize = 2; +const DYNAMIC_CIRCUITS_MAINTENANCE_TICK_SECONDS: u64 = 60; static ACTOR_SENDER: OnceCell> = OnceCell::new(); @@ -148,41 +149,57 @@ fn actor_loop(rx: Receiver, hooks: Option { - let result = reload_shaped_devices_inner(&reason, hooks.as_deref()); - let _ = reply.send(result); - } - NetworkDevicesCommand::ReloadNetworkJson { reason, reply } => { - let result = reload_network_json_inner(&reason, hooks.as_deref()); - let _ = reply.send(result); - } - NetworkDevicesCommand::ApplyShapedDevicesSnapshot { - reason, - shaped, - reply, - } => { - debug!("Publishing shaped-devices snapshot reason={reason}"); - state::publish_shaped_devices(*shaped); - if let Some(hooks) = &hooks { - hooks.on_shaped_devices_updated(); + let tick = crossbeam_channel::tick(Duration::from_secs( + DYNAMIC_CIRCUITS_MAINTENANCE_TICK_SECONDS, + )); + + loop { + crossbeam_channel::select! { + recv(rx) -> msg => { + let command = match msg { + Ok(value) => value, + Err(_) => break, + }; + + match command { + NetworkDevicesCommand::ReloadShapedDevices { reason, reply } => { + let result = reload_shaped_devices_inner(&reason, hooks.as_deref()); + let _ = reply.send(result); + } + NetworkDevicesCommand::ReloadNetworkJson { reason, reply } => { + let result = reload_network_json_inner(&reason, hooks.as_deref()); + let _ = reply.send(result); + } + NetworkDevicesCommand::ApplyShapedDevicesSnapshot { + reason, + shaped, + reply, + } => { + debug!("Publishing shaped-devices snapshot reason={reason}"); + state::publish_shaped_devices(*shaped); + if let Some(hooks) = &hooks { + hooks.on_shaped_devices_updated(); + } + let _ = reply.send(Ok(())); + } + NetworkDevicesCommand::ReportObservations { observations } => { + handle_observations(&observations); + } + NetworkDevicesCommand::UpsertDynamicCircuit { + shaped_device, + reply, + } => { + let result = upsert_dynamic_circuit_inner(*shaped_device); + let _ = reply.send(result); + } + NetworkDevicesCommand::RemoveDynamicCircuit { circuit_id, reply } => { + let result = remove_dynamic_circuit_inner(&circuit_id); + let _ = reply.send(result); + } } - let _ = reply.send(Ok(())); } - NetworkDevicesCommand::ReportObservations { observations } => { - handle_observations(&observations); - } - NetworkDevicesCommand::UpsertDynamicCircuit { - shaped_device, - reply, - } => { - let result = upsert_dynamic_circuit_inner(*shaped_device); - let _ = reply.send(result); - } - NetworkDevicesCommand::RemoveDynamicCircuit { circuit_id, reply } => { - let result = remove_dynamic_circuit_inner(&circuit_id); - let _ = reply.send(result); + recv(tick) -> _ => { + prune_expired_dynamic_circuits_inner(hooks.as_deref()); } } } @@ -206,6 +223,65 @@ fn reload_dynamic_circuits_inner(reason: &str) { state::publish_dynamic_circuits_snapshot(circuits); } +fn prune_expired_dynamic_circuits_inner(hooks: Option<&dyn DaemonHooks>) { + let Ok(config) = lqos_config::load_config() else { + return; + }; + let Some(dynamic_cfg) = config.dynamic_circuits.as_ref() else { + return; + }; + if !dynamic_cfg.enabled { + return; + } + let ttl_seconds = dynamic_cfg.ttl_seconds; + if ttl_seconds == 0 { + return; + } + + let Ok(now_unix) = lqos_utils::unix_time::unix_now() else { + return; + }; + + let snapshot = state::dynamic_circuits_snapshot(); + if snapshot.is_empty() { + return; + } + + let mut expired_ids = expired_dynamic_circuit_ids(snapshot.as_ref(), now_unix, ttl_seconds); + if expired_ids.is_empty() { + return; + } + expired_ids.sort(); + expired_ids.dedup(); + + let expired_keys: HashSet = expired_ids + .iter() + .map(|id| normalize_circuit_id_key(id)) + .collect(); + let updated: Vec<_> = snapshot + .iter() + .filter(|circuit| { + let key = normalize_circuit_id_key(&circuit.shaped.circuit_id); + !expired_keys.contains(&key) + }) + .cloned() + .collect(); + + if let Err(err) = persist_dynamic_circuits_to_disk(&updated) { + warn!("Unable to persist dynamic circuits after TTL pruning: {err:?}"); + return; + } + state::publish_dynamic_circuits_snapshot(updated); + debug!( + "Pruned {} expired dynamic circuits ttl_seconds={ttl_seconds}", + expired_ids.len() + ); + + if let Some(hooks) = hooks { + hooks.on_dynamic_circuits_expired(&expired_ids); + } +} + fn upsert_dynamic_circuit_inner(mut shaped_device: lqos_config::ShapedDevice) -> Result<()> { recompute_hashes(&mut shaped_device); let now_unix = lqos_utils::unix_time::unix_now().context("get unix time")?; diff --git a/src/rust/lqos_network_devices/src/dynamic.rs b/src/rust/lqos_network_devices/src/dynamic.rs index 832a5d2e..5a8be8b1 100644 --- a/src/rust/lqos_network_devices/src/dynamic.rs +++ b/src/rust/lqos_network_devices/src/dynamic.rs @@ -24,3 +24,21 @@ pub struct CircuitObservation { pub device_hash: Option, pub circuit_hash: Option, } + +pub(crate) fn expired_dynamic_circuit_ids( + circuits: &[DynamicCircuit], + now_unix: u64, + ttl_seconds: u64, +) -> Vec { + circuits + .iter() + .filter_map(|circuit| { + let circuit_id = circuit.shaped.circuit_id.trim(); + if circuit_id.is_empty() { + return None; + } + let age_seconds = now_unix.saturating_sub(circuit.last_seen_unix); + (age_seconds > ttl_seconds).then_some(circuit_id.to_string()) + }) + .collect() +} diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs index 9b6d424e..aef4265c 100644 --- a/src/rust/lqos_network_devices/src/lib.rs +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -33,6 +33,10 @@ pub trait DaemonHooks: Send + Sync + 'static { fn on_shaped_devices_updated(&self); /// Called after a new `network.json` snapshot is published. fn on_network_json_updated(&self); + /// Called after one or more dynamic circuits are expired and pruned by the runtime actor. + /// + /// Default implementation does nothing so non-daemon callers don't need to handle it. + fn on_dynamic_circuits_expired(&self, _circuit_ids: &[String]) {} } /// Starts the runtime actor and directory watcher used by long-running daemons. diff --git a/src/rust/lqos_network_devices/src/tests.rs b/src/rust/lqos_network_devices/src/tests.rs index a48bff82..afda7f5b 100644 --- a/src/rust/lqos_network_devices/src/tests.rs +++ b/src/rust/lqos_network_devices/src/tests.rs @@ -134,3 +134,56 @@ fn dynamic_circuit_last_seen_updates_for_seen_hashes() { crate::state::publish_dynamic_circuits_snapshot(original.as_ref().clone()); } + +#[test] +fn dynamic_circuit_expiration_helper_respects_ttl_boundary() { + let _guard = TEST_LOCK.lock(); + + let now_unix = 1_000; + let ttl_seconds = 300; + + let mut alive = ShapedDevice::default(); + alive.circuit_id = "alive".into(); + alive.device_id = "device-alive".into(); + + let mut expired = ShapedDevice::default(); + expired.circuit_id = "expired".into(); + expired.device_id = "device-expired".into(); + + let circuits = vec![ + DynamicCircuit { + shaped: alive, + // age == ttl => not expired + last_seen_unix: now_unix - ttl_seconds, + }, + DynamicCircuit { + shaped: expired, + // age > ttl => expired + last_seen_unix: now_unix - ttl_seconds - 1, + }, + ]; + + let mut ids = crate::dynamic::expired_dynamic_circuit_ids(&circuits, now_unix, ttl_seconds); + ids.sort(); + assert_eq!(ids, vec!["expired".to_string()]); +} + +#[test] +fn dynamic_circuit_expiration_helper_expires_zero_last_seen() { + let _guard = TEST_LOCK.lock(); + + let now_unix = 1_000; + let ttl_seconds = 300; + + let mut shaped = ShapedDevice::default(); + shaped.circuit_id = "zero".into(); + shaped.device_id = "device-zero".into(); + + let circuits = vec![DynamicCircuit { + shaped, + last_seen_unix: 0, + }]; + + let ids = crate::dynamic::expired_dynamic_circuit_ids(&circuits, now_unix, ttl_seconds); + assert_eq!(ids, vec!["zero".to_string()]); +} diff --git a/src/rust/lqosd/src/network_devices_hooks.rs b/src/rust/lqosd/src/network_devices_hooks.rs index 5a119508..11d069b9 100644 --- a/src/rust/lqosd/src/network_devices_hooks.rs +++ b/src/rust/lqosd/src/network_devices_hooks.rs @@ -1,5 +1,6 @@ use crate::shaped_devices_tracker; use crate::throughput_tracker::THROUGHPUT_TRACKER; +use tracing::warn; pub(crate) struct LqosdNetworkDevicesHooks; @@ -19,4 +20,22 @@ impl lqos_network_devices::DaemonHooks for LqosdNetworkDevicesHooks { THROUGHPUT_TRACKER.refresh_circuit_ids(net_json); }); } + + fn on_dynamic_circuits_expired(&self, circuit_ids: &[String]) { + let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else { + return; + }; + + for circuit_id in circuit_ids { + let result = sender.send(lqos_bakery::BakeryCommands::RemoveDynamicCircuitOverlay { + circuit_id: circuit_id.clone(), + reply: None, + }); + if let Err(err) = result { + warn!( + "Unable to enqueue dynamic circuit overlay removal for '{circuit_id}': {err}" + ); + } + } + } } From ae91d4b9fc15c2eef61514301fcabc5062fea208 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 11 Apr 2026 12:06:26 -0500 Subject: [PATCH 07/20] feat: promote unknown IPs into dynamic circuits --- src/rust/Cargo.lock | 1 + src/rust/lqos_bakery/src/commands.rs | 2 +- src/rust/lqos_bakery/src/lib.rs | 28 ++- src/rust/lqos_network_devices/Cargo.toml | 1 + src/rust/lqos_network_devices/src/actor.rs | 180 ++++++++++++++++++-- src/rust/lqos_network_devices/src/lib.rs | 4 + src/rust/lqos_network_devices/src/tests.rs | 60 ++++--- src/rust/lqosd/src/dynamic_circuits.rs | 3 +- src/rust/lqosd/src/network_devices_hooks.rs | 125 ++++++++++++++ 9 files changed, 360 insertions(+), 44 deletions(-) diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index c62f5fec..0dd07d96 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2118,6 +2118,7 @@ dependencies = [ "crossbeam-channel", "fxhash", "ip_network", + "ip_network_table", "lqos_config", "lqos_utils", "once_cell", diff --git a/src/rust/lqos_bakery/src/commands.rs b/src/rust/lqos_bakery/src/commands.rs index 0d834ad2..14230ab1 100644 --- a/src/rust/lqos_bakery/src/commands.rs +++ b/src/rust/lqos_bakery/src/commands.rs @@ -240,7 +240,7 @@ pub enum BakeryCommands { shaped_device: Box, /// Optional synchronous reply channel for immediate acceptance reporting. #[allocative(skip)] - reply: Option>>, + reply: Option, String>>>, }, /// Remove a runtime-only dynamic circuit overlay entry by circuit identifier. /// diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs index 03c8a5a0..0cc94345 100644 --- a/src/rust/lqos_bakery/src/lib.rs +++ b/src/rust/lqos_bakery/src/lib.rs @@ -751,7 +751,7 @@ fn handle_upsert_dynamic_circuit_overlay( mq_layout: &Option, qdisc_handles: &mut QdiscHandleState, migrations: &HashMap, -) -> Result<(), String> { +) -> Result, String> { if shaped_device.circuit_id.trim().is_empty() { return Err("dynamic circuit requires circuit_id".to_string()); } @@ -776,7 +776,7 @@ fn handle_upsert_dynamic_circuit_overlay( if batch_in_progress { // Avoid mutating live TC while a full batch is being assembled; the overlay will be // applied when the batch is committed. - return Ok(()); + return Ok(None); } let Ok(config) = lqos_config::load_config() else { @@ -784,18 +784,28 @@ fn handle_upsert_dynamic_circuit_overlay( }; if config.queues.queue_mode.is_observe() { - return Ok(()); + return Ok(None); } // If we don't yet have a baseline MQ layout or any sites, keep the overlay and let the next // commit batch create the circuit. if !MQ_CREATED.load(Ordering::Relaxed) || mq_layout.is_none() || sites.is_empty() { - return Ok(()); + return Ok(None); } // If the circuit is already present, don't attempt to live-update it here yet. - if circuits.contains_key(&circuit_hash) { - return Ok(()); + if let Some(existing) = circuits.get(&circuit_hash) { + if let BakeryCommands::AddCircuit { + class_major, + class_minor, + .. + } = existing.as_ref() + { + return Ok(Some(TcHandle::from_u32( + ((*class_major as u32) << 16) | (*class_minor as u32), + ))); + } + return Ok(None); } let site_hash = runtime_hash_to_i64(&entry.shaped_device.parent_node); @@ -889,7 +899,7 @@ fn handle_upsert_dynamic_circuit_overlay( } let Some(layout) = mq_layout.as_ref() else { - return Ok(()); + return Ok(None); }; let live_reserved_handles = snapshot_live_qdisc_handle_majors_or_empty(&config, "dynamic circuit additions"); @@ -911,7 +921,9 @@ fn handle_upsert_dynamic_circuit_overlay( qdisc_handles.save(&config); update_queue_distribution_snapshot(sites, circuits); - Ok(()) + Ok(Some(TcHandle::from_u32( + ((down_major as u32) << 16) | (class_minor as u32), + ))) } #[allow(clippy::too_many_arguments)] diff --git a/src/rust/lqos_network_devices/Cargo.toml b/src/rust/lqos_network_devices/Cargo.toml index 011a1ba5..f7d1c245 100644 --- a/src/rust/lqos_network_devices/Cargo.toml +++ b/src/rust/lqos_network_devices/Cargo.toml @@ -10,6 +10,7 @@ arc-swap = { workspace = true } crossbeam-channel = { workspace = true } fxhash = "0.2.1" ip_network = { workspace = true } +ip_network_table = { workspace = true } lqos_config = { path = "../lqos_config" } lqos_utils = { path = "../lqos_utils" } once_cell = { workspace = true } diff --git a/src/rust/lqos_network_devices/src/actor.rs b/src/rust/lqos_network_devices/src/actor.rs index ac31b5e2..415305c8 100644 --- a/src/rust/lqos_network_devices/src/actor.rs +++ b/src/rust/lqos_network_devices/src/actor.rs @@ -1,11 +1,13 @@ use crate::dynamic::{CircuitObservation, expired_dynamic_circuit_ids}; use crate::dynamic_store::{load_dynamic_circuits_from_disk, persist_dynamic_circuits_to_disk}; use crate::state; -use crate::{DaemonHooks, load_network_json, load_shaped_devices}; +use crate::{DaemonHooks, ShapedDevicesCatalog, load_network_json, load_shaped_devices}; use anyhow::{Context, Result, anyhow}; use crossbeam_channel::{Receiver, Sender}; +use ip_network_table::IpNetworkTable; use once_cell::sync::OnceCell; use std::collections::HashSet; +use std::net::IpAddr; use std::sync::Arc; use std::time::Duration; use tokio::sync::oneshot; @@ -14,6 +16,7 @@ use tracing::{debug, error, warn}; const RELOAD_RETRY_DELAY_MS: u64 = 500; const RELOAD_ATTEMPTS: usize = 2; const DYNAMIC_CIRCUITS_MAINTENANCE_TICK_SECONDS: u64 = 60; +const MAX_UNKNOWN_IP_PROMOTIONS_PER_OBSERVATION: usize = 32; static ACTOR_SENDER: OnceCell> = OnceCell::new(); @@ -183,7 +186,7 @@ fn actor_loop(rx: Receiver, hooks: Option { - handle_observations(&observations); + handle_observations(&observations, hooks.as_deref()); } NetworkDevicesCommand::UpsertDynamicCircuit { shaped_device, @@ -289,11 +292,15 @@ fn upsert_dynamic_circuit_inner(mut shaped_device: lqos_config::ShapedDevice) -> let snapshot = state::dynamic_circuits_snapshot(); let mut updated = snapshot.as_ref().clone(); let key = normalize_circuit_id_key(&shaped_device.circuit_id); + let mut should_persist = false; if let Some(pos) = updated .iter() .position(|c| normalize_circuit_id_key(&c.shaped.circuit_id) == key) { + if updated[pos].shaped != shaped_device { + should_persist = true; + } updated[pos].shaped = shaped_device; updated[pos].last_seen_unix = now_unix; } else { @@ -301,9 +308,12 @@ fn upsert_dynamic_circuit_inner(mut shaped_device: lqos_config::ShapedDevice) -> shaped: shaped_device, last_seen_unix: now_unix, }); + should_persist = true; } - persist_dynamic_circuits_to_disk(&updated).context("persist dynamic circuits to disk")?; + if should_persist { + persist_dynamic_circuits_to_disk(&updated).context("persist dynamic circuits to disk")?; + } state::publish_dynamic_circuits_snapshot(updated); Ok(()) } @@ -330,7 +340,7 @@ fn remove_dynamic_circuit_inner(circuit_id: &str) -> Result { Ok(true) } -fn handle_observations(observations: &[CircuitObservation]) { +fn handle_observations(observations: &[CircuitObservation], hooks: Option<&dyn DaemonHooks>) { if observations.is_empty() { return; } @@ -386,18 +396,168 @@ fn handle_observations(observations: &[CircuitObservation]) { } if !unknown_candidates.is_empty() { - // TODO(dynamic-circuits): This is where unknown observations will be evaluated - // and potentially turned into dynamic circuit overlay entries. - // - // Unknown candidates are kernel observations whose hashes do not match static shaped - // devices and also do not match any existing dynamic circuits. + maybe_promote_unknown_ips(&unknown_candidates, &catalog, hooks); + } +} + +fn maybe_promote_unknown_ips( + unknown_candidates: &[CircuitObservation], + catalog: &ShapedDevicesCatalog, + hooks: Option<&dyn DaemonHooks>, +) { + let Ok(config) = lqos_config::load_config() else { + return; + }; + + let Some(dynamic_cfg) = config.dynamic_circuits.as_ref() else { + return; + }; + if !dynamic_cfg.enabled || !dynamic_cfg.enable_unknown_ip_promotion { + return; + } + if dynamic_cfg.ranges.is_empty() { + return; + } + + let mut trie: IpNetworkTable = IpNetworkTable::new(); + for (idx, rule) in dynamic_cfg.ranges.iter().enumerate() { + trie.insert(rule.ip_range, idx); + } + + let mut promoted = 0usize; + for observation in unknown_candidates { + if promoted >= MAX_UNKNOWN_IP_PROMOTIONS_PER_OBSERVATION { + break; + } + + // Never promote an IP that is already covered by static shaped devices. + if catalog + .device_longest_match_for_ip(&observation.ip) + .is_some() + { + continue; + } + + let ip: IpAddr = observation.ip.as_ip(); + let Some((_net, rule_idx)) = trie.longest_match(ip) else { + continue; + }; + let Some(rule) = dynamic_cfg.ranges.get(*rule_idx) else { + continue; + }; + + let label = format!("[dyn] ({}) {}", rule.name.trim(), observation.ip); + let parent_node = resolve_unknown_promotion_parent(rule.attach_to.trim()); + if parent_node.trim().is_empty() { + continue; + } + + let shaped_device = shaped_device_from_unknown_ip(&label, &parent_node, ip, rule); + if let Err(err) = upsert_dynamic_circuit_inner(shaped_device.clone()) { + warn!( + "Unable to persist dynamic circuit from unknown IP promotion for {}: {err:?}", + observation.ip + ); + continue; + } + + if let Some(hooks) = hooks { + hooks.on_unknown_ip_promoted(&shaped_device); + } + + promoted += 1; + } + + if promoted > 0 { debug!( - "Received {} unknown circuit observations (dynamic circuit auto-create not implemented yet)", + "Promoted {promoted} unknown IP(s) into dynamic circuits (candidates={})", unknown_candidates.len() ); } } +fn resolve_unknown_promotion_parent(attach_to: &str) -> String { + let trimmed_attach = attach_to.trim(); + if !trimmed_attach.is_empty() { + if let Some(resolved) = crate::resolve_parent_node(trimmed_attach) { + return resolved.name; + } + + // In "flat" setups (no meaningful `network.json`), attachments are just site-name strings. + let is_flat = state::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let has_non_root = nodes + .iter() + .any(|node| node.name.trim() != "Root" && !node.name.trim().is_empty()); + nodes.len() <= 1 || !has_non_root + }); + if is_flat { + return trimmed_attach.to_string(); + } + } + + let (node_len, orphans, first_non_root) = state::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let first_non_root = nodes + .iter() + .find(|node| node.name.trim() != "Root" && !node.name.trim().is_empty()) + .map(|node| node.name.clone()); + let orphans = nodes + .iter() + .find(|node| node.name.trim().eq_ignore_ascii_case("ORPHANS")) + .map(|node| node.name.clone()); + + (nodes.len(), orphans, first_non_root) + }); + // "Flat" means `network.json` had no meaningful tree beyond Root. + let is_flat = node_len <= 1 || first_non_root.is_none(); + if !is_flat { + if let Some(orphans) = orphans { + return orphans; + } + if let Some(first) = first_non_root { + return first; + } + } + + let shaped_parent = state::shaped_devices_snapshot() + .devices + .iter() + .find_map(|dev| { + let trimmed = dev.parent_node.trim(); + (!trimmed.is_empty()).then_some(trimmed.to_string()) + }); + + shaped_parent.unwrap_or_else(|| "Root".to_string()) +} + +fn shaped_device_from_unknown_ip( + label: &str, + parent_node: &str, + ip: IpAddr, + rule: &lqos_config::DynamicCircuitRangeRule, +) -> lqos_config::ShapedDevice { + let mut device = lqos_config::ShapedDevice { + circuit_id: label.to_string(), + circuit_name: label.to_string(), + device_id: label.to_string(), + device_name: label.to_string(), + parent_node: parent_node.to_string(), + download_min_mbps: rule.download_min_mbps, + upload_min_mbps: rule.upload_min_mbps, + download_max_mbps: rule.download_max_mbps, + upload_max_mbps: rule.upload_max_mbps, + ..Default::default() + }; + + match ip { + IpAddr::V4(ip) => device.ipv4.push((ip, 32)), + IpAddr::V6(ip) => device.ipv6.push((ip, 128)), + } + + device +} + fn reload_shaped_devices_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> { for attempt in 1..=RELOAD_ATTEMPTS { match load_shaped_devices() { diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs index aef4265c..4d8c4f58 100644 --- a/src/rust/lqos_network_devices/src/lib.rs +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -37,6 +37,10 @@ pub trait DaemonHooks: Send + Sync + 'static { /// /// Default implementation does nothing so non-daemon callers don't need to handle it. fn on_dynamic_circuits_expired(&self, _circuit_ids: &[String]) {} + /// Called when the runtime actor promotes an unknown IP into a dynamic circuit overlay entry. + /// + /// Default implementation does nothing so non-daemon callers don't need to handle it. + fn on_unknown_ip_promoted(&self, _shaped_device: &lqos_config::ShapedDevice) {} } /// Starts the runtime actor and directory watcher used by long-running daemons. diff --git a/src/rust/lqos_network_devices/src/tests.rs b/src/rust/lqos_network_devices/src/tests.rs index afda7f5b..a1d5801a 100644 --- a/src/rust/lqos_network_devices/src/tests.rs +++ b/src/rust/lqos_network_devices/src/tests.rs @@ -46,17 +46,21 @@ fn make_node( fn catalog_device_by_hashes_prefers_device_hash() { let _guard = TEST_LOCK.lock(); - let mut a = ShapedDevice::default(); - a.circuit_id = "circuit-a".into(); - a.device_id = "device-a".into(); - a.circuit_hash = 10; - a.device_hash = 100; + let a = ShapedDevice { + circuit_id: "circuit-a".into(), + device_id: "device-a".into(), + circuit_hash: 10, + device_hash: 100, + ..Default::default() + }; - let mut b = ShapedDevice::default(); - b.circuit_id = "circuit-b".into(); - b.device_id = "device-b".into(); - b.circuit_hash = 20; - b.device_hash = 200; + let b = ShapedDevice { + circuit_id: "circuit-b".into(), + device_id: "device-b".into(), + circuit_hash: 20, + device_hash: 200, + ..Default::default() + }; let mut shaped = ConfigShapedDevices::default(); shaped.replace_with_new_data(vec![a.clone(), b.clone()]); @@ -105,11 +109,13 @@ fn dynamic_circuit_last_seen_updates_for_seen_hashes() { let original = crate::state::dynamic_circuits_snapshot(); - let mut shaped = ShapedDevice::default(); - shaped.circuit_id = "dynamic-circuit".into(); - shaped.device_id = "dynamic-device".into(); - shaped.circuit_hash = 10; - shaped.device_hash = 100; + let shaped = ShapedDevice { + circuit_id: "dynamic-circuit".into(), + device_id: "dynamic-device".into(), + circuit_hash: 10, + device_hash: 100, + ..Default::default() + }; crate::state::publish_dynamic_circuits_snapshot(vec![DynamicCircuit { shaped: shaped.clone(), @@ -142,13 +148,17 @@ fn dynamic_circuit_expiration_helper_respects_ttl_boundary() { let now_unix = 1_000; let ttl_seconds = 300; - let mut alive = ShapedDevice::default(); - alive.circuit_id = "alive".into(); - alive.device_id = "device-alive".into(); + let alive = ShapedDevice { + circuit_id: "alive".into(), + device_id: "device-alive".into(), + ..Default::default() + }; - let mut expired = ShapedDevice::default(); - expired.circuit_id = "expired".into(); - expired.device_id = "device-expired".into(); + let expired = ShapedDevice { + circuit_id: "expired".into(), + device_id: "device-expired".into(), + ..Default::default() + }; let circuits = vec![ DynamicCircuit { @@ -175,9 +185,11 @@ fn dynamic_circuit_expiration_helper_expires_zero_last_seen() { let now_unix = 1_000; let ttl_seconds = 300; - let mut shaped = ShapedDevice::default(); - shaped.circuit_id = "zero".into(); - shaped.device_id = "device-zero".into(); + let shaped = ShapedDevice { + circuit_id: "zero".into(), + device_id: "device-zero".into(), + ..Default::default() + }; let circuits = vec![DynamicCircuit { shaped, diff --git a/src/rust/lqosd/src/dynamic_circuits.rs b/src/rust/lqosd/src/dynamic_circuits.rs index c19c4097..4a8664a5 100644 --- a/src/rust/lqosd/src/dynamic_circuits.rs +++ b/src/rust/lqosd/src/dynamic_circuits.rs @@ -71,7 +71,7 @@ fn upsert_bakery_overlay(shaped_device: ShapedDevice) -> Result<(), String> { return Err("Bakery not initialized".to_string()); }; - let (tx, rx) = mpsc::channel::>(); + let (tx, rx) = mpsc::channel::, String>>(); sender .send(lqos_bakery::BakeryCommands::UpsertDynamicCircuitOverlay { shaped_device: Box::new(shaped_device), @@ -81,6 +81,7 @@ fn upsert_bakery_overlay(shaped_device: ShapedDevice) -> Result<(), String> { rx.recv_timeout(Duration::from_secs(10)) .map_err(|e| format!("bakery reply timeout: {e}"))? + .map(|_| ()) } fn remove_bakery_overlay(circuit_id: &str) -> Result<(), String> { diff --git a/src/rust/lqosd/src/network_devices_hooks.rs b/src/rust/lqosd/src/network_devices_hooks.rs index 11d069b9..9d90817d 100644 --- a/src/rust/lqosd/src/network_devices_hooks.rs +++ b/src/rust/lqosd/src/network_devices_hooks.rs @@ -1,5 +1,9 @@ use crate::shaped_devices_tracker; use crate::throughput_tracker::THROUGHPUT_TRACKER; +use lqos_bus::TcHandle; +use lqos_config::ShapedDevice; +use std::sync::mpsc; +use std::time::Duration; use tracing::warn; pub(crate) struct LqosdNetworkDevicesHooks; @@ -38,4 +42,125 @@ impl lqos_network_devices::DaemonHooks for LqosdNetworkDevicesHooks { } } } + + fn on_unknown_ip_promoted(&self, shaped_device: &ShapedDevice) { + let shaped_device = shaped_device.clone(); + let circuit_id = shaped_device.circuit_id.clone(); + + let result = std::thread::Builder::new() + .name("dyn-circuit-unknown-ip".to_string()) + .spawn(move || { + let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else { + return; + }; + + let (tx, rx) = mpsc::channel::, String>>(); + if let Err(err) = + sender.send(lqos_bakery::BakeryCommands::UpsertDynamicCircuitOverlay { + shaped_device: Box::new(shaped_device.clone()), + reply: Some(tx), + }) + { + warn!( + "Unable to enqueue dynamic circuit overlay for unknown IP promotion '{}': {err}", + shaped_device.circuit_id + ); + return; + } + + let handle = match rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(Some(handle))) => handle, + Ok(Ok(None)) => { + // Bakery accepted the overlay but could not yet allocate a concrete class + // handle (e.g., baseline not ready). We'll retry on future observations. + return; + } + Ok(Err(err)) => { + warn!( + "Bakery rejected dynamic circuit overlay for unknown IP promotion '{}': {err}", + shaped_device.circuit_id + ); + return; + } + Err(err) => { + warn!( + "Timeout waiting for Bakery reply while promoting unknown IP '{}': {err}", + shaped_device.circuit_id + ); + return; + } + }; + + let circuit_hash = if shaped_device.circuit_hash != 0 { + shaped_device.circuit_hash + } else { + lqos_utils::hash_to_i64(&shaped_device.circuit_id) + }; + let device_hash = if shaped_device.device_hash != 0 { + shaped_device.device_hash + } else { + lqos_utils::hash_to_i64(&shaped_device.device_id) + }; + + let cpu_count = lqos_sys::num_possible_cpus() + .map(|n| n.max(1)) + .unwrap_or(1); + let cpu = ((circuit_hash as u64) % (cpu_count as u64)) as u32; + + for (ip, prefix) in shaped_device.ipv4.iter() { + let addr = if *prefix == 32 { + ip.to_string() + } else { + format!("{ip}/{prefix}") + }; + if let Err(err) = lqos_sys::add_ip_to_tc( + &addr, + handle, + cpu, + false, + circuit_hash as u64, + device_hash as u64, + ) { + warn!( + "Unable to map unknown IP dynamic circuit '{}' for {addr}: {err:?}", + shaped_device.circuit_id + ); + } + } + for (ip, prefix) in shaped_device.ipv6.iter() { + let addr = if *prefix == 128 { + ip.to_string() + } else { + format!("{ip}/{prefix}") + }; + if let Err(err) = lqos_sys::add_ip_to_tc( + &addr, + handle, + cpu, + false, + circuit_hash as u64, + device_hash as u64, + ) { + warn!( + "Unable to map unknown IP dynamic circuit '{}' for {addr}: {err:?}", + shaped_device.circuit_id + ); + } + } + + if let Err(err) = lqos_sys::clear_hot_cache() { + warn!( + "Unable to clear hot cache after mapping unknown IP dynamic circuit '{}': {err:?}", + shaped_device.circuit_id + ); + } + }); + + if let Err(err) = result { + warn!( + "Unable to spawn unknown IP promotion helper thread for '{}': {err}", + circuit_id + ); + } + } } From bdf7344d0b39d92d2980dc6d36c31c091e3be19a Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 11 Apr 2026 12:18:16 -0500 Subject: [PATCH 08/20] fix: drop dynamic circuits superseded by ShapedDevices --- src/rust/lqos_network_devices/src/actor.rs | 66 +++++++++++++++++++- src/rust/lqos_network_devices/src/dynamic.rs | 31 +++++++++ src/rust/lqos_network_devices/src/tests.rs | 36 +++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/rust/lqos_network_devices/src/actor.rs b/src/rust/lqos_network_devices/src/actor.rs index 415305c8..6b6f3069 100644 --- a/src/rust/lqos_network_devices/src/actor.rs +++ b/src/rust/lqos_network_devices/src/actor.rs @@ -1,4 +1,6 @@ -use crate::dynamic::{CircuitObservation, expired_dynamic_circuit_ids}; +use crate::dynamic::{ + CircuitObservation, expired_dynamic_circuit_ids, is_superseded_by_shaped_devices, +}; use crate::dynamic_store::{load_dynamic_circuits_from_disk, persist_dynamic_circuits_to_disk}; use crate::state; use crate::{DaemonHooks, ShapedDevicesCatalog, load_network_json, load_shaped_devices}; @@ -151,6 +153,7 @@ fn actor_loop(rx: Receiver, hooks: Option, hooks: Option { debug!("Publishing shaped-devices snapshot reason={reason}"); state::publish_shaped_devices(*shaped); + prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks.as_deref()); if let Some(hooks) = &hooks { hooks.on_shaped_devices_updated(); } @@ -564,6 +568,7 @@ fn reload_shaped_devices_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Ok(shaped) => { debug!("Loaded shaped devices reason={reason}"); state::publish_shaped_devices(shaped); + prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks); if let Some(hooks) = hooks { hooks.on_shaped_devices_updated(); } @@ -589,6 +594,65 @@ fn reload_shaped_devices_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> unreachable!("reload loop must return"); } +fn prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks: Option<&dyn DaemonHooks>) { + let catalog = state::shaped_devices_catalog(); + let snapshot = state::dynamic_circuits_snapshot(); + if snapshot.is_empty() { + return; + } + + let mut removed_ids: Vec = Vec::new(); + let mut removed_keys: HashSet = HashSet::new(); + + for circuit in snapshot.iter() { + if !is_superseded_by_shaped_devices(circuit, &catalog) { + continue; + } + + let circuit_id = circuit.shaped.circuit_id.trim(); + if circuit_id.is_empty() { + continue; + } + let key = normalize_circuit_id_key(circuit_id); + if removed_keys.insert(key) { + removed_ids.push(circuit_id.to_string()); + } + } + + if removed_keys.is_empty() { + return; + } + + removed_ids.sort(); + removed_ids.dedup(); + + let updated: Vec<_> = snapshot + .iter() + .filter(|circuit| { + let circuit_id = circuit.shaped.circuit_id.trim(); + if circuit_id.is_empty() { + return true; + } + let key = normalize_circuit_id_key(circuit_id); + !removed_keys.contains(&key) + }) + .cloned() + .collect(); + + if let Err(err) = persist_dynamic_circuits_to_disk(&updated) { + warn!("Unable to persist dynamic circuits after shaped-devices conflict prune: {err:?}"); + } + state::publish_dynamic_circuits_snapshot(updated); + debug!( + "Pruned {} dynamic circuits superseded by ShapedDevices.csv changes", + removed_ids.len() + ); + + if let Some(hooks) = hooks { + hooks.on_dynamic_circuits_expired(&removed_ids); + } +} + fn reload_network_json_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> { for attempt in 1..=RELOAD_ATTEMPTS { match load_network_json() { diff --git a/src/rust/lqos_network_devices/src/dynamic.rs b/src/rust/lqos_network_devices/src/dynamic.rs index 5a8be8b1..a9c29348 100644 --- a/src/rust/lqos_network_devices/src/dynamic.rs +++ b/src/rust/lqos_network_devices/src/dynamic.rs @@ -1,6 +1,7 @@ use lqos_config::ShapedDevice; use lqos_utils::XdpIpAddress; use serde::{Deserialize, Serialize}; +use std::net::IpAddr; /// Runtime-only dynamic circuit overlay entry. /// @@ -25,6 +26,36 @@ pub struct CircuitObservation { pub circuit_hash: Option, } +pub(crate) fn is_superseded_by_shaped_devices( + circuit: &DynamicCircuit, + shaped_devices: &crate::ShapedDevicesCatalog, +) -> bool { + if shaped_devices + .device_by_hashes( + Some(circuit.shaped.device_hash), + Some(circuit.shaped.circuit_hash), + ) + .is_some() + { + return true; + } + + for (ip, _) in circuit.shaped.ipv4.iter() { + let probe = XdpIpAddress::from_ip(IpAddr::V4(*ip)); + if shaped_devices.device_longest_match_for_ip(&probe).is_some() { + return true; + } + } + for (ip, _) in circuit.shaped.ipv6.iter() { + let probe = XdpIpAddress::from_ip(IpAddr::V6(*ip)); + if shaped_devices.device_longest_match_for_ip(&probe).is_some() { + return true; + } + } + + false +} + pub(crate) fn expired_dynamic_circuit_ids( circuits: &[DynamicCircuit], now_unix: u64, diff --git a/src/rust/lqos_network_devices/src/tests.rs b/src/rust/lqos_network_devices/src/tests.rs index a1d5801a..4fe375bb 100644 --- a/src/rust/lqos_network_devices/src/tests.rs +++ b/src/rust/lqos_network_devices/src/tests.rs @@ -7,6 +7,7 @@ use lqos_utils::units::DownUpOrder; use once_cell::sync::Lazy; use parking_lot::Mutex; use std::collections::HashSet; +use std::net::Ipv4Addr; use std::sync::Arc; static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); @@ -199,3 +200,38 @@ fn dynamic_circuit_expiration_helper_expires_zero_last_seen() { let ids = crate::dynamic::expired_dynamic_circuit_ids(&circuits, now_unix, ttl_seconds); assert_eq!(ids, vec!["zero".to_string()]); } + +#[test] +fn dynamic_circuit_is_superseded_when_shaped_devices_now_cover_it() { + let _guard = TEST_LOCK.lock(); + + let mut static_device = ShapedDevice { + circuit_id: "static".into(), + device_id: "static-device".into(), + ..Default::default() + }; + static_device.ipv4.push((Ipv4Addr::new(192, 0, 2, 42), 32)); + + let mut shaped = ConfigShapedDevices::default(); + shaped.replace_with_new_data(vec![static_device]); + let catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped)); + + let mut dynamic_shaped = ShapedDevice { + circuit_id: "[dyn] (test) 192.0.2.42".into(), + device_id: "[dyn] (test) 192.0.2.42".into(), + circuit_hash: 123, + device_hash: 456, + ..Default::default() + }; + dynamic_shaped.ipv4.push((Ipv4Addr::new(192, 0, 2, 42), 32)); + + let dyn_circuit = DynamicCircuit { + shaped: dynamic_shaped, + last_seen_unix: 0, + }; + + assert!(crate::dynamic::is_superseded_by_shaped_devices( + &dyn_circuit, + &catalog + )); +} From 06e70c32917587ebbecf5878c5e61b52f7522fd8 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 11 Apr 2026 13:12:23 -0500 Subject: [PATCH 09/20] feat: include dynamic circuits in node manager, lqtop, treeguard --- .../src/combined_catalog.rs | 117 ++++++++++ src/rust/lqos_network_devices/src/lib.rs | 10 + .../js_build/src/shaped-devices.js | 61 ++++- .../src/node_manager/local_api/circuit.rs | 7 + .../local_api/circuit_activity.rs | 6 +- .../node_manager/local_api/circuit_count.rs | 11 +- .../src/node_manager/local_api/directories.rs | 8 +- .../node_manager/local_api/executive_cache.rs | 12 +- .../src/node_manager/local_api/search.rs | 218 ++++++++++++++++++ .../local_api/shaped_devices_page.rs | 81 ++++--- .../node_manager/static2/shaped_devices.html | 20 +- src/rust/lqosd/src/node_manager/template.rs | 12 +- .../ws/ticker/circuit_capacity.rs | 4 +- .../ws/ticker/ipstats_conversion.rs | 4 +- .../shaped_devices_tracker/circuit_live.rs | 2 +- .../lqosd/src/shaped_devices_tracker/mod.rs | 14 +- src/rust/lqosd/src/throughput_tracker/mod.rs | 23 +- .../src/throughput_tracker/tracking_data.rs | 44 ++-- src/rust/lqosd/src/treeguard/actor.rs | 75 ++++-- src/rust/lqtop/src/widgets/top_flows.rs | 19 ++ src/rust/lqtop/src/widgets/top_hosts.rs | 15 ++ 21 files changed, 651 insertions(+), 112 deletions(-) create mode 100644 src/rust/lqos_network_devices/src/combined_catalog.rs diff --git a/src/rust/lqos_network_devices/src/combined_catalog.rs b/src/rust/lqos_network_devices/src/combined_catalog.rs new file mode 100644 index 00000000..8d822489 --- /dev/null +++ b/src/rust/lqos_network_devices/src/combined_catalog.rs @@ -0,0 +1,117 @@ +use crate::{DynamicCircuit, ShapedDevicesCatalog}; +use fxhash::FxHashMap; +use lqos_config::ShapedDevice; +use std::sync::Arc; + +fn normalize_circuit_id_key(circuit_id: &str) -> String { + circuit_id.trim().to_ascii_lowercase() +} + +/// Snapshot handle for shaped devices plus runtime dynamic circuits. +/// +/// This catalog is intended for read-heavy paths (dashboards, APIs) that need to +/// treat dynamic circuits as first-class circuits alongside `ShapedDevices.csv`. +#[derive(Clone)] +pub struct NetworkDevicesCatalog { + shaped: ShapedDevicesCatalog, + dynamic: Arc>, + dyn_by_device_hash: FxHashMap, + dyn_by_circuit_hash: FxHashMap, + dyn_by_circuit_id: FxHashMap, +} + +impl NetworkDevicesCatalog { + pub(crate) fn from_snapshots( + shaped: ShapedDevicesCatalog, + dynamic: Arc>, + ) -> Self { + let mut dyn_by_device_hash = FxHashMap::default(); + let mut dyn_by_circuit_hash = FxHashMap::default(); + let mut dyn_by_circuit_id = FxHashMap::default(); + + for (idx, circuit) in dynamic.iter().enumerate() { + dyn_by_device_hash.insert(circuit.shaped.device_hash, idx); + dyn_by_circuit_hash.insert(circuit.shaped.circuit_hash, idx); + dyn_by_circuit_id.insert(normalize_circuit_id_key(&circuit.shaped.circuit_id), idx); + } + + Self { + shaped, + dynamic, + dyn_by_device_hash, + dyn_by_circuit_hash, + dyn_by_circuit_id, + } + } + + /// Returns the underlying static shaped-devices catalog (`ShapedDevices.csv`). + pub fn shaped_devices(&self) -> &ShapedDevicesCatalog { + &self.shaped + } + + /// Returns the dynamic circuit overlay snapshot. + pub fn dynamic_circuits(&self) -> &[DynamicCircuit] { + self.dynamic.as_ref() + } + + /// Iterates over static shaped-device rows (`ShapedDevices.csv`). + pub fn iter_static_devices(&self) -> impl Iterator { + self.shaped.iter_devices() + } + + /// Iterates over dynamic circuit overlay entries as shaped-device rows. + pub fn iter_dynamic_devices(&self) -> impl Iterator { + self.dynamic.iter().map(|circuit| &circuit.shaped) + } + + /// Iterates over both static and dynamic shaped-device rows. + pub fn iter_all_devices(&self) -> impl Iterator { + self.iter_static_devices().chain(self.iter_dynamic_devices()) + } + + /// Returns true if the device hash is currently tracked as a dynamic circuit. + pub fn is_dynamic_device_hash(&self, device_hash: i64) -> bool { + self.dyn_by_device_hash.contains_key(&device_hash) + } + + /// Returns true if the circuit hash is currently tracked as a dynamic circuit. + pub fn is_dynamic_circuit_hash(&self, circuit_hash: i64) -> bool { + self.dyn_by_circuit_hash.contains_key(&circuit_hash) + } + + /// Looks up a shaped device using optional hashes, preferring static shaped devices. + /// + /// When the hashes are not present in `ShapedDevices.csv`, this falls back to the + /// runtime dynamic circuit overlay snapshot. + pub fn device_by_hashes( + &self, + device_hash: Option, + circuit_hash: Option, + ) -> Option<&ShapedDevice> { + if let Some(device) = self.shaped.device_by_hashes(device_hash, circuit_hash) { + return Some(device); + } + + if let Some(device_hash) = device_hash + && let Some(idx) = self.dyn_by_device_hash.get(&device_hash) + { + return self.dynamic.get(*idx).map(|circuit| &circuit.shaped); + } + + if let Some(circuit_hash) = circuit_hash + && let Some(idx) = self.dyn_by_circuit_hash.get(&circuit_hash) + { + return self.dynamic.get(*idx).map(|circuit| &circuit.shaped); + } + + None + } + + /// Looks up a dynamic circuit overlay entry by circuit id. + pub fn dynamic_device_by_circuit_id(&self, circuit_id: &str) -> Option<&ShapedDevice> { + let key = normalize_circuit_id_key(circuit_id); + let idx = self.dyn_by_circuit_id.get(&key)?; + self.dynamic.get(*idx).map(|circuit| &circuit.shaped) + } +} + diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs index 4d8c4f58..6066523a 100644 --- a/src/rust/lqos_network_devices/src/lib.rs +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -7,6 +7,7 @@ mod actor; mod catalog; +mod combined_catalog; mod directory_watcher; mod dynamic; mod dynamic_store; @@ -23,6 +24,7 @@ use std::sync::Arc; use tracing::debug; pub use catalog::{CircuitRateCaps, ShapedDevicesCatalog}; +pub use combined_catalog::NetworkDevicesCatalog; pub use dynamic::{CircuitObservation, DynamicCircuit}; pub use hash_cache::ShapedDeviceHashCache; pub use topology::{ResolvedParentNode, resolve_parent_node, resolve_parent_node_reference}; @@ -76,6 +78,14 @@ pub fn shaped_devices_catalog() -> ShapedDevicesCatalog { state::shaped_devices_catalog() } +/// Returns a combined catalog snapshot for both shaped devices and dynamic circuits. +pub fn network_devices_catalog() -> NetworkDevicesCatalog { + NetworkDevicesCatalog::from_snapshots( + state::shaped_devices_catalog(), + state::dynamic_circuits_snapshot(), + ) +} + /// Returns the current in-memory shaped-device hash cache snapshot. pub fn shaped_device_hash_cache_snapshot() -> Arc { state::shaped_device_hash_cache_snapshot() diff --git a/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js b/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js index 049ca1aa..f485de9b 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js @@ -13,6 +13,7 @@ let shapedDevices = []; let devicesPerPage = 24; let page = 0; let searchTerm = ""; +let shapedDevicesKind = "Static"; let metricElsByCircuitId = new Map(); const latestByCircuitId = new Map(); const planByCircuitId = new Map(); @@ -78,6 +79,52 @@ function countCircuits() { return totalCircuits; } +function updateKindTabs() { + const staticTab = document.getElementById("sdTabStatic"); + const dynamicTab = document.getElementById("sdTabDynamic"); + if (!staticTab || !dynamicTab) { + return; + } + const isDynamic = shapedDevicesKind === "Dynamic"; + staticTab.classList.toggle("active", !isDynamic); + dynamicTab.classList.toggle("active", isDynamic); + staticTab.setAttribute("aria-selected", (!isDynamic).toString()); + dynamicTab.setAttribute("aria-selected", isDynamic.toString()); +} + +function renderCounts() { + const countEl = $("#count"); + const circuitEl = $("#countCircuit"); + if (shapedDevicesKind === "Dynamic") { + countEl.text(totalRows + " dynamic circuits"); + circuitEl.text(""); + circuitEl.addClass("d-none"); + } else { + countEl.text(totalRows + " devices"); + circuitEl.text(totalCircuits + " circuits"); + circuitEl.removeClass("d-none"); + } +} + +function setShapedDevicesKind(kind) { + if (!kind || (kind !== "Static" && kind !== "Dynamic")) { + kind = "Static"; + } + if (shapedDevicesKind === kind) { + return; + } + shapedDevicesKind = kind; + page = 0; + searchTerm = ""; + const searchBox = document.getElementById("sdSearch"); + if (searchBox) { + searchBox.value = ""; + } + circuitMetricsWatchSignature = null; + updateKindTabs(); + requestShapedDevicesPage(); +} + function filterDevices() { page = 0; requestShapedDevicesPage(); @@ -550,6 +597,7 @@ function currentShapedDevicesPageQuery() { const query = { page, page_size: devicesPerPage, + kind: shapedDevicesKind, }; if (searchTerm && searchTerm.trim() !== "") { query.search = searchTerm; @@ -610,13 +658,13 @@ async function requestShapedDevicesPage() { }); renderCards(); requestCircuitMetricsWatch(true); - $("#count").text(totalRows + " devices"); - $("#countCircuit").text(totalCircuits + " circuits"); + renderCounts(); } catch (_error) { shapedDevices = []; totalRows = 0; totalCircuits = 0; renderCards(); + renderCounts(); } } @@ -676,7 +724,16 @@ function handleCircuitMetrics(msg) { wsClient.on("CircuitMetricsSnapshot", handleCircuitMetrics); wsClient.on("CircuitMetricsUpdate", handleCircuitMetrics); wsClient.on("join", () => { + updateKindTabs(); requestShapedDevicesPage(); }); +document.getElementById("sdTabStatic")?.addEventListener("click", () => { + setShapedDevicesKind("Static"); +}); +document.getElementById("sdTabDynamic")?.addEventListener("click", () => { + setShapedDevicesKind("Dynamic"); +}); + +updateKindTabs(); requestShapedDevicesPage(); diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit.rs b/src/rust/lqosd/src/node_manager/local_api/circuit.rs index 695587c2..4f86d122 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit.rs @@ -75,6 +75,13 @@ pub fn circuit_by_id_data(id: &str) -> Option { let catalog = lqos_network_devices::shaped_devices_catalog(); let mut devices: Vec = catalog.devices_for_circuit_id(&safe_id); + if devices.is_empty() { + let catalog = lqos_network_devices::network_devices_catalog(); + if let Some(device) = catalog.dynamic_device_by_circuit_id(&safe_id) { + devices.push(device.clone()); + } + } + if devices.is_empty() { None } else { diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs index f16e2ab9..b383cf86 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs @@ -163,13 +163,13 @@ fn sanitized_plan_ceiling_bps(plan_mbps: f32) -> u32 { } fn circuit_display_rate_ceiling_bps( - catalog: &lqos_network_devices::ShapedDevicesCatalog, + catalog: &lqos_network_devices::NetworkDevicesCatalog, circuit_hash: i64, ) -> Option> { let mut max_down_mbps = 0.0_f32; let mut max_up_mbps = 0.0_f32; - for device in catalog.iter_devices() { + for device in catalog.iter_all_devices() { if device.circuit_hash != circuit_hash { continue; } @@ -219,7 +219,7 @@ fn flow_qoo(local: &FlowbeeLocalData) -> DownUpOrder> { fn flow_snapshot_rows(circuit_id: &str) -> Vec { let circuit_hash = hash_to_i64(circuit_id); - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let display_rate_ceiling = circuit_display_rate_ceiling_bps(&catalog, circuit_hash); let Ok(now) = time_since_boot() else { return Vec::new(); diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs index 8070438e..77edc861 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs @@ -34,9 +34,14 @@ pub fn circuit_count_data() -> CircuitCount { .filter_map(|(_k, d)| d.circuit_id.clone()) .collect(); - // Get configured circuits from ShapedDevices - let configured_count = - lqos_network_devices::shaped_devices_catalog().configured_circuit_count(); + // Get configured circuits from ShapedDevices + dynamic overlay + let catalog = lqos_network_devices::network_devices_catalog(); + let configured_count = catalog + .iter_all_devices() + .map(|device| device.circuit_id.trim()) + .filter(|id| !id.is_empty()) + .collect::>() + .len(); // Use active count if > 0, otherwise fall back to configured count let count = if !active_circuits.is_empty() { diff --git a/src/rust/lqosd/src/node_manager/local_api/directories.rs b/src/rust/lqosd/src/node_manager/local_api/directories.rs index 16197a70..b66534f5 100644 --- a/src/rust/lqosd/src/node_manager/local_api/directories.rs +++ b/src/rust/lqosd/src/node_manager/local_api/directories.rs @@ -84,10 +84,10 @@ pub fn circuit_directory_page(query: CircuitDirectoryQuery) -> CircuitDirectoryP let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut circuits: BTreeMap = BTreeMap::new(); - for device in catalog.iter_devices() { + for device in catalog.iter_all_devices() { let circuit_id = device.circuit_id.trim().to_string(); if circuit_id.is_empty() { continue; @@ -209,10 +209,10 @@ pub fn treeguard_metadata_summary() -> TreeGuardMetadataSummary { }) .count(); - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut circuit_ids = BTreeSet::new(); let mut fq_codel_circuit_ids = BTreeSet::new(); - for device in catalog.iter_devices() { + for device in catalog.iter_all_devices() { let circuit_id = device.circuit_id.trim().to_string(); if circuit_id.is_empty() { continue; diff --git a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs index 9793eb00..ae7f80b5 100644 --- a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs +++ b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs @@ -686,8 +686,18 @@ fn entity_snapshots( fn build_oversubscribed_sites( entities: &[ExecutiveEntitySnapshot], ) -> Vec { - let circuit_caps = + let mut circuit_caps = lqos_network_devices::shaped_devices_catalog().circuit_rate_caps_by_circuit_id(); + for circuit in lqos_network_devices::dynamic_circuits_snapshot().iter() { + let shaped = &circuit.shaped; + circuit_caps.entry(shaped.circuit_id.clone()).or_insert_with(|| { + lqos_network_devices::CircuitRateCaps { + parent_node: shaped.parent_node.clone(), + download_max_mbps: shaped.download_max_mbps, + upload_max_mbps: shaped.upload_max_mbps, + } + }); + } let entity_map = entities .iter() diff --git a/src/rust/lqosd/src/node_manager/local_api/search.rs b/src/rust/lqosd/src/node_manager/local_api/search.rs index 81a90e51..b1cb6ca0 100644 --- a/src/rust/lqosd/src/node_manager/local_api/search.rs +++ b/src/rust/lqosd/src/node_manager/local_api/search.rs @@ -1,4 +1,5 @@ use ip_network::IpNetwork; +use ip_network::{Ipv4Network, Ipv6Network}; use lqos_utils::XdpIpAddress; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -69,6 +70,53 @@ pub enum SearchResult { }, } +fn dynamic_longest_match_for_ip( + ip: IpAddr, + dynamic: &[lqos_network_devices::DynamicCircuit], +) -> Option<(IpNetwork, &lqos_config::ShapedDevice)> { + let mut best: Option<(IpNetwork, &lqos_config::ShapedDevice, u8)> = None; + + for circuit in dynamic { + let dev = &circuit.shaped; + match ip { + IpAddr::V4(ip4) => { + for (addr, prefix) in &dev.ipv4 { + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(net) = Ipv4Network::new(*addr, prefix_u8) else { + continue; + }; + if !net.contains(ip4) { + continue; + } + if best.as_ref().map(|(_, _, p)| *p).unwrap_or(0) < prefix_u8 { + best = Some((IpNetwork::V4(net), dev, prefix_u8)); + } + } + } + IpAddr::V6(ip6) => { + for (addr, prefix) in &dev.ipv6 { + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(net) = Ipv6Network::new(*addr, prefix_u8) else { + continue; + }; + if !net.contains(ip6) { + continue; + } + if best.as_ref().map(|(_, _, p)| *p).unwrap_or(0) < prefix_u8 { + best = Some((IpNetwork::V6(net), dev, prefix_u8)); + } + } + } + } + } + + best.map(|(net, dev, _)| (net, dev)) +} + pub fn search_results(search: SearchRequest) -> Vec { const MAX_RESULTS: usize = 50; let mut results: Vec = Vec::new(); @@ -79,6 +127,7 @@ pub fn search_results(search: SearchRequest) -> Vec { let exact_ip: Option = raw_term.parse::().ok(); let looks_like_ip_prefix = raw_term.contains('.') || raw_term.contains(':'); let catalog = lqos_network_devices::shaped_devices_catalog(); + let dynamic_snapshot = lqos_network_devices::dynamic_circuits_snapshot(); // Helper to add results with de-dup and cap fn push_result( @@ -117,6 +166,19 @@ pub fn search_results(search: SearchRequest) -> Vec { }, MAX_RESULTS, ); + } else if let Some((net, dev)) = dynamic_longest_match_for_ip(ip, dynamic_snapshot.as_ref()) + { + let name = format!("{} ({})", dev.device_name, pretty_net(&net)); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); } } @@ -156,6 +218,69 @@ pub fn search_results(search: SearchRequest) -> Vec { ); } } + for circuit in dynamic_snapshot.iter() { + if results.len() >= MAX_RESULTS { + break; + } + let dev = &circuit.shaped; + for (addr, prefix) in &dev.ipv4 { + if results.len() >= MAX_RESULTS { + break; + } + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(v4net) = Ipv4Network::new(*addr, prefix_u8) else { + continue; + }; + let addr = v4net.network_address().to_ipv6_mapped(); + let mapped_pref = prefix_u8.saturating_add(96); + let Ok(v6net) = Ipv6Network::new(addr, mapped_pref) else { + continue; + }; + let n = IpNetwork::V6(v6net); + if ipv6_overlap(&n, query_v6) { + let name = + format!("{} ({})", dev.device_name, pretty_net(&n)); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } + for (addr, prefix) in &dev.ipv6 { + if results.len() >= MAX_RESULTS { + break; + } + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(v6net) = Ipv6Network::new(*addr, prefix_u8) else { + continue; + }; + let n = IpNetwork::V6(v6net); + if ipv6_overlap(&n, query_v6) { + let name = + format!("{} ({})", dev.device_name, pretty_net(&n)); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } + } } } } else { @@ -179,6 +304,64 @@ pub fn search_results(search: SearchRequest) -> Vec { ); } } + for circuit in dynamic_snapshot.iter() { + if results.len() >= MAX_RESULTS { + break; + } + let dev = &circuit.shaped; + for (addr, prefix) in &dev.ipv4 { + if results.len() >= MAX_RESULTS { + break; + } + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(net) = Ipv4Network::new(*addr, prefix_u8) else { + continue; + }; + let n = IpNetwork::V4(net); + let s = pretty_net(&n); + if s.starts_with(raw_term) { + let name = format!("{} ({})", dev.device_name, s); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } + for (addr, prefix) in &dev.ipv6 { + if results.len() >= MAX_RESULTS { + break; + } + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(net) = Ipv6Network::new(*addr, prefix_u8) else { + continue; + }; + let n = IpNetwork::V6(net); + let s = pretty_net(&n); + if s.starts_with(raw_term) { + let name = format!("{} ({})", dev.device_name, s); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } + } } } @@ -217,6 +400,41 @@ pub fn search_results(search: SearchRequest) -> Vec { ); } } + + for circuit in dynamic_snapshot.iter() { + if results.len() >= MAX_RESULTS { + break; + } + let sd = &circuit.shaped; + let circuit_name_lc = sd.circuit_name.to_lowercase(); + if circuit_name_lc.contains(&term_lc) { + push_result( + &mut results, + &mut seen, + SearchResult::Circuit { + id: sd.circuit_id.clone(), + name: sd.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + if results.len() >= MAX_RESULTS { + break; + } + let device_name_lc = sd.device_name.to_lowercase(); + if device_name_lc.contains(&term_lc) { + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: sd.circuit_id.clone(), + name: sd.device_name.clone(), + circuit_name: sd.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } } // Fourth pass: Site name substring matches diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs index 7ca8a1eb..08efe5e3 100644 --- a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs @@ -29,6 +29,16 @@ pub struct ShapedDevicesPageQuery { pub page_size: Option, #[serde(default, deserialize_with = "deserialize_optional_string")] pub search: Option, + /// Which inventory surface to display. + #[serde(default)] + pub kind: Option, +} + +/// Which shaped-device inventory source to query. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ShapedDevicesPageKind { + Static, + Dynamic, } /// A server-paged slice of shaped devices plus total result counts. @@ -52,36 +62,48 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); - let catalog = lqos_network_devices::shaped_devices_catalog(); + let kind = query.kind.clone().unwrap_or(ShapedDevicesPageKind::Static); - let mut filtered: Vec = catalog - .iter_devices() - .filter(|device| { - if search.is_empty() { - return true; - } - device.device_name.to_lowercase().contains(&search) - || device.circuit_name.to_lowercase().contains(&search) - || device.parent_node.to_lowercase().contains(&search) - || device.circuit_id.to_lowercase().contains(&search) - || device.device_id.to_lowercase().contains(&search) - || device.mac.to_lowercase().contains(&search) - || device.comment.to_lowercase().contains(&search) - || device - .sqm_override - .as_deref() - .unwrap_or("") - .to_lowercase() - .contains(&search) - || device.ipv4.iter().any(|(addr, prefix)| { - format!("{addr}/{prefix}").to_lowercase().contains(&search) - }) - || device.ipv6.iter().any(|(addr, prefix)| { - format!("{addr}/{prefix}").to_lowercase().contains(&search) - }) - }) - .cloned() - .collect(); + let matches_search = |device: &ShapedDevice| { + if search.is_empty() { + return true; + } + device.device_name.to_lowercase().contains(&search) + || device.circuit_name.to_lowercase().contains(&search) + || device.parent_node.to_lowercase().contains(&search) + || device.circuit_id.to_lowercase().contains(&search) + || device.device_id.to_lowercase().contains(&search) + || device.mac.to_lowercase().contains(&search) + || device.comment.to_lowercase().contains(&search) + || device + .sqm_override + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&search) + || device + .ipv4 + .iter() + .any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search)) + || device + .ipv6 + .iter() + .any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search)) + }; + + let mut filtered: Vec = match kind { + ShapedDevicesPageKind::Static => lqos_network_devices::shaped_devices_catalog() + .iter_devices() + .filter(|device| matches_search(device)) + .cloned() + .collect(), + ShapedDevicesPageKind::Dynamic => lqos_network_devices::dynamic_circuits_snapshot() + .iter() + .map(|circuit| &circuit.shaped) + .filter(|device| matches_search(device)) + .cloned() + .collect(), + }; filtered.sort_by(|left, right| { left.circuit_name .cmp(&right.circuit_name) @@ -112,6 +134,7 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { } else { query.search }, + kind: Some(kind), }, total_rows, total_circuits, diff --git a/src/rust/lqosd/src/node_manager/static2/shaped_devices.html b/src/rust/lqosd/src/node_manager/static2/shaped_devices.html index 3ecd8a54..db1f9d8a 100644 --- a/src/rust/lqosd/src/node_manager/static2/shaped_devices.html +++ b/src/rust/lqosd/src/node_manager/static2/shaped_devices.html @@ -1,6 +1,22 @@
-
Shaped Devices ? ?
+
+ Circuits + ? + ? +
+
@@ -9,4 +25,4 @@
- \ No newline at end of file + diff --git a/src/rust/lqosd/src/node_manager/template.rs b/src/rust/lqosd/src/node_manager/template.rs index b54a6116..ccdc684f 100644 --- a/src/rust/lqosd/src/node_manager/template.rs +++ b/src/rust/lqosd/src/node_manager/template.rs @@ -151,8 +151,16 @@ pub async fn apply_templates( let week_ago = now - (7 * 24 * 60 * 60); let fl = FIRST_LOAD.load(Relaxed); if fl != 0 && fl < week_ago { - let num_circuits = - lqos_network_devices::shaped_devices_catalog().configured_circuit_count(); + let catalog = lqos_network_devices::network_devices_catalog(); + let mut circuits: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for device in catalog.iter_all_devices() { + let circuit_id = device.circuit_id.trim(); + if circuit_id.is_empty() { + continue; + } + circuits.insert(circuit_id); + } + let num_circuits = circuits.len(); if num_circuits > 1_000 && !script_has_insight { show_modal = "true"; show_modal_number = num_circuits.to_string(); diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs b/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs index 50ec05db..f05da9be 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs @@ -46,13 +46,13 @@ pub async fn circuit_capacity(channels: Arc) { }); // Map circuits to capacities - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let capacities: Vec = { circuits .iter() .filter_map(|(circuit_id, accumulator)| { if let Some(device) = catalog - .iter_devices() + .iter_all_devices() .find(|sd| sd.circuit_id == *circuit_id) { let down_mbps = (accumulator.bytes.down as f64 * 8.0) / 1_000_000.0; diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs b/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs index e5529904..f1a95a24 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs @@ -32,9 +32,9 @@ impl From<&IpStats> for IpStatsWithPlan { }; if !result.circuit_id.is_empty() { - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); if let Some(circuit) = catalog - .iter_devices() + .iter_all_devices() .find(|sd| sd.circuit_id == result.circuit_id) { let name = if circuit.circuit_name.chars().count() > 20 { diff --git a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs index 6f806cd1..4c23cd66 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs @@ -103,7 +103,7 @@ pub fn rebuild_circuit_live_snapshot() -> Arc { }; let kernel_now: std::time::Duration = kernel_now.into(); - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut by_circuit_id: FxHashMap = FxHashMap::default(); for (ip_key, data) in THROUGHPUT_TRACKER.raw_data.lock().iter() { diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index d8b041fd..ecc3de96 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -171,7 +171,7 @@ fn node_to_transport_with_summary( fn build_network_tree_summaries( nodes: &[NetworkJsonNode], - shaped_devices: &lqos_network_devices::ShapedDevicesCatalog, + shaped_devices: &lqos_network_devices::NetworkDevicesCatalog, ) -> Vec { let mut summaries = vec![NetworkTreeSummary::default(); nodes.len()]; let mut direct_circuits = vec![FxHashSet::default(); nodes.len()]; @@ -182,7 +182,7 @@ fn build_network_tree_summaries( node_index_by_name.entry(node.name.as_str()).or_insert(idx); } - for device in shaped_devices.iter_devices() { + for device in shaped_devices.iter_all_devices() { let Some(node_idx) = node_index_by_name.get(device.parent_node.as_str()).copied() else { continue; }; @@ -217,7 +217,7 @@ fn build_network_tree_summaries( pub fn get_one_network_map_layer(parent_idx: usize) -> BusResponse { lqos_network_devices::with_network_json_read(|net_json| { let nodes_ref = net_json.get_nodes_when_ready(); - let shaped_devices = lqos_network_devices::shaped_devices_catalog(); + let shaped_devices = lqos_network_devices::network_devices_catalog(); let summaries = build_network_tree_summaries(nodes_ref, &shaped_devices); if let Some(parent) = nodes_ref.get(parent_idx) { let mut nodes = vec![( @@ -252,7 +252,7 @@ pub fn get_one_network_map_layer(parent_idx: usize) -> BusResponse { pub fn full_network_map_snapshot() -> Vec<(usize, NetworkJsonTransport)> { lqos_network_devices::with_network_json_read(|net_json| { let nodes = net_json.get_nodes_when_ready(); - let shaped_devices = lqos_network_devices::shaped_devices_catalog(); + let shaped_devices = lqos_network_devices::network_devices_catalog(); let summaries = build_network_tree_summaries(nodes, &shaped_devices); nodes .iter() @@ -347,7 +347,7 @@ pub fn get_full_network_map() -> BusResponse { pub fn get_top_n_root_queues(n_queues: usize) -> BusResponse { lqos_network_devices::with_network_json_read(|net_json| { let nodes_ref = net_json.get_nodes_when_ready(); - let shaped_devices = lqos_network_devices::shaped_devices_catalog(); + let shaped_devices = lqos_network_devices::network_devices_catalog(); let summaries = build_network_tree_summaries(nodes_ref, &shaped_devices); if let Some(parent) = nodes_ref.first() { let mut nodes = vec![( @@ -493,7 +493,7 @@ pub fn get_funnel(circuit_id: &str) -> BusResponse { pub fn get_all_circuits() -> BusResponse { if let Ok(kernel_now) = time_since_boot() { - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let data = THROUGHPUT_TRACKER .raw_data .lock() @@ -604,7 +604,7 @@ pub fn get_all_circuits() -> BusResponse { pub fn get_circuit_by_id(desired_circuit_id: String) -> BusResponse { if let Ok(kernel_now) = time_since_boot() { let desired_hash = hash_to_i64(&desired_circuit_id); - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let data = THROUGHPUT_TRACKER .raw_data .lock() diff --git a/src/rust/lqosd/src/throughput_tracker/mod.rs b/src/rust/lqosd/src/throughput_tracker/mod.rs index b9cedc61..1b02cde1 100644 --- a/src/rust/lqosd/src/throughput_tracker/mod.rs +++ b/src/rust/lqosd/src/throughput_tracker/mod.rs @@ -453,9 +453,9 @@ pub fn circuit_heatmaps() -> BusResponse { return BusResponse::CircuitHeatmaps(Vec::new()); } - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut circuit_meta: FxHashMap = FxHashMap::default(); - catalog.iter_devices().for_each(|device| { + catalog.iter_all_devices().for_each(|device| { circuit_meta .entry(device.circuit_hash) .or_insert_with(|| (device.circuit_id.clone(), device.circuit_name.clone())); @@ -892,13 +892,14 @@ fn current_host_counts() -> (u32, u32) { /// Gather headline metrics for the Executive Summary header cards. pub fn executive_summary_header() -> BusResponse { - let catalog = lqos_network_devices::shaped_devices_catalog(); - let circuit_count = catalog - .iter_devices() - .map(|device| device.circuit_hash) - .collect::>() - .len() as u64; - let device_count = catalog.devices_len() as u64; + let catalog = lqos_network_devices::network_devices_catalog(); + let mut circuits: FxHashSet = FxHashSet::default(); + let mut device_count = 0_u64; + for device in catalog.iter_all_devices() { + device_count = device_count.saturating_add(1); + circuits.insert(device.circuit_hash); + } + let circuit_count = circuits.len() as u64; let site_count = lqos_network_devices::with_network_json_read(|net_json| { let total_nodes = net_json.get_nodes_when_ready().len(); @@ -1108,7 +1109,7 @@ pub fn top_flows(n: u32, flow_type: TopFlowType) -> BusResponse { } } - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); let result = table @@ -1172,7 +1173,7 @@ pub fn flows_by_ip(ip: &str) -> BusResponse { let ip = XdpIpAddress::from_ip(ip); let lock = ALL_FLOWS.lock(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let (circuit_id, circuit_name) = { let mut circuit_id = String::new(); let mut circuit_name = String::new(); diff --git a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs index a4d9c5f6..459f9a87 100644 --- a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs +++ b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs @@ -171,10 +171,12 @@ impl ThroughputTracker { return; } - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut capacity_lookup: FxHashMap = FxHashMap::default(); - capacity_lookup.reserve(catalog.devices_len()); - catalog.iter_devices().for_each(|device| { + capacity_lookup.reserve( + catalog.shaped_devices().devices_len() + catalog.dynamic_circuits().len(), + ); + catalog.iter_all_devices().for_each(|device| { let entry = capacity_lookup .entry(device.circuit_hash) .or_insert((device.download_max_mbps, device.upload_max_mbps)); @@ -403,7 +405,7 @@ impl ThroughputTracker { } fn shaped_device_for_hashes( - catalog: &lqos_network_devices::ShapedDevicesCatalog, + catalog: &lqos_network_devices::NetworkDevicesCatalog, device_hash: Option, circuit_hash: Option, ) -> Option<&lqos_config::ShapedDevice> { @@ -411,7 +413,7 @@ impl ThroughputTracker { } fn lookup_network_parents_from_hashes( - catalog: &lqos_network_devices::ShapedDevicesCatalog, + catalog: &lqos_network_devices::NetworkDevicesCatalog, device_hash: Option, circuit_hash: Option, lock: &NetworkJson, @@ -421,7 +423,7 @@ impl ThroughputTracker { } pub(crate) fn refresh_circuit_ids(&self, lock: &NetworkJson) { - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut raw_data = self.raw_data.lock(); raw_data.iter_mut().for_each(|(_key, data)| { let shaped_device = @@ -444,16 +446,6 @@ impl ThroughputTracker { ) { const MAX_UNKNOWN_OBSERVATIONS_PER_TICK: usize = 256; - let dynamic_snapshot = lqos_network_devices::dynamic_circuits_snapshot(); - let mut dynamic_device_hashes: FxHashSet = FxHashSet::default(); - let mut dynamic_circuit_hashes: FxHashSet = FxHashSet::default(); - dynamic_device_hashes.reserve(dynamic_snapshot.len()); - dynamic_circuit_hashes.reserve(dynamic_snapshot.len()); - for circuit in dynamic_snapshot.iter() { - dynamic_device_hashes.insert(circuit.shaped.device_hash); - dynamic_circuit_hashes.insert(circuit.shaped.circuit_hash); - } - let mut reported_dynamic_device_hashes: FxHashSet = FxHashSet::default(); let mut reported_dynamic_circuit_hashes: FxHashSet = FxHashSet::default(); let mut observations: Vec = Vec::new(); @@ -461,7 +453,7 @@ impl ThroughputTracker { let mut changed_circuits = HashSet::new(); let self_cycle = self.cycle.load(std::sync::atomic::Ordering::Relaxed); - let catalog = lqos_network_devices::shaped_devices_catalog(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut raw_data = self.raw_data.lock(); #[allow(clippy::too_many_arguments)] @@ -469,18 +461,17 @@ impl ThroughputTracker { observations: &mut Vec, reported_dynamic_device_hashes: &mut FxHashSet, reported_dynamic_circuit_hashes: &mut FxHashSet, - dynamic_device_hashes: &FxHashSet, - dynamic_circuit_hashes: &FxHashSet, + catalog: &lqos_network_devices::NetworkDevicesCatalog, ip: XdpIpAddress, device_hash: Option, circuit_hash: Option, ) { - if dynamic_device_hashes.is_empty() && dynamic_circuit_hashes.is_empty() { + if catalog.dynamic_circuits().is_empty() { return; } if let Some(device_hash) = device_hash - && dynamic_device_hashes.contains(&device_hash) + && catalog.is_dynamic_device_hash(device_hash) && reported_dynamic_device_hashes.insert(device_hash) { observations.push(lqos_network_devices::CircuitObservation { @@ -492,7 +483,7 @@ impl ThroughputTracker { } if let Some(circuit_hash) = circuit_hash - && dynamic_circuit_hashes.contains(&circuit_hash) + && catalog.is_dynamic_circuit_hash(circuit_hash) && reported_dynamic_circuit_hashes.insert(circuit_hash) { observations.push(lqos_network_devices::CircuitObservation { @@ -536,8 +527,7 @@ impl ThroughputTracker { &mut observations, &mut reported_dynamic_device_hashes, &mut reported_dynamic_circuit_hashes, - &dynamic_device_hashes, - &dynamic_circuit_hashes, + &catalog, *xdp_ip, entry.device_hash, entry.circuit_hash, @@ -562,8 +552,7 @@ impl ThroughputTracker { &mut observations, &mut reported_dynamic_device_hashes, &mut reported_dynamic_circuit_hashes, - &dynamic_device_hashes, - &dynamic_circuit_hashes, + &catalog, *xdp_ip, entry.device_hash, entry.circuit_hash, @@ -630,8 +619,7 @@ impl ThroughputTracker { &mut observations, &mut reported_dynamic_device_hashes, &mut reported_dynamic_circuit_hashes, - &dynamic_device_hashes, - &dynamic_circuit_hashes, + &catalog, *xdp_ip, device_hash, circuit_hash, diff --git a/src/rust/lqosd/src/treeguard/actor.rs b/src/rust/lqosd/src/treeguard/actor.rs index be8799be..d73ed0c9 100644 --- a/src/rust/lqosd/src/treeguard/actor.rs +++ b/src/rust/lqosd/src/treeguard/actor.rs @@ -277,7 +277,16 @@ fn current_topology_totals() -> (usize, usize) { }) }; - let total_circuits = lqos_network_devices::shaped_devices_catalog().configured_circuit_count(); + let catalog = lqos_network_devices::network_devices_catalog(); + let mut circuit_ids: FxHashSet<&str> = FxHashSet::default(); + for device in catalog.iter_all_devices() { + let circuit_id = device.circuit_id.trim(); + if circuit_id.is_empty() { + continue; + } + circuit_ids.insert(circuit_id); + } + let total_circuits = circuit_ids.len(); (total_nodes, total_circuits) } @@ -295,10 +304,10 @@ fn direct_child_site_counts_by_node(nodes: &[NetworkJsonNode]) -> Vec { } fn direct_circuit_counts_by_node( - shaped_devices: &lqos_network_devices::ShapedDevicesCatalog, + shaped_devices: &lqos_network_devices::NetworkDevicesCatalog, ) -> FxHashMap { let mut circuits_by_node: FxHashMap> = FxHashMap::default(); - for device in shaped_devices.iter_devices() { + for device in shaped_devices.iter_all_devices() { let node_name = device.parent_node.trim(); let circuit_id = device.circuit_id.trim(); if node_name.is_empty() || circuit_id.is_empty() { @@ -445,6 +454,7 @@ struct CircuitInventoryEntry { #[derive(Clone, Debug, Default)] struct CircuitInventory { shaped_devices_generation: u64, + dynamic_signature: u64, circuit_ids: Vec, entries: FxHashMap, all_device_ids: FxHashSet, @@ -598,23 +608,57 @@ fn select_link_virtualization_candidates( fn ensure_circuit_inventory( runtime_state: &mut TreeguardRuntimeState, - shaped: &lqos_network_devices::ShapedDevicesCatalog, + shaped: &lqos_network_devices::NetworkDevicesCatalog, ) { - let generation = shaped.generation(); - if runtime_state.circuit_inventory.shaped_devices_generation == generation { + use std::hash::{Hash, Hasher}; + + fn dynamic_overlay_signature(dynamic: &[lqos_network_devices::DynamicCircuit]) -> u64 { + use fxhash::FxHasher; + + let mut entry_sigs: Vec = Vec::with_capacity(dynamic.len()); + for circuit in dynamic { + let dev = &circuit.shaped; + let mut h = FxHasher::default(); + dev.circuit_id.trim().hash(&mut h); + dev.device_id.trim().hash(&mut h); + dev.parent_node.trim().hash(&mut h); + dev.download_max_mbps.to_bits().hash(&mut h); + dev.upload_max_mbps.to_bits().hash(&mut h); + dev.sqm_override + .as_deref() + .unwrap_or("") + .trim() + .hash(&mut h); + entry_sigs.push(h.finish()); + } + entry_sigs.sort_unstable(); + + let mut outer = FxHasher::default(); + entry_sigs.hash(&mut outer); + outer.finish() + } + + let generation = shaped.shaped_devices().generation(); + let dynamic_signature = dynamic_overlay_signature(shaped.dynamic_circuits()); + if runtime_state.circuit_inventory.shaped_devices_generation == generation + && runtime_state.circuit_inventory.dynamic_signature == dynamic_signature + { return; } - runtime_state.circuit_inventory = build_circuit_inventory(shaped); + runtime_state.circuit_inventory = build_circuit_inventory(shaped, dynamic_signature); runtime_state.circuit_batch_cursor = 0; } fn build_circuit_inventory( - shaped: &lqos_network_devices::ShapedDevicesCatalog, + shaped: &lqos_network_devices::NetworkDevicesCatalog, + dynamic_signature: u64, ) -> CircuitInventory { let mut circuits_by_device_id: FxHashMap> = FxHashMap::default(); - circuits_by_device_id.reserve(shaped.devices_len()); - for device in shaped.iter_devices() { + circuits_by_device_id.reserve( + shaped.shaped_devices().devices_len() + shaped.dynamic_circuits().len(), + ); + for device in shaped.iter_all_devices() { let device_id = device.device_id.trim(); let circuit_id = device.circuit_id.trim(); if device_id.is_empty() || circuit_id.is_empty() { @@ -639,10 +683,10 @@ fn build_circuit_inventory( .collect(); let mut by_circuit_id: FxHashMap> = FxHashMap::default(); - by_circuit_id.reserve(shaped.devices_len()); + by_circuit_id.reserve(shaped.shaped_devices().devices_len() + shaped.dynamic_circuits().len()); let mut all_device_ids = FxHashSet::default(); - all_device_ids.reserve(shaped.devices_len()); - for device in shaped.iter_devices() { + all_device_ids.reserve(shaped.shaped_devices().devices_len() + shaped.dynamic_circuits().len()); + for device in shaped.iter_all_devices() { if device.circuit_id.trim().is_empty() { continue; } @@ -718,7 +762,8 @@ fn build_circuit_inventory( } CircuitInventory { - shaped_devices_generation: shaped.generation(), + shaped_devices_generation: shaped.shaped_devices().generation(), + dynamic_signature, circuit_ids, entries, all_device_ids, @@ -831,7 +876,7 @@ fn run_tick( return; } - let shaped = lqos_network_devices::shaped_devices_catalog(); + let shaped = lqos_network_devices::network_devices_catalog(); ensure_circuit_inventory(runtime_state, &shaped); let link_states = &mut runtime_state.link_states; diff --git a/src/rust/lqtop/src/widgets/top_flows.rs b/src/rust/lqtop/src/widgets/top_flows.rs index b33eeafc..9ea551d8 100644 --- a/src/rust/lqtop/src/widgets/top_flows.rs +++ b/src/rust/lqtop/src/widgets/top_flows.rs @@ -3,6 +3,23 @@ use lqos_bus::{BusResponse, FlowbeeSummaryData}; use lqos_utils::packet_scale::scale_bits; use ratatui::prelude::*; +fn truncate_by_chars(input: &str, max_chars: usize) -> String { + input.chars().take(max_chars).collect() +} + +fn flow_circuit_label(flow: &FlowbeeSummaryData) -> String { + let candidate = if !flow.circuit_name.trim().is_empty() { + flow.circuit_name.trim() + } else { + flow.circuit_id.trim() + }; + if candidate.is_empty() { + "-".to_string() + } else { + truncate_by_chars(candidate, 28) + } +} + pub struct TopFlows { bus_link: tokio::sync::mpsc::Sender, rx: std::sync::mpsc::Receiver, @@ -38,6 +55,7 @@ impl TopWidget for TopFlows { fn render_to_frame(&mut self, frame: &mut Frame) { let mut t = TableHelper::new([ + "Circuit", "Src IP", "Dst IP", "Type", @@ -49,6 +67,7 @@ impl TopWidget for TopFlows { ]); for flow in self.flows.iter() { t.add_row([ + flow_circuit_label(flow), flow.local_ip.to_string(), flow.remote_ip.to_string(), flow.analysis.to_string(), diff --git a/src/rust/lqtop/src/widgets/top_hosts.rs b/src/rust/lqtop/src/widgets/top_hosts.rs index b35ddc8b..20097ced 100644 --- a/src/rust/lqtop/src/widgets/top_hosts.rs +++ b/src/rust/lqtop/src/widgets/top_hosts.rs @@ -3,6 +3,19 @@ use lqos_bus::{BusResponse, IpStats}; use lqos_utils::packet_scale::{scale_bits, scale_packets}; use ratatui::prelude::*; +fn truncate_by_chars(input: &str, max_chars: usize) -> String { + input.chars().take(max_chars).collect() +} + +fn circuit_label(circuit_id: &str) -> String { + let trimmed = circuit_id.trim(); + if trimmed.is_empty() { + "-".to_string() + } else { + truncate_by_chars(trimmed, 28) + } +} + pub struct TopHosts { bus_link: tokio::sync::mpsc::Sender, rx: std::sync::mpsc::Receiver, @@ -38,6 +51,7 @@ impl TopWidget for TopHosts { fn render_to_frame(&mut self, frame: &mut Frame) { let mut t = TableHelper::new([ + "Circuit", "IP Address", "Down (bps)", "Up (bps)", @@ -49,6 +63,7 @@ impl TopWidget for TopHosts { for host in self.stats.iter() { t.add_row([ + circuit_label(&host.circuit_id), host.ip_address.to_string(), scale_bits(host.bits_per_second.down), scale_bits(host.bits_per_second.up), From 4b4baa8de314ca207c1031710bdb037817def3bf Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 11 Apr 2026 13:27:32 -0500 Subject: [PATCH 10/20] fix: reconcile tree attached circuits + lockfile --- src/rust/Cargo.lock | 1 + .../lqosd/src/node_manager/local_api/tree_attached_circuits.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 9dac6da6..fbaf2f8f 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2120,6 +2120,7 @@ dependencies = [ "ip_network", "ip_network_table", "lqos_config", + "lqos_topology_compile", "lqos_utils", "once_cell", "parking_lot", diff --git a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs index 03129de9..886a75e3 100644 --- a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs +++ b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs @@ -474,7 +474,7 @@ mod tests { ..ConfigShapedDevices::default() }; - let rows = aggregate_attached_circuit_rows(&matcher, &shaped_devices); + let rows = aggregate_attached_circuit_rows(&matcher, shaped_devices.devices.iter()); assert_eq!(rows.len(), 1); assert_eq!(rows[0].circuit_id, "circuit-1"); assert_eq!(rows[0].circuit_name, "Circuit One"); From 60f6fc29877be2ace5a4905523904ec245da249e Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 09:37:13 -0500 Subject: [PATCH 11/20] fix: avoid stack overflow when saving config --- .../src/etc/v15/dynamic_circuits.rs | 38 +++++++++++++++++++ src/rust/lqosd/src/main.rs | 1 + 2 files changed, 39 insertions(+) diff --git a/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs index 479e3aba..f725db28 100644 --- a/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs +++ b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs @@ -143,6 +143,7 @@ impl DynamicCircuitsConfig { #[cfg(test)] mod tests { use super::*; + use crate::etc::v15::Config; #[test] fn parses_default_ipv4_shorthand() { @@ -169,4 +170,41 @@ mod tests { toml::from_str(r#"ip_range = "::""#).expect("parse ip_range shorthand"); assert_eq!(parsed.ip_range.to_string(), "::/0"); } + + #[test] + fn config_roundtrips_with_dynamic_circuit_ranges() { + let mut config = Config::default(); + + let rule = DynamicCircuitRangeRule { + name: "Default".to_string(), + ip_range: "0.0.0.0/0" + .parse() + .expect("default ip network should parse"), + download_min_mbps: 10.0, + upload_min_mbps: 2.0, + download_max_mbps: 100.0, + upload_max_mbps: 20.0, + attach_to: String::new(), + }; + + config.dynamic_circuits = Some(DynamicCircuitsConfig { + enabled: true, + ttl_seconds: 300, + enable_unknown_ip_promotion: true, + ranges: vec![rule], + }); + + let raw = toml::to_string_pretty(&config).expect("config should serialize to TOML"); + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + assert!(parsed.dynamic_circuits.is_some()); + assert_eq!( + parsed + .dynamic_circuits + .as_ref() + .expect("dynamic circuits should deserialize") + .ranges + .len(), + 1 + ); + } } diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index 18bc0cb7..523bbfc8 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -309,6 +309,7 @@ fn main() -> Result<()> { .spawn(move || { let Ok(tokio_runtime) = tokio::runtime::Builder::new_multi_thread() .enable_all() + .thread_stack_size(8 * 1024 * 1024) .build() else { error!("Unable to start Tokio runtime. Not much is going to work"); From affda46bdac984b853aec8d3c8fab19dd382ca1c Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 10:21:58 -0500 Subject: [PATCH 12/20] fix: load ShapedDevices.csv when topology import missing --- src/rust/lqos_network_devices/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs index d7f253d1..07fbe3e7 100644 --- a/src/rust/lqos_network_devices/src/lib.rs +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -83,7 +83,10 @@ pub fn load_shaped_devices() -> Result { .context("Unable to load topology_import.json")? { Some(topology_import) => Ok(topology_import.into_imported_bundle().shaped_devices), - None => Ok(ConfigShapedDevices::default()), + None => { + debug!("topology_import.json missing; falling back to ShapedDevices.csv"); + ConfigShapedDevices::load().context("Unable to load ShapedDevices.csv") + } } } else { ConfigShapedDevices::load().context("Unable to load ShapedDevices.csv") From 9ecad1988d38c44271681768cd8b87a66e66ffe2 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 10:29:14 -0500 Subject: [PATCH 13/20] fix: fall back to ShapedDevices.csv when topology import empty --- src/rust/lqos_network_devices/src/lib.rs | 25 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs index 07fbe3e7..6e7a12a1 100644 --- a/src/rust/lqos_network_devices/src/lib.rs +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -79,18 +79,27 @@ fn integration_ingress_enabled(config: &lqos_config::Config) -> bool { pub fn load_shaped_devices() -> Result { let config = lqos_config::load_config().context("Unable to load /etc/lqos.conf")?; if integration_ingress_enabled(config.as_ref()) { - match lqos_topology_compile::TopologyImportFile::load(config.as_ref()) - .context("Unable to load topology_import.json")? - { - Some(topology_import) => Ok(topology_import.into_imported_bundle().shaped_devices), - None => { + match lqos_topology_compile::TopologyImportFile::load(config.as_ref()) { + Ok(Some(topology_import)) => { + let shaped_devices = topology_import.into_imported_bundle().shaped_devices; + if !shaped_devices.devices.is_empty() { + return Ok(shaped_devices); + } + debug!( + "topology_import.json contained 0 shaped devices; falling back to ShapedDevices.csv" + ); + } + Ok(None) => { debug!("topology_import.json missing; falling back to ShapedDevices.csv"); - ConfigShapedDevices::load().context("Unable to load ShapedDevices.csv") + } + Err(err) => { + debug!( + "Unable to load topology_import.json ({err}); falling back to ShapedDevices.csv" + ); } } - } else { - ConfigShapedDevices::load().context("Unable to load ShapedDevices.csv") } + ConfigShapedDevices::load().context("Unable to load ShapedDevices.csv") } /// Loads `network.json` from disk. From cbd4f606673de92df1c499f17692715c7c99196d Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 10:32:02 -0500 Subject: [PATCH 14/20] ui: group unknown IP promotion with range rules --- .../static2/config_dynamic_circuits.html | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html b/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html index 56933632..7a276705 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html +++ b/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html @@ -29,12 +29,6 @@
How long dynamic circuit state remains valid without being seen.
- -
- - -
Allow unknown IPs to be promoted into dynamic circuits when a range rule matches.
-
@@ -43,6 +37,12 @@
Range Rules
Define the IP networks that should receive default rates and an optional topology attachment.
+
+ + +
When enabled, unknown IP traffic is evaluated against the range rules below.
+
+
@@ -80,4 +80,3 @@
- From be572280842256210a4acb91169d268b6eb30b6f Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 10:56:06 -0500 Subject: [PATCH 15/20] fix: resolve dynamic circuits by IP for UI --- .../src/combined_catalog.rs | 47 ++++++++++++++++++- src/rust/lqos_network_devices/src/tests.rs | 11 ++--- .../src/throughput_tracker/tracking_data.rs | 1 - 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/rust/lqos_network_devices/src/combined_catalog.rs b/src/rust/lqos_network_devices/src/combined_catalog.rs index 2e965646..fbe20eaa 100644 --- a/src/rust/lqos_network_devices/src/combined_catalog.rs +++ b/src/rust/lqos_network_devices/src/combined_catalog.rs @@ -1,7 +1,11 @@ use crate::{DynamicCircuit, ShapedDevicesCatalog}; use fxhash::FxHashMap; +use ip_network::IpNetwork; +use ip_network_table::IpNetworkTable; use lqos_config::ShapedDevice; +use lqos_utils::XdpIpAddress; use std::sync::Arc; +use std::net::IpAddr; fn normalize_circuit_id_key(circuit_id: &str) -> String { circuit_id.trim().to_ascii_lowercase() @@ -11,13 +15,19 @@ fn normalize_circuit_id_key(circuit_id: &str) -> String { /// /// This catalog is intended for read-heavy paths (dashboards, APIs) that need to /// treat dynamic circuits as first-class circuits alongside `ShapedDevices.csv`. -#[derive(Clone)] pub struct NetworkDevicesCatalog { shaped: ShapedDevicesCatalog, dynamic: Arc>, dyn_by_device_hash: FxHashMap, dyn_by_circuit_hash: FxHashMap, dyn_by_circuit_id: FxHashMap, + dyn_ip_table: IpNetworkTable, +} + +impl Clone for NetworkDevicesCatalog { + fn clone(&self) -> Self { + Self::from_snapshots(self.shaped.clone(), self.dynamic.clone()) + } } impl NetworkDevicesCatalog { @@ -29,11 +39,26 @@ impl NetworkDevicesCatalog { let mut dyn_by_device_hash = FxHashMap::default(); let mut dyn_by_circuit_hash = FxHashMap::default(); let mut dyn_by_circuit_id = FxHashMap::default(); + let mut dyn_ip_table = IpNetworkTable::new(); for (idx, circuit) in dynamic.iter().enumerate() { dyn_by_device_hash.insert(circuit.shaped.device_hash, idx); dyn_by_circuit_hash.insert(circuit.shaped.circuit_hash, idx); dyn_by_circuit_id.insert(normalize_circuit_id_key(&circuit.shaped.circuit_id), idx); + + for (ipv4, prefix) in &circuit.shaped.ipv4 { + let prefix = prefix.saturating_add(96).min(128); + if let Ok(net) = IpNetwork::new(ipv4.to_ipv6_mapped(), prefix as u8) { + dyn_ip_table.insert(net, idx); + } + } + for (ipv6, prefix) in &circuit.shaped.ipv6 { + if *prefix <= 128 { + if let Ok(net) = IpNetwork::new(*ipv6, *prefix as u8) { + dyn_ip_table.insert(net, idx); + } + } + } } Self { @@ -42,6 +67,7 @@ impl NetworkDevicesCatalog { dyn_by_device_hash, dyn_by_circuit_hash, dyn_by_circuit_id, + dyn_ip_table, } } @@ -70,6 +96,25 @@ impl NetworkDevicesCatalog { self.iter_static_devices().chain(self.iter_dynamic_devices()) } + /// Returns the longest-prefix match entry for an IP address. + /// + /// This prefers static shaped devices, then falls back to dynamic circuits. + pub fn device_longest_match_for_ip( + &self, + ip: &XdpIpAddress, + ) -> Option<(IpNetwork, &ShapedDevice)> { + if let Some((net, device)) = self.shaped.device_longest_match_for_ip(ip) { + return Some((net, device)); + } + + let lookup = match ip.as_ip() { + IpAddr::V4(ip) => ip.to_ipv6_mapped(), + IpAddr::V6(ip) => ip, + }; + let (net, idx) = self.dyn_ip_table.longest_match(lookup)?; + self.dynamic.get(*idx).map(|circuit| (net, &circuit.shaped)) + } + /// Returns true if the device hash is currently tracked as a dynamic circuit. pub fn is_dynamic_device_hash(&self, device_hash: i64) -> bool { self.dyn_by_device_hash.contains_key(&device_hash) diff --git a/src/rust/lqos_network_devices/src/tests.rs b/src/rust/lqos_network_devices/src/tests.rs index 4fe375bb..dc57fc96 100644 --- a/src/rust/lqos_network_devices/src/tests.rs +++ b/src/rust/lqos_network_devices/src/tests.rs @@ -50,16 +50,12 @@ fn catalog_device_by_hashes_prefers_device_hash() { let a = ShapedDevice { circuit_id: "circuit-a".into(), device_id: "device-a".into(), - circuit_hash: 10, - device_hash: 100, ..Default::default() }; let b = ShapedDevice { circuit_id: "circuit-b".into(), device_id: "device-b".into(), - circuit_hash: 20, - device_hash: 200, ..Default::default() }; @@ -68,13 +64,16 @@ fn catalog_device_by_hashes_prefers_device_hash() { let catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped)); + let a_device_hash = lqos_utils::hash_to_i64(&a.device_id); + let b_circuit_hash = lqos_utils::hash_to_i64(&b.circuit_id); + let resolved = catalog - .device_by_hashes(Some(a.device_hash), Some(b.circuit_hash)) + .device_by_hashes(Some(a_device_hash), Some(b_circuit_hash)) .expect("Expected shaped device match"); assert_eq!(resolved.device_id, a.device_id); let fallback = catalog - .device_by_hashes(Some(999), Some(b.circuit_hash)) + .device_by_hashes(Some(999), Some(b_circuit_hash)) .expect("Expected circuit-hash fallback match"); assert_eq!(fallback.device_id, b.device_id); } diff --git a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs index 6fa0b72d..08cbbbaf 100644 --- a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs +++ b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs @@ -414,7 +414,6 @@ impl ThroughputTracker { return Some(device); } catalog - .shaped_devices() .device_longest_match_for_ip(ip) .map(|(_net, device)| device) } From 6e4bb732edd16a5983cfd3a42270f824a7270c53 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 11:07:25 -0500 Subject: [PATCH 16/20] Fix unknown IP refresh after dyn expiry - Emit unknown observations for existing entries when still unresolved\n- Downgrade watched queue miss to debug --- .../src/tracking/watched_queues.rs | 4 ++-- .../lqosd/src/throughput_tracker/tracking_data.rs | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/rust/lqos_queue_tracker/src/tracking/watched_queues.rs b/src/rust/lqos_queue_tracker/src/tracking/watched_queues.rs index 0d33767c..98ebb35c 100644 --- a/src/rust/lqos_queue_tracker/src/tracking/watched_queues.rs +++ b/src/rust/lqos_queue_tracker/src/tracking/watched_queues.rs @@ -4,7 +4,7 @@ use lqos_bus::TcHandle; use lqos_sys::num_possible_cpus; use lqos_utils::unix_time::unix_now; use once_cell::sync::Lazy; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; pub(crate) static WATCHED_QUEUES: Lazy> = Lazy::new(DashMap::new); @@ -74,7 +74,7 @@ pub fn add_watched_queue(circuit_id: &str) { WATCHED_QUEUES.insert(circuit_id.to_string(), new_watch); //info!("Added {circuit_id} to watched queues. Now watching {} queues.", WATCHED_QUEUES.len()); } else { - warn!("No circuit ID of {circuit_id}"); + debug!("Circuit queue not found for watch request: {circuit_id}"); } } else { warn!("Unable to access watched queue list. Try again later."); diff --git a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs index 08cbbbaf..34e83a18 100644 --- a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs +++ b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs @@ -586,6 +586,15 @@ impl ThroughputTracker { entry.device_hash, entry.circuit_hash, ); + if shaped_device.is_none() + && observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK + { + observations.push(lqos_network_devices::CircuitObservation { + ip: *xdp_ip, + device_hash: entry.device_hash, + circuit_hash: entry.circuit_hash, + }); + } if let Some(parents) = &entry.network_json_parents { net_json_calc.add_throughput_cycle( @@ -661,8 +670,7 @@ impl ThroughputTracker { device_hash, circuit_hash, ); - if shaped_device.is_none() && observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK - { + if shaped_device.is_none() && observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK { observations.push(lqos_network_devices::CircuitObservation { ip: *xdp_ip, device_hash, From 5a1e266dd0fbbd4e5ce2dd9793ac3119eb362298 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 11:44:34 -0500 Subject: [PATCH 17/20] Improve dynamic circuits config UX - Rework Operation layout to avoid wide-table squeeze\n- Add responsive config table styling\n- Collapse min/max speed columns into down/up pairs --- .../js_build/src/config_dynamic_circuits.js | 80 ++++++++++++++----- .../static2/config_dynamic_circuits.html | 78 +++++++++++------- .../src/node_manager/static2/node_manager.css | 48 +++++++++++ 3 files changed, 157 insertions(+), 49 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js b/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js index e941a230..e17520b9 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js @@ -119,7 +119,7 @@ function renderRangesTable() { if (!Array.isArray(ranges) || ranges.length === 0) { const empty = document.createElement("tr"); const cell = document.createElement("td"); - cell.colSpan = 8; + cell.colSpan = 6; cell.className = "text-muted"; cell.textContent = "No rules configured."; empty.appendChild(cell); @@ -150,49 +150,91 @@ function renderRangesTable() { return input; }; - const addCell = (child) => { + const mkRatePair = ( + downValue, + onDownChange, + upValue, + onUpChange, + ) => { + const container = document.createElement("div"); + container.className = "d-grid gap-1"; + + const mkDir = (icon, ariaLabel, value, onChange) => { + const group = document.createElement("div"); + group.className = "input-group input-group-sm"; + + const prefix = document.createElement("span"); + prefix.className = "input-group-text"; + const i = document.createElement("i"); + i.className = `fa ${icon}`; + prefix.appendChild(i); + + const input = mkNumber(value, onChange); + input.setAttribute("aria-label", ariaLabel); + + group.appendChild(prefix); + group.appendChild(input); + return group; + }; + + container.appendChild(mkDir("fa-arrow-down", "Down Mbps", downValue, onDownChange)); + container.appendChild(mkDir("fa-arrow-up", "Up Mbps", upValue, onUpChange)); + return container; + }; + + const addCell = (label, child) => { const td = document.createElement("td"); + td.dataset.label = label; td.appendChild(child); tr.appendChild(td); }; addCell( + "Name", mkText(rule.name, (v) => { window.config.dynamic_circuits.ranges[index].name = v; }, "Rule name"), ); addCell( + "IP Range", mkText(rule.ip_range, (v) => { window.config.dynamic_circuits.ranges[index].ip_range = v; }, "192.168.0.0/24"), ); addCell( - mkNumber(rule.download_min_mbps, (v) => { - window.config.dynamic_circuits.ranges[index].download_min_mbps = v; - }), + "Min Mbps", + mkRatePair( + rule.download_min_mbps, + (v) => { + window.config.dynamic_circuits.ranges[index].download_min_mbps = v; + }, + rule.upload_min_mbps, + (v) => { + window.config.dynamic_circuits.ranges[index].upload_min_mbps = v; + }, + ), ); addCell( - mkNumber(rule.upload_min_mbps, (v) => { - window.config.dynamic_circuits.ranges[index].upload_min_mbps = v; - }), - ); - addCell( - mkNumber(rule.download_max_mbps, (v) => { - window.config.dynamic_circuits.ranges[index].download_max_mbps = v; - }), - ); - addCell( - mkNumber(rule.upload_max_mbps, (v) => { - window.config.dynamic_circuits.ranges[index].upload_max_mbps = v; - }), + "Max Mbps", + mkRatePair( + rule.download_max_mbps, + (v) => { + window.config.dynamic_circuits.ranges[index].download_max_mbps = v; + }, + rule.upload_max_mbps, + (v) => { + window.config.dynamic_circuits.ranges[index].upload_max_mbps = v; + }, + ), ); const attach = mkText(rule.attach_to, (v) => { window.config.dynamic_circuits.ranges[index].attach_to = v; }, "network.json node (optional)"); attach.setAttribute("list", "networkNodesDatalist"); - addCell(attach); + addCell("Attach To", attach); const removeTd = document.createElement("td"); + removeTd.dataset.label = "Actions"; const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "btn btn-sm btn-outline-danger"; diff --git a/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html b/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html index 7a276705..08cda7db 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html +++ b/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html @@ -13,21 +13,28 @@
-
-
+
+
Operation
Enable the subsystem and choose retention and promotion behavior.
-
- - -
Master switch for dynamic circuit tracking and promotion.
-
+
+
+
+ + +
Master switch for dynamic circuit tracking and promotion.
+
+
-
- - -
How long dynamic circuit state remains valid without being seen.
+
+ +
+ + seconds +
+
How long dynamic circuit state remains valid without being seen.
+
@@ -37,37 +44,48 @@
Range Rules
Define the IP networks that should receive default rates and an optional topology attachment.
-
- - -
When enabled, unknown IP traffic is evaluated against the range rules below.
+
+
+ + +
When enabled, unknown IP traffic is evaluated against the range rules below.
+
+ +
+ +
- +
- - - - - - - - + + + + + +
NameIP RangeMin DownMin UpMax DownMax UpAttach ToActionsName + IP Range + + Min MbpsMax MbpsAttach ToActions
- - - -
- IP Range accepts CIDR notation (for example 192.168.200.0/24). 0.0.0.0 and :: are allowed and mean match-all. -
diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 589d91f8..017c2ffd 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -1903,6 +1903,54 @@ table tr td a { color: var(--bs-secondary-color) !important; } +/* Responsive inline tables for config editors */ + +@media (max-width: 767.98px) { + .lqos-config-responsive-table thead { + display: none; + } + + .lqos-config-responsive-table tbody tr { + display: block; + border: 1px solid var(--lqos-border); + border-radius: var(--lqos-radius-md); + background: color-mix(in srgb, var(--lqos-surface) 70%, transparent); + padding: 0.65rem 0.75rem; + margin-bottom: 0.75rem; + } + + .lqos-config-responsive-table tbody td { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.35rem 0; + border: 0; + } + + .lqos-config-responsive-table tbody td::before { + content: attr(data-label); + flex: 0 0 7.25rem; + color: var(--bs-secondary-color); + font-weight: 600; + font-size: 0.85rem; + } + + .lqos-config-responsive-table tbody td > * { + flex: 1 1 auto; + min-width: 0; + } + + .lqos-config-responsive-table tbody td[colspan] { + display: block; + padding: 0.25rem 0; + } + + .lqos-config-responsive-table tbody td[colspan]::before { + content: ""; + } +} + .lqos-config-network-editor { display: grid; gap: 0.9rem; From 7ca64aabfdd768502d5ae2c2f99c119b253c72ea Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 12:48:51 -0500 Subject: [PATCH 18/20] fix: pin sha2 and hex-encode digests --- src/rust/Cargo.lock | 81 +++---------------- src/rust/Cargo.toml | 2 +- src/rust/lqos_config/src/authentication.rs | 2 +- .../lqos_config/src/etc/etclqos_migration.rs | 5 +- .../lqos_config/src/etc/v15/top_config.rs | 2 +- src/rust/lqos_config/src/hex_encoding.rs | 19 +++++ src/rust/lqos_config/src/lib.rs | 1 + .../src/topology_canonical_state.rs | 4 +- .../lqos_config/src/topology_runtime_state.rs | 6 +- 9 files changed, 44 insertions(+), 78 deletions(-) create mode 100644 src/rust/lqos_config/src/hex_encoding.rs diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 98819b6b..b637645a 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "generic-array", ] @@ -380,7 +380,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -392,15 +392,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - [[package]] name = "block2" version = "0.6.2" @@ -556,7 +547,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "inout", "zeroize", ] @@ -674,12 +665,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "cookie" version = "0.18.1" @@ -884,15 +869,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "hybrid-array", -] - [[package]] name = "csv" version = "1.4.0" @@ -1106,22 +1082,11 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "block-buffer", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" -dependencies = [ - "block-buffer 0.12.0", - "const-oid", - "crypto-common 0.2.1", -] - [[package]] name = "dispatch2" version = "0.3.1" @@ -1171,7 +1136,7 @@ dependencies = [ "rand_core 0.6.4", "salsa20", "serde", - "sha2 0.10.9", + "sha2", "subtle", "winapi", "zeroize", @@ -1561,7 +1526,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -1615,15 +1580,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "1.9.0" @@ -2166,7 +2122,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "thiserror", "toml", "toml_edit 0.25.11+spec-1.1.0", @@ -2484,7 +2440,7 @@ dependencies = [ "serde", "serde_cbor", "serde_json", - "sha2 0.11.0", + "sha2", "sha256", "signal-hook", "smallvec", @@ -3780,7 +3736,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", + "digest", ] [[package]] @@ -3791,18 +3747,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.2", + "digest", ] [[package]] @@ -3814,7 +3759,7 @@ dependencies = [ "async-trait", "bytes", "hex", - "sha2 0.10.9", + "sha2", "tokio", ] @@ -4573,7 +4518,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "subtle", ] diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 1c266e5f..f2a0f51d 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -66,7 +66,7 @@ serde_json = "1" csv = "1" ip_network_table = "0" ip_network = "0" -sha2 = "0" +sha2 = "0.10" hmac = "0.12" base64 = "0.22" argon2 = "0.5" diff --git a/src/rust/lqos_config/src/authentication.rs b/src/rust/lqos_config/src/authentication.rs index 3262030b..6461f4f3 100644 --- a/src/rust/lqos_config/src/authentication.rs +++ b/src/rust/lqos_config/src/authentication.rs @@ -285,7 +285,7 @@ impl WebUsers { let salted = format!("!x{password}{LEGACY_PASSWORD_PEPPER}"); let mut sha256 = Sha256::new(); sha256.update(salted); - format!("{:X}", sha256.finalize()) + crate::hex_encoding::encode_hex_upper(sha256.finalize()) } fn verify_password( diff --git a/src/rust/lqos_config/src/etc/etclqos_migration.rs b/src/rust/lqos_config/src/etc/etclqos_migration.rs index fdf9acbf..963e7d34 100644 --- a/src/rust/lqos_config/src/etc/etclqos_migration.rs +++ b/src/rust/lqos_config/src/etc/etclqos_migration.rs @@ -243,8 +243,9 @@ fn check_config(cfg_doc: &mut DocumentMut, cfg: &mut EtcLqos) { && let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id") { let hash = sha2::Sha256::new().chain(machine_id).finalize(); - cfg.node_id = Some(format!("{:x}", hash)); - cfg_doc["node_id"] = value(format!("{:x}", hash)); + let node_id = crate::hex_encoding::encode_hex_lower(hash); + cfg.node_id = Some(node_id.clone()); + cfg_doc["node_id"] = value(node_id); println!("Updating"); if let Err(e) = cfg.save(cfg_doc) { error!("Unable to save /etc/lqos.conf"); diff --git a/src/rust/lqos_config/src/etc/v15/top_config.rs b/src/rust/lqos_config/src/etc/v15/top_config.rs index 975af8ca..6f7627ab 100644 --- a/src/rust/lqos_config/src/etc/v15/top_config.rs +++ b/src/rust/lqos_config/src/etc/v15/top_config.rs @@ -207,7 +207,7 @@ impl Config { pub fn calculate_node_id() -> String { if let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id") { let hash = sha2::Sha256::new().chain(machine_id).finalize(); - format!("{:x}", hash) + crate::hex_encoding::encode_hex_lower(hash) } else { Uuid::new_v4().to_string() } diff --git a/src/rust/lqos_config/src/hex_encoding.rs b/src/rust/lqos_config/src/hex_encoding.rs new file mode 100644 index 00000000..45a6295f --- /dev/null +++ b/src/rust/lqos_config/src/hex_encoding.rs @@ -0,0 +1,19 @@ +const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; +const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF"; + +pub(crate) fn encode_hex_lower(bytes: impl AsRef<[u8]>) -> String { + encode_hex(bytes.as_ref(), HEX_LOWER) +} + +pub(crate) fn encode_hex_upper(bytes: impl AsRef<[u8]>) -> String { + encode_hex(bytes.as_ref(), HEX_UPPER) +} + +fn encode_hex(bytes: &[u8], table: &[u8; 16]) -> String { + let mut encoded = String::with_capacity(bytes.len().saturating_mul(2)); + for &byte in bytes { + encoded.push(table[(byte >> 4) as usize] as char); + encoded.push(table[(byte & 0x0f) as usize] as char); + } + encoded +} diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index ae2bcb85..7be006d7 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -15,6 +15,7 @@ mod circuit_ethernet_metadata; mod cpu_topology; mod etc; mod ethernet_port_limits; +mod hex_encoding; mod mikrotik_ipv6_credentials; mod network_json; mod planner; diff --git a/src/rust/lqos_config/src/topology_canonical_state.rs b/src/rust/lqos_config/src/topology_canonical_state.rs index 3fa734c1..2e253342 100644 --- a/src/rust/lqos_config/src/topology_canonical_state.rs +++ b/src/rust/lqos_config/src/topology_canonical_state.rs @@ -221,7 +221,7 @@ where hasher.update([0]); hasher.update(identifier.as_bytes()); } - Some(format!("{:x}", hasher.finalize())) + Some(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } /// Returns a stable identity hash for imported topology facts and compile mode selection. @@ -246,7 +246,7 @@ where hasher.update([0]); hasher.update(identifier.as_bytes()); } - Some(format!("{:x}", hasher.finalize())) + Some(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } fn collect_network_json_ingress_tokens(map: &Map, out: &mut Vec) { diff --git a/src/rust/lqos_config/src/topology_runtime_state.rs b/src/rust/lqos_config/src/topology_runtime_state.rs index 3a46f184..4bf53b5d 100644 --- a/src/rust/lqos_config/src/topology_runtime_state.rs +++ b/src/rust/lqos_config/src/topology_runtime_state.rs @@ -582,7 +582,7 @@ pub fn compute_topology_source_generation( hash_file_state(&mut hasher, TOPOLOGY_EDITOR_STATE_FILENAME, &editor_path)?; } - Ok(format!("{:x}", hasher.finalize())) + Ok(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } impl TopologyAttachmentHealthStateFile { @@ -667,7 +667,7 @@ impl TopologyShapingInputsFile { hasher.update(b"topology-runtime-shaping-generation"); hasher.update([0xff]); hasher.update(payload); - Ok(format!("{:x}", hasher.finalize())) + Ok(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } } @@ -702,7 +702,7 @@ pub fn compute_effective_network_generation( hasher.update(b"topology-runtime-effective-generation"); hasher.update([0xff]); hasher.update(payload); - Ok(format!("{:x}", hasher.finalize())) + Ok(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } impl TopologyRuntimeStatusFile { From 92b386ede952354ae6067befeb6171ba0fa4f784 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 13:34:08 -0500 Subject: [PATCH 19/20] Missing files --- src/rust/lqos_bus/src/dynamic_circuits.rs | 87 +++ src/rust/lqos_config/src/dynamic_status.rs | 600 +++++++++++++++++++++ 2 files changed, 687 insertions(+) create mode 100644 src/rust/lqos_bus/src/dynamic_circuits.rs create mode 100644 src/rust/lqos_config/src/dynamic_status.rs diff --git a/src/rust/lqos_bus/src/dynamic_circuits.rs b/src/rust/lqos_bus/src/dynamic_circuits.rs new file mode 100644 index 00000000..9f82db87 --- /dev/null +++ b/src/rust/lqos_bus/src/dynamic_circuits.rs @@ -0,0 +1,87 @@ +//! Bus types for dynamic circuit overlay operations. +//! +//! `DynamicAddCircuit` and `DynamicRemoveCircuit` are high-level requests used +//! to manage runtime-only dynamic circuits. These operations apply to the +//! overlay state and never mutate `ShapedDevices.csv`. +//! +//! When callers omit identity fields, the daemon allocates stable defaults +//! such as `Dynamic ` for `circuit_id` and reuses that value for +//! `device_id`. + +use allocative::Allocative; +use serde::{Deserialize, Serialize}; + +/// Attachment target for a dynamic circuit overlay request. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)] +pub struct DynamicCircuitAttachmentTarget { + /// Stable topology node identifier from `network.json` or `network.effective.json`. + pub node_id: Option, + /// Human-readable topology node name used when a stable ID is unavailable. + pub node_name: Option, +} + +/// Optional caller-supplied identity fields for a dynamic circuit. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)] +pub struct DynamicCircuitIdentitySpec { + /// Circuit identifier override. The daemon allocates `Dynamic ` when omitted. + pub circuit_id: Option, + /// Device identifier override. Defaults to `circuit_id` when omitted. + pub device_id: Option, + /// Human-readable circuit display name. + pub circuit_name: Option, + /// Human-readable device display name. + pub device_name: Option, +} + +/// Rate inputs for a dynamic circuit overlay request. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Allocative)] +pub struct DynamicCircuitRates { + /// Minimum guaranteed downstream rate in Mbps. + pub download_min_mbps: f32, + /// Minimum guaranteed upstream rate in Mbps. + pub upload_min_mbps: f32, + /// Maximum downstream rate in Mbps. + pub download_max_mbps: f32, + /// Maximum upstream rate in Mbps. + pub upload_max_mbps: f32, + /// Optional SQM override such as `cake` or `fq_codel`. + pub sqm_override: Option, +} + +/// Request payload for adding a dynamic circuit overlay entry. +/// +/// This is a high-level overlay operation and never writes to +/// `ShapedDevices.csv`. When `identity` is omitted, the daemon allocates +/// defaults such as `Dynamic `. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Allocative)] +pub struct DynamicAddCircuitRequest { + /// Attachment target for the dynamic circuit. + pub attachment: DynamicCircuitAttachmentTarget, + /// Optional caller-supplied identity fields. + pub identity: Option, + /// Requested shaping rates. + pub rates: DynamicCircuitRates, + /// IPs and CIDRs owned by this dynamic circuit. + pub ip_cidrs: Vec, + /// Optional TTL in seconds. The daemon applies the default when omitted. + pub ttl_seconds: Option, +} + +/// Request payload for removing a dynamic circuit overlay entry. +/// +/// This removes only the runtime overlay entry; it never edits +/// `ShapedDevices.csv`. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)] +pub struct DynamicRemoveCircuitRequest { + /// Stable circuit identifier to remove from the dynamic overlay. + pub circuit_id: String, +} + +/// Reply payload returned after creating a dynamic circuit overlay entry. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)] +pub struct DynamicAddCircuitReply { + /// Allocated or accepted circuit identifier. + pub circuit_id: String, + /// Allocated or accepted device identifier. + pub device_id: String, +} diff --git a/src/rust/lqos_config/src/dynamic_status.rs b/src/rust/lqos_config/src/dynamic_status.rs new file mode 100644 index 00000000..c6631e72 --- /dev/null +++ b/src/rust/lqos_config/src/dynamic_status.rs @@ -0,0 +1,600 @@ +//! Dynamic circuit persistence for the runtime overlay. +//! +//! The dynamic-circuits store is an overlay on top of +//! `lqos_config::ConfigShapedDevices` and never mutates ShapedDevices.csv. +//! Static `ShapedDevices.csv` data remains authoritative; dynamic state exists +//! only to persist runtime overlay entries in `dynamic_status.json`. +//! +//! This module is intentionally small until the runtime overlay loader lands. + +use allocative::Allocative; +use serde::{Deserialize, Serialize}; +use std::{fs, path::{Path, PathBuf}}; +use thiserror::Error; + +/// File name used to persist dynamic circuit overlay state. +pub const DYNAMIC_STATUS_FILENAME: &str = "dynamic_status.json"; +/// Current supported on-disk schema version for `dynamic_status.json`. +pub const DYNAMIC_STATUS_VERSION: u32 = 1; + +/// Errors that can occur while resolving or loading `dynamic_status.json`. +#[derive(Debug, Error)] +pub enum DynamicStatusError { + /// The main LibreQoS config could not be loaded. + #[error("Unable to load LibreQoS config while resolving dynamic_status.json path")] + ConfigLoadError, + /// A `dynamic_status.json` file could not be read from disk. + #[error("Unable to read dynamic status file at {path}: {source}")] + CannotRead { + /// Full path to the file that failed. + path: String, + /// Source IO error. + source: std::io::Error, + }, + /// A `dynamic_status.json` file existed but could not be parsed. + #[error("Unable to parse dynamic status file at {path}: {details}")] + ParseError { + /// Full path to the file that failed. + path: String, + /// Parse error details. + details: String, + }, + /// A `dynamic_status.json` file could not be written to disk. + #[error("Unable to write dynamic status file at {path}: {source}")] + CannotWrite { + /// Full path to the file that failed. + path: String, + /// Source IO error. + source: std::io::Error, + }, + /// Dynamic status data could not be serialized. + #[error("Unable to serialize dynamic status file: {details}")] + SerializeError { + /// Serialization error details. + details: String, + }, + /// A dynamic circuit record is internally inconsistent or contains invalid data. + #[error("Invalid dynamic circuit record: {details}")] + InvalidRecord { + /// Specific validation or conversion details. + details: String, + }, +} + +/// Root persisted state for the dynamic circuit runtime overlay. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Allocative)] +pub struct DynamicStatusFile { + /// On-disk schema version for `dynamic_status.json`. + pub version: u32, + /// Monotonic ID allocator so `Dynamic ` values are never reused. + pub next_dynamic_id: u64, + /// Persisted dynamic circuits that can be materialized into overlay devices. + pub circuits: Vec, +} + +impl DynamicStatusFile { + /// Loads `dynamic_status.json`, returning an empty default state when the file is absent. + pub fn load_or_default() -> Result { + let path = dynamic_status_path()?; + if !path.exists() { + return Ok(Self::default()); + } + + let raw = fs::read_to_string(&path).map_err(|source| DynamicStatusError::CannotRead { + path: path.display().to_string(), + source, + })?; + let parsed: Self = serde_json::from_str(&raw).map_err(|error| DynamicStatusError::ParseError { + path: path.display().to_string(), + details: error.to_string(), + })?; + if parsed.version != DYNAMIC_STATUS_VERSION { + return Err(DynamicStatusError::InvalidRecord { + details: format!( + "Unsupported dynamic_status.json version {} at {} (expected {})", + parsed.version, + path.display(), + DYNAMIC_STATUS_VERSION + ), + }); + } + Ok(parsed) + } + + /// Saves `dynamic_status.json` using a same-directory temp file and atomic rename. + pub fn save_atomic(&self) -> Result<(), DynamicStatusError> { + let path = dynamic_status_path()?; + let tmp_path = path.with_file_name(format!("{DYNAMIC_STATUS_FILENAME}.tmp")); + let serialized = + serde_json::to_string_pretty(self).map_err(|error| DynamicStatusError::SerializeError { + details: error.to_string(), + })?; + + fs::write(&tmp_path, serialized).map_err(|source| DynamicStatusError::CannotWrite { + path: tmp_path.display().to_string(), + source, + })?; + fs::rename(&tmp_path, &path).map_err(|source| DynamicStatusError::CannotWrite { + path: path.display().to_string(), + source, + })?; + Ok(()) + } + + /// Allocates default circuit/device identifiers when callers omit them. + pub fn allocate_default_ids( + &mut self, + circuit_id: Option, + device_id: Option, + ) -> (String, String) { + let circuit_id = circuit_id.unwrap_or_else(|| { + let allocated = format!("Dynamic {}", self.next_dynamic_id); + self.next_dynamic_id += 1; + allocated + }); + let device_id = device_id.unwrap_or_else(|| circuit_id.clone()); + (circuit_id, device_id) + } +} + +/// Persisted attachment target for a dynamic circuit. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Allocative)] +pub struct DynamicAttachmentTarget { + /// Stable topology node identifier when known. + pub node_id: Option, + /// Human-readable parent node name. + pub node_name: String, +} + +/// Persisted dynamic circuit record. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Allocative)] +pub struct DynamicCircuitRecord { + /// Stable circuit identifier. + pub circuit_id: String, + /// Stable device identifier. + pub device_id: String, + /// Optional human-readable circuit name. + pub circuit_name: Option, + /// Optional human-readable device name. + pub device_name: Option, + /// Attachment target used to place the circuit in the topology. + pub attachment: DynamicAttachmentTarget, + /// Optional MAC address associated with the device. + pub mac: Option, + /// IP/CIDR strings assigned to the dynamic circuit. + pub ip_cidrs: Vec, + /// Minimum guaranteed downstream rate in Mbps. + pub download_min_mbps: f32, + /// Minimum guaranteed upstream rate in Mbps. + pub upload_min_mbps: f32, + /// Maximum downstream rate in Mbps. + pub download_max_mbps: f32, + /// Maximum upstream rate in Mbps. + pub upload_max_mbps: f32, + /// Optional SQM override. + pub sqm_override: Option, + /// TTL in seconds applied when extending expiry on activity. + pub ttl_seconds: u64, + /// Unix timestamp when the record was created. + pub created: u64, + /// Unix timestamp when the record was last observed active. + #[serde(alias = "last_seen")] + pub last_seen_at: u64, + /// Unix timestamp when the record should expire if not refreshed. + #[serde(alias = "expires")] + pub expires_at: u64, +} + +impl DynamicCircuitRecord { + /// Converts the persisted record into a `ShapedDevice` for overlay indexing. + pub fn to_shaped_device(&self) -> Result { + let mut ipv4 = Vec::new(); + let mut ipv6 = Vec::new(); + for cidr in &self.ip_cidrs { + if let Ok(parsed) = crate::ShapedDevice::parse_cidr_v4(cidr) { + ipv4.push(parsed); + continue; + } + if let Ok(parsed) = crate::ShapedDevice::parse_cidr_v6(cidr) { + ipv6.push(parsed); + continue; + } + return Err(DynamicStatusError::InvalidRecord { + details: format!("Unable to parse CIDR '{cidr}' for dynamic circuit '{}'", self.circuit_id), + }); + } + + let circuit_name = self + .circuit_name + .clone() + .unwrap_or_else(|| self.circuit_id.clone()); + let device_name = self + .device_name + .clone() + .unwrap_or_else(|| self.device_id.clone()); + let parent_node = self.attachment.node_name.clone(); + + Ok(crate::ShapedDevice { + circuit_hash: lqos_utils::hash_to_i64(&self.circuit_id), + device_hash: lqos_utils::hash_to_i64(&self.device_id), + parent_hash: lqos_utils::hash_to_i64(&parent_node), + circuit_id: self.circuit_id.clone(), + circuit_name, + device_id: self.device_id.clone(), + device_name, + parent_node, + mac: self.mac.clone().unwrap_or_default(), + ipv4, + ipv6, + download_min_mbps: self.download_min_mbps, + upload_min_mbps: self.upload_min_mbps, + download_max_mbps: self.download_max_mbps, + upload_max_mbps: self.upload_max_mbps, + comment: String::new(), + sqm_override: self.sqm_override.clone(), + }) + } +} + +fn dynamic_status_path_for_config(config: &crate::Config) -> PathBuf { + Path::new(&config.lqos_directory).join(DYNAMIC_STATUS_FILENAME) +} + +/// Resolves the active `dynamic_status.json` path from the LibreQoS config. +pub fn dynamic_status_path() -> Result { + let config = crate::load_config().map_err(|_| DynamicStatusError::ConfigLoadError)?; + Ok(dynamic_status_path_for_config(&config)) +} + +#[cfg(test)] +mod tests { + use super::{ + DYNAMIC_STATUS_FILENAME, DYNAMIC_STATUS_VERSION, DynamicAttachmentTarget, + DynamicCircuitRecord, DynamicStatusFile, + dynamic_status_path, + }; + use crate::{Config, clear_cached_config}; + use std::{ + fs, + path::PathBuf, + sync::Mutex, + sync::atomic::{AtomicU64, Ordering}, + }; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + static TEST_ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn unique_test_dir(prefix: &str) -> PathBuf { + let suffix = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("libreqos-{prefix}-{}-{suffix}", std::process::id())) + } + + #[test] + fn dynamic_status_path_ends_with_filename() { + let _guard = TEST_ENV_LOCK.lock().expect("lock test env"); + clear_cached_config(); + + let config_dir = unique_test_dir("dynamic-status-path"); + let config_path = config_dir.join("lqos.conf"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let mut config = Config::default(); + config.lqos_directory = config_dir.to_string_lossy().to_string(); + let config_toml = toml::to_string(&config).expect("serialize config"); + fs::write(&config_path, config_toml).expect("write config"); + + let old_lqos_config = std::env::var_os("LQOS_CONFIG"); + let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); + unsafe { + std::env::set_var("LQOS_CONFIG", &config_path); + std::env::set_var("LQOS_DIRECTORY", &config_dir); + } + + let resolved = dynamic_status_path().expect("dynamic_status_path"); + assert!(resolved.ends_with(DYNAMIC_STATUS_FILENAME)); + + match old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + clear_cached_config(); + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir_all(&config_dir); + } + + #[test] + fn load_or_default_missing_file_returns_empty_status() { + let _guard = TEST_ENV_LOCK.lock().expect("lock test env"); + clear_cached_config(); + + let config_dir = unique_test_dir("dynamic-status-missing"); + let config_path = config_dir.join("lqos.conf"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let mut config = Config::default(); + config.lqos_directory = config_dir.to_string_lossy().to_string(); + let config_toml = toml::to_string(&config).expect("serialize config"); + fs::write(&config_path, config_toml).expect("write config"); + + let old_lqos_config = std::env::var_os("LQOS_CONFIG"); + let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); + unsafe { + std::env::set_var("LQOS_CONFIG", &config_path); + std::env::set_var("LQOS_DIRECTORY", &config_dir); + } + + let loaded = DynamicStatusFile::load_or_default().expect("load_or_default"); + assert_eq!(loaded.circuits.len(), 0); + + match old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + clear_cached_config(); + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir_all(&config_dir); + } + + #[test] + fn save_atomic_then_load_round_trip() { + let _guard = TEST_ENV_LOCK.lock().expect("lock test env"); + clear_cached_config(); + + let config_dir = unique_test_dir("dynamic-status-save"); + let config_path = config_dir.join("lqos.conf"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let mut config = Config::default(); + config.lqos_directory = config_dir.to_string_lossy().to_string(); + let config_toml = toml::to_string(&config).expect("serialize config"); + fs::write(&config_path, config_toml).expect("write config"); + + let old_lqos_config = std::env::var_os("LQOS_CONFIG"); + let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); + unsafe { + std::env::set_var("LQOS_CONFIG", &config_path); + std::env::set_var("LQOS_DIRECTORY", &config_dir); + } + + let status = DynamicStatusFile { + version: DYNAMIC_STATUS_VERSION, + next_dynamic_id: 43, + circuits: vec![super::DynamicCircuitRecord { + circuit_id: "Dynamic 42".to_string(), + device_id: "Dynamic 42".to_string(), + circuit_name: Some("Transient Subscriber".to_string()), + device_name: Some("Transient CPE".to_string()), + attachment: super::DynamicAttachmentTarget { + node_id: Some("tower-a-sector-3".to_string()), + node_name: "Tower A / Sector 3".to_string(), + }, + mac: Some("aa:bb:cc:dd:ee:ff".to_string()), + ip_cidrs: vec!["192.0.2.10/32".to_string(), "2001:db8::1234/128".to_string()], + download_min_mbps: 25.0, + upload_min_mbps: 5.0, + download_max_mbps: 150.0, + upload_max_mbps: 30.0, + sqm_override: Some("cake".to_string()), + ttl_seconds: 3600, + created: 1_775_606_400, + last_seen_at: 1_775_608_200, + expires_at: 1_775_611_800, + }], + }; + status.save_atomic().expect("save_atomic"); + let loaded = DynamicStatusFile::load_or_default().expect("load_or_default"); + assert_eq!(loaded, status); + + match old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + clear_cached_config(); + let _ = fs::remove_file(config_dir.join(DYNAMIC_STATUS_FILENAME)); + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir_all(&config_dir); + } + + #[test] + fn allocate_default_ids_increments_next_dynamic_id() { + let mut status = DynamicStatusFile { + version: DYNAMIC_STATUS_VERSION, + next_dynamic_id: 42, + circuits: Vec::new(), + }; + + let first = status.allocate_default_ids(None, None); + let second = status.allocate_default_ids(None, None); + + assert_eq!(first.0, "Dynamic 42"); + assert_eq!(first.1, "Dynamic 42"); + assert_eq!(second.0, "Dynamic 43"); + assert_eq!(second.1, "Dynamic 43"); + assert_eq!(status.next_dynamic_id, 44); + } + + #[test] + fn to_shaped_device_sets_hashes_from_ids() { + let record = DynamicCircuitRecord { + circuit_id: "Dynamic 42".to_string(), + device_id: "Dynamic 42".to_string(), + circuit_name: Some("Transient Subscriber".to_string()), + device_name: Some("Transient CPE".to_string()), + attachment: DynamicAttachmentTarget { + node_id: Some("tower-a-sector-3".to_string()), + node_name: "Tower A / Sector 3".to_string(), + }, + mac: Some("aa:bb:cc:dd:ee:ff".to_string()), + ip_cidrs: vec!["192.0.2.10/32".to_string(), "2001:db8::1234/128".to_string()], + download_min_mbps: 25.0, + upload_min_mbps: 5.0, + download_max_mbps: 150.0, + upload_max_mbps: 30.0, + sqm_override: Some("cake".to_string()), + ttl_seconds: 3600, + created: 1_775_606_400, + last_seen_at: 1_775_608_200, + expires_at: 1_775_611_800, + }; + + let device = record.to_shaped_device().expect("to_shaped_device"); + assert_eq!(device.circuit_hash, lqos_utils::hash_to_i64(&device.circuit_id)); + } + + #[test] + fn to_shaped_device_ipv4_cidr_uses_ipv6_mapped_prefix() { + let record = DynamicCircuitRecord { + circuit_id: "Dynamic 7".to_string(), + device_id: "Dynamic 7".to_string(), + circuit_name: None, + device_name: None, + attachment: DynamicAttachmentTarget { + node_id: None, + node_name: "Orphans".to_string(), + }, + mac: None, + ip_cidrs: vec!["192.0.2.10/24".to_string()], + download_min_mbps: 10.0, + upload_min_mbps: 10.0, + download_max_mbps: 20.0, + upload_max_mbps: 20.0, + sqm_override: None, + ttl_seconds: 3600, + created: 1, + last_seen_at: 1, + expires_at: 2, + }; + + let device = record.to_shaped_device().expect("to_shaped_device"); + let ipv6_list = device.to_ipv6_list(); + assert_eq!(ipv6_list.len(), 1); + let (mapped, prefix) = ipv6_list[0]; + assert_eq!(mapped, std::net::Ipv4Addr::new(192, 0, 2, 10).to_ipv6_mapped()); + assert_eq!(prefix, 120); + } + + #[test] + fn to_shaped_device_invalid_ip_reports_offending_string() { + let record = DynamicCircuitRecord { + circuit_id: "Dynamic 99".to_string(), + device_id: "Dynamic 99".to_string(), + circuit_name: None, + device_name: None, + attachment: DynamicAttachmentTarget { + node_id: None, + node_name: "Orphans".to_string(), + }, + mac: None, + ip_cidrs: vec!["not-an-ip".to_string()], + download_min_mbps: 10.0, + upload_min_mbps: 10.0, + download_max_mbps: 20.0, + upload_max_mbps: 20.0, + sqm_override: None, + ttl_seconds: 3600, + created: 1, + last_seen_at: 1, + expires_at: 2, + }; + + let error = record.to_shaped_device().expect_err("invalid CIDR should fail"); + let message = error.to_string(); + assert!(message.contains("not-an-ip")); + } + + #[test] + fn example_fixture_parses_ttl_fields() { + let parsed: DynamicStatusFile = + serde_json::from_str(include_str!("testdata/dynamic_status.example.json")) + .expect("parse example fixture"); + assert_eq!(parsed.version, DYNAMIC_STATUS_VERSION); + assert_eq!(parsed.circuits.len(), 1); + assert_eq!(parsed.circuits[0].ttl_seconds, 3600); + assert_eq!(parsed.circuits[0].last_seen_at, 1_775_608_200); + assert_eq!(parsed.circuits[0].expires_at, 1_775_611_800); + } + + #[test] + fn legacy_ttl_field_names_still_parse() { + let parsed: DynamicStatusFile = serde_json::from_str( + r#"{ + "version": 1, + "next_dynamic_id": 2, + "circuits": [ + { + "circuit_id": "Dynamic 1", + "device_id": "Dynamic 1", + "attachment": { "node_id": null, "node_name": "AP-1" }, + "mac": null, + "ip_cidrs": ["100.64.0.10/32"], + "download_min_mbps": 25.0, + "upload_min_mbps": 10.0, + "download_max_mbps": 50.0, + "upload_max_mbps": 20.0, + "sqm_override": null, + "ttl_seconds": 3600, + "created": 1, + "last_seen": 2, + "expires": 3602 + } + ] +}"#, + ) + .expect("parse legacy ttl field names"); + assert_eq!(parsed.circuits[0].last_seen_at, 2); + assert_eq!(parsed.circuits[0].expires_at, 3602); + } + + #[test] + fn load_or_default_rejects_unsupported_version() { + let _guard = TEST_ENV_LOCK.lock().expect("lock test env"); + clear_cached_config(); + + let config_dir = unique_test_dir("dynamic-status-bad-version"); + let config_path = config_dir.join("lqos.conf"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let mut config = Config::default(); + config.lqos_directory = config_dir.to_string_lossy().to_string(); + let config_toml = toml::to_string(&config).expect("serialize config"); + fs::write(&config_path, config_toml).expect("write config"); + + let old_lqos_config = std::env::var_os("LQOS_CONFIG"); + let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); + unsafe { + std::env::set_var("LQOS_CONFIG", &config_path); + std::env::set_var("LQOS_DIRECTORY", &config_dir); + } + + let status_path = config_dir.join(DYNAMIC_STATUS_FILENAME); + fs::write( + &status_path, + r#"{"version":99,"next_dynamic_id":1,"circuits":[]}"#, + ) + .expect("write dynamic status"); + + let error = DynamicStatusFile::load_or_default().expect_err("unsupported version"); + assert!(matches!(error, super::DynamicStatusError::InvalidRecord { .. })); + + match old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + clear_cached_config(); + let _ = fs::remove_file(&status_path); + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir_all(&config_dir); + } +} From 9c23368ebcfa369b1dcba110ab0b715618b2dc0a Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 13 Apr 2026 14:25:29 -0500 Subject: [PATCH 20/20] Chore: fixup unit tests --- .../lqosd/src/shaped_devices_tracker/mod.rs | 131 +++++++++++++++++- .../src/throughput_tracker/tracking_data.rs | 16 ++- src/rust/lqosd/src/treeguard/actor.rs | 7 + 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index ec1d839b..606afa56 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -1,6 +1,6 @@ use crate::node_manager::local_api::network_tree_lite::NetworkTreeLiteNode; use crate::treeguard::actor::is_runtime_virtualized_node; -use anyhow::Result; +use anyhow::{Context, Result}; use arc_swap::ArcSwap; use fxhash::{FxHashMap, FxHashSet}; use lqos_bus::{BusResponse, Circuit}; @@ -210,6 +210,89 @@ fn shaped_devices_from_runtime_inputs( shaped } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ShapedDevicesLoadSource { + RuntimeShapingInputs, + TopologyImport, + ShapedDevicesCsv, +} + +fn integration_ingress_enabled(config: &lqos_config::Config) -> bool { + config.uisp_integration.enable_uisp + || config.splynx_integration.enable_splynx + || config + .netzur_integration + .as_ref() + .is_some_and(|integration| integration.enable_netzur) + || config + .visp_integration + .as_ref() + .is_some_and(|integration| integration.enable_visp) + || config.powercode_integration.enable_powercode + || config.sonar_integration.enable_sonar + || config + .wispgate_integration + .as_ref() + .is_some_and(|integration| integration.enable_wispgate) +} + +fn load_ready_runtime_shaping_inputs( + config: &lqos_config::Config, +) -> Result> { + let Some(active_path) = active_runtime_shaping_inputs_path(config)? else { + return Ok(None); + }; + if !active_path.exists() { + return Ok(None); + } + let raw = std::fs::read_to_string(&active_path) + .with_context(|| format!("Unable to read shaping inputs at {}", active_path.display()))?; + let shaping_inputs = serde_json::from_str(&raw).with_context(|| { + format!( + "Unable to decode shaping inputs JSON at {}", + active_path.display() + ) + })?; + Ok(Some(shaping_inputs)) +} + +fn load_shaped_devices_from_preferred_source( + config: &lqos_config::Config, +) -> Result<(ConfigShapedDevices, ShapedDevicesLoadSource)> { + if let Some(shaping_inputs) = load_ready_runtime_shaping_inputs(config)? { + return Ok(( + shaped_devices_from_runtime_inputs(&shaping_inputs), + ShapedDevicesLoadSource::RuntimeShapingInputs, + )); + } + + if integration_ingress_enabled(config) { + match lqos_topology_compile::TopologyImportFile::load(config) { + Ok(Some(topology_import)) => { + let shaped_devices = topology_import.into_imported_bundle().shaped_devices; + if !shaped_devices.devices.is_empty() { + return Ok((shaped_devices, ShapedDevicesLoadSource::TopologyImport)); + } + debug!( + "topology_import.json contained 0 shaped devices; falling back to ShapedDevices.csv" + ); + } + Ok(None) => { + debug!("topology_import.json missing; falling back to ShapedDevices.csv"); + } + Err(err) => { + debug!( + "Unable to load topology_import.json ({err}); falling back to ShapedDevices.csv" + ); + } + } + } + + let shaped_devices = ConfigShapedDevices::load_for_config(config) + .context("Unable to load ShapedDevices.csv")?; + Ok((shaped_devices, ShapedDevicesLoadSource::ShapedDevicesCsv)) +} + fn load_shaping_inputs() { let Ok(config) = load_config() else { warn!("Unable to load LibreQoS config while loading shaping_inputs.json"); @@ -1196,4 +1279,50 @@ mod tests { assert_eq!(loaded.devices.len(), 1); assert_eq!(loaded.devices[0].circuit_id, "import-circuit"); } + + #[test] + fn load_shaped_devices_falls_back_to_csv_when_topology_import_is_empty() { + let lqos_directory = unique_temp_dir("lqosd-shaped-devices-topology-import-empty"); + let state_directory = lqos_directory.join("state"); + fs::create_dir_all(state_directory.join("topology")).expect("topology dir should exist"); + + write_shaped_devices_csv( + &lqos_directory.join("ShapedDevices.csv"), + "csv-circuit", + "192.0.2.10/32", + ); + + let imported = ImportedTopologyBundle { + source: "test/import".to_string(), + generated_unix: Some(123), + ingress_identity: Some("import-base".to_string()), + native_canonical: None, + native_editor: None, + parent_candidates: None, + compatibility_network_json: json!({}), + shaped_devices: ConfigShapedDevices::default(), + circuit_anchors: CircuitAnchorsFile::default(), + ethernet_advisories: Vec::new(), + }; + TopologyImportFile::from_imported_bundle(&imported, "full") + .save(&Config { + lqos_directory: lqos_directory.to_string_lossy().to_string(), + state_directory: Some(state_directory.to_string_lossy().to_string()), + ..Config::default() + }) + .expect("topology import should save"); + + let mut config = Config { + lqos_directory: lqos_directory.to_string_lossy().to_string(), + state_directory: Some(state_directory.to_string_lossy().to_string()), + ..Config::default() + }; + config.uisp_integration.enable_uisp = true; + + let (loaded, source) = load_shaped_devices_from_preferred_source(&config) + .expect("CSV fallback should load"); + assert_eq!(source, ShapedDevicesLoadSource::ShapedDevicesCsv); + assert_eq!(loaded.devices.len(), 1); + assert_eq!(loaded.devices[0].circuit_id, "csv-circuit"); + } } diff --git a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs index bde43f1c..c6472062 100644 --- a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs +++ b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs @@ -1447,10 +1447,18 @@ mod tests { fn write_test_config(runtime_dir: &Path) -> PathBuf { let config_path = runtime_dir.join("lqos.test.toml"); - let raw = include_str!("../../../lqos_config/src/etc/v15/example.toml").replace( - "lqos_directory = \"/opt/libreqos/src\"", - &format!("lqos_directory = \"{}\"", runtime_dir.display()), - ); + let raw = include_str!("../../../lqos_config/src/etc/v15/example.toml") + .replace( + "lqos_directory = \"/opt/libreqos/src\"", + &format!("lqos_directory = \"{}\"", runtime_dir.display()), + ) + .replace( + "state_directory = \"/opt/libreqos/state\"", + &format!( + "state_directory = \"{}\"", + runtime_dir.join("state").display() + ), + ); std::fs::write(&config_path, raw).expect("test config should be written"); config_path } diff --git a/src/rust/lqosd/src/treeguard/actor.rs b/src/rust/lqosd/src/treeguard/actor.rs index 04a02240..0ffb2c65 100644 --- a/src/rust/lqosd/src/treeguard/actor.rs +++ b/src/rust/lqosd/src/treeguard/actor.rs @@ -4141,6 +4141,13 @@ mod tests { "lqos_directory = \"/opt/libreqos/src\"", &format!("lqos_directory = \"{}\"", runtime_dir.display()), ) + .replace( + "state_directory = \"/opt/libreqos/state\"", + &format!( + "state_directory = \"{}\"", + runtime_dir.join("state").display() + ), + ) .replace("use_xdp_bridge = true", "use_xdp_bridge = false") .replace( "[treeguard.links]\nenabled = true",