diff --git a/src/LibreQoS.py b/src/LibreQoS.py index e6969053..381f6bc6 100755 --- a/src/LibreQoS.py +++ b/src/LibreQoS.py @@ -74,6 +74,10 @@ class RefreshFailure(Exception): pass +class ValidationFailure(RefreshFailure): + pass + + def report_refresh_failure(code, message, context=None, dedupe_key=None): logging.error(message) print("ERROR: " + message) @@ -134,11 +138,8 @@ def get_state_path(category, filename): return os.path.join(get_state_directory(), category, filename) -def get_existing_state_path(category, filename): - preferred = get_state_path(category, filename) - if os.path.exists(preferred): - return preferred - return os.path.join(get_libreqos_directory(), filename) +def get_runtime_state_path(category, filename): + return get_state_path(category, filename) def ensure_parent_dir(path): @@ -149,11 +150,7 @@ def ensure_parent_dir(path): def get_network_json_path(): base_dir = get_libreqos_directory() - effective_path = get_existing_state_path("topology", "network.effective.json") - - if os.path.exists(effective_path): - return effective_path - + effective_path = get_runtime_state_path("topology", "network.effective.json") if topology_import_ingress_enabled(): return effective_path @@ -167,47 +164,47 @@ def get_network_json_path(): def get_shaping_inputs_path(): - return get_existing_state_path("shaping", "shaping_inputs.json") + return get_runtime_state_path("shaping", "shaping_inputs.json") def get_circuit_anchors_path(): - return get_existing_state_path("topology", "circuit_anchors.json") + return get_runtime_state_path("topology", "circuit_anchors.json") def get_planner_state_path(): - return get_existing_state_path("shaping", "planner_state.json") + return get_runtime_state_path("shaping", "planner_state.json") def get_topology_runtime_status_path(): - return get_existing_state_path("topology", "topology_runtime_status.json") + return get_runtime_state_path("topology", "topology_runtime_status.json") def get_queuing_structure_path(): - return get_existing_state_path("shaping", "queuingStructure.json") + return get_runtime_state_path("shaping", "queuingStructure.json") def get_last_run_path(): - return get_existing_state_path("stats", "lastRun.txt") + return get_runtime_state_path("stats", "lastRun.txt") def get_last_good_config_json_path(): - return get_existing_state_path("shaping", "lastGoodConfig.json") + return get_runtime_state_path("shaping", "lastGoodConfig.json") def get_last_good_config_csv_path(): - return get_existing_state_path("shaping", "lastGoodConfig.csv") + return get_runtime_state_path("shaping", "lastGoodConfig.csv") def get_last_loaded_shaped_devices_path(): - return get_existing_state_path("shaping", "ShapedDevices.lastLoaded.csv") + return get_runtime_state_path("shaping", "ShapedDevices.lastLoaded.csv") def get_stats_by_circuit_path(): - return get_existing_state_path("stats", "statsByCircuit.json") + return get_runtime_state_path("stats", "statsByCircuit.json") def get_stats_by_parent_node_path(): - return get_existing_state_path("stats", "statsByParentNode.json") + return get_runtime_state_path("stats", "statsByParentNode.json") def get_linux_tc_path(): @@ -831,34 +828,45 @@ def validateNetworkAndDevices(): warnings.warn("Rust failed to validate ShapedDevices.csv", stacklevel=2) warnings.warn(rustValid, stacklevel=2) devicesValidatedOrNot = False - with open(get_network_json_path()) as file: - try: - data = json.load(file) # put JSON-data to a variable - if data != {}: - #Traverse - observedNodes = set() - duplicateNodes = set() - def traverseToVerifyValidity(data): - for elem in data: - if isinstance(elem, str): - if (isinstance(data[elem], dict)) and (elem != 'children'): - if elem not in observedNodes: - observedNodes.add(elem) - if 'children' in data[elem]: - traverseToVerifyValidity(data[elem]['children']) - else: - duplicateNodes.add(elem) - traverseToVerifyValidity(data) - if len(duplicateNodes) > 0: - for elem in sorted(duplicateNodes): - warnings.warn("Non-unique Node name in network.json: " + elem, stacklevel=2) - networkValidatedOrNot = False - if len(observedNodes) < 1: - warnings.warn("network.json had 0 valid nodes. Only {} is accepted for that scenario.", stacklevel=2) - networkValidatedOrNot = False - except json.decoder.JSONDecodeError: - warnings.warn("network.json is an invalid JSON file", stacklevel=2) # in case json is invalid - networkValidatedOrNot = False + network_json_path = get_network_json_path() + if not os.path.isfile(network_json_path): + if integration_ingress: + warnings.warn( + f"Integration topology has not published an effective network tree yet: {network_json_path}", + stacklevel=2, + ) + else: + warnings.warn(f"network.json is missing: {network_json_path}", stacklevel=2) + networkValidatedOrNot = False + else: + with open(network_json_path) as file: + try: + data = json.load(file) # put JSON-data to a variable + if data != {}: + #Traverse + observedNodes = set() + duplicateNodes = set() + def traverseToVerifyValidity(data): + for elem in data: + if isinstance(elem, str): + if (isinstance(data[elem], dict)) and (elem != 'children'): + if elem not in observedNodes: + observedNodes.add(elem) + if 'children' in data[elem]: + traverseToVerifyValidity(data[elem]['children']) + else: + duplicateNodes.add(elem) + traverseToVerifyValidity(data) + if len(duplicateNodes) > 0: + for elem in sorted(duplicateNodes): + warnings.warn("Non-unique Node name in network.json: " + elem, stacklevel=2) + networkValidatedOrNot = False + if len(observedNodes) < 1: + warnings.warn("network.json had 0 valid nodes. Only {} is accepted for that scenario.", stacklevel=2) + networkValidatedOrNot = False + except json.decoder.JSONDecodeError: + warnings.warn("network.json is an invalid JSON file", stacklevel=2) # in case json is invalid + networkValidatedOrNot = False if integration_ingress: if devicesValidatedOrNot == True: print("integration shaping ingress passed validation") @@ -1298,11 +1306,13 @@ def refreshShapers(): safeToRunRefresh = True else: if topology_import_ingress_enabled(): - warnings.warn("Validation failed for integration ingress/runtime artifacts - will now exit.", stacklevel=2) - safeToRunRefresh = False + message = "Validation failed for integration ingress/runtime artifacts - will now exit." + warnings.warn(message, stacklevel=2) + raise ValidationFailure(message) elif (isThisFirstRunSinceBoot == False): - warnings.warn("Validation failed. Because this is not the first run since boot (queues already set up) - will now exit.", stacklevel=2) - safeToRunRefresh = False + message = "Validation failed. Because this is not the first run since boot (queues already set up) - will now exit." + warnings.warn(message, stacklevel=2) + raise ValidationFailure(message) else: warnings.warn("Validation failed. However - because this is the first run since boot - will load queues from last good config", stacklevel=2) shapedDevicesFile = get_last_good_config_csv_path() @@ -2853,7 +2863,9 @@ def refreshShapersUpdateOnly(): if (validateNetworkAndDevices() == True): safeToRunRefresh = True else: - warnings.warn("Validation failed. Will now exit.", stacklevel=2) + message = "Validation failed. Will now exit." + warnings.warn(message, stacklevel=2) + raise ValidationFailure(message) if safeToRunRefresh == True: networkChanged = False diff --git a/src/build_dpkg.sh b/src/build_dpkg.sh index 39c04614..c7c007c6 100755 --- a/src/build_dpkg.sh +++ b/src/build_dpkg.sh @@ -124,6 +124,22 @@ cat <<'EOF' > postinst #!/bin/bash set -euo pipefail +set_libreqos_operator_permissions() { +local runtime_paths=() +[ -e /opt/libreqos/src ] && runtime_paths+=("/opt/libreqos/src") +[ -e /opt/libreqos/state ] && runtime_paths+=("/opt/libreqos/state") +[ ${#runtime_paths[@]} -eq 0 ] && return + +if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && id "$SUDO_USER" >/dev/null 2>&1; then + chown -R "$SUDO_USER:$SUDO_USER" "${runtime_paths[@]}" + chmod -R u+rwX "${runtime_paths[@]}" || true + echo "Granted $SUDO_USER ownership of /opt/libreqos/src and /opt/libreqos/state for SFTP editing." +else + echo "Unable to determine the installing operator account automatically." + echo "If you plan to edit LibreQoS files over SFTP, run: sudo chown -R : /opt/libreqos/src /opt/libreqos/state" +fi +} + # Install Python Dependencies pushd /opt/libreqos > /dev/null # - Setup Python dependencies as a post-install task. Use --ignore-installed so @@ -136,7 +152,7 @@ PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install --ignore-installed -r src/req fi # Ensure folder permissions are correct post-install -sudo chown -R root:root /opt/libreqos +set_libreqos_operator_permissions # - Setup the services install -m 0644 /opt/libreqos/src/bin/lqosd.service.example /etc/systemd/system/lqosd.service diff --git a/src/build_rust.sh b/src/build_rust.sh index 097167cc..35415a99 100755 --- a/src/build_rust.sh +++ b/src/build_rust.sh @@ -140,6 +140,24 @@ mv liblqos_python.so.new liblqos_python.so echo "Updating lqos_api binary..." bash ./update_api.sh || echo "Warning: Failed to update lqos_api (continuing)." +set_libreqos_operator_permissions() { + local runtime_paths=() + [ -e /opt/libreqos/src ] && runtime_paths+=("/opt/libreqos/src") + [ -e /opt/libreqos/state ] && runtime_paths+=("/opt/libreqos/state") + [ ${#runtime_paths[@]} -eq 0 ] && return + + if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && id "$SUDO_USER" >/dev/null 2>&1; then + sudo chown -R "$SUDO_USER:$SUDO_USER" "${runtime_paths[@]}" + sudo chmod -R u+rwX "${runtime_paths[@]}" || true + echo "Granted $SUDO_USER ownership of /opt/libreqos/src and /opt/libreqos/state for SFTP editing." + else + echo "Unable to determine the installing operator account automatically." + echo "If you plan to edit LibreQoS files over SFTP, run: sudo chown -R : /opt/libreqos/src /opt/libreqos/state" + fi +} + +set_libreqos_operator_permissions + # If we're running systemd, we need to restart processes service_exists() { local n=$1 diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs index a2488ff7..59a522c8 100644 --- a/src/rust/lqos_bakery/src/lib.rs +++ b/src/rust/lqos_bakery/src/lib.rs @@ -3170,38 +3170,7 @@ fn migration_target_label(migration: &Migration) -> String { } fn runtime_network_json_path(config: &Config) -> std::path::PathBuf { - let base_path = Path::new(&config.lqos_directory); - let effective_path = config.topology_state_read_path("network.effective.json"); - let integration_ingress_enabled = 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); - if effective_path.exists() || integration_ingress_enabled { - return effective_path; - } - if config - .long_term_stats - .enable_insight_topology - .unwrap_or(false) - { - let tmp_path = base_path.join("network.insight.json"); - if tmp_path.exists() { - return tmp_path; - } - } - base_path.join("network.json") + lqos_config::NetworkJson::path_for_config(config) } fn runtime_hash_to_i64(text: &str) -> i64 { diff --git a/src/rust/lqos_config/src/etc/etclqos_migration.rs b/src/rust/lqos_config/src/etc/etclqos_migration.rs index ac3b7725..aa757f2a 100644 --- a/src/rust/lqos_config/src/etc/etclqos_migration.rs +++ b/src/rust/lqos_config/src/etc/etclqos_migration.rs @@ -308,8 +308,8 @@ mod test { .filter(|line| !line.trim().starts_with("set_cpu_governor_performance")) .collect::>() .join("\n"); - let config = super::EtcLqos::load_from_string(&raw) - .expect("Unable to read legacy config fixture"); + let config = + super::EtcLqos::load_from_string(&raw).expect("Unable to read legacy config fixture"); assert!( config .tuning diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs index 4a0995c8..a796e2c8 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -21,9 +21,8 @@ mod v15; pub use v15::{ BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode, MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig, - StormguardStrategy, TopologyConfig, - TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, - TreeguardLinksConfig, TreeguardQooConfig, Tunables, + StormguardStrategy, TopologyConfig, 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/top_config.rs b/src/rust/lqos_config/src/etc/v15/top_config.rs index 6d127808..cc6f7d70 100644 --- a/src/rust/lqos_config/src/etc/v15/top_config.rs +++ b/src/rust/lqos_config/src/etc/v15/top_config.rs @@ -461,44 +461,24 @@ impl Config { Path::new(&self.lqos_directory).join(filename) } - /// Returns the preferred read path for a topology runtime file, with legacy fallback. + /// Returns the canonical read path for a topology runtime file. pub fn topology_state_read_path(&self, filename: &str) -> PathBuf { - let preferred = self.topology_state_file_path(filename); - if preferred.exists() { - preferred - } else { - self.legacy_runtime_file_path(filename) - } + self.topology_state_file_path(filename) } - /// Returns the preferred read path for a shaping runtime file, with legacy fallback. + /// Returns the canonical read path for a shaping runtime file. pub fn shaping_state_read_path(&self, filename: &str) -> PathBuf { - let preferred = self.shaping_state_file_path(filename); - if preferred.exists() { - preferred - } else { - self.legacy_runtime_file_path(filename) - } + self.shaping_state_file_path(filename) } - /// Returns the preferred read path for a stats runtime file, with legacy fallback. + /// Returns the canonical read path for a stats runtime file. pub fn stats_state_read_path(&self, filename: &str) -> PathBuf { - let preferred = self.stats_state_file_path(filename); - if preferred.exists() { - preferred - } else { - self.legacy_runtime_file_path(filename) - } + self.stats_state_file_path(filename) } - /// Returns the preferred read path for a cache runtime file, with legacy fallback. + /// Returns the canonical read path for a cache runtime file. pub fn cache_state_read_path(&self, filename: &str) -> PathBuf { - let preferred = self.cache_state_file_path(filename); - if preferred.exists() { - preferred - } else { - self.legacy_runtime_file_path(filename) - } + self.cache_state_file_path(filename) } /// Returns the explicitly configured shared topology compile mode, when set. diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index 7be006d7..2e54fd6b 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -41,12 +41,12 @@ pub use cpu_topology::{ CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus, }; pub use etc::{ - BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode, QueueMode, - MikrotikIpv6Config, RttThresholds, SingleInterfaceConfig, StormguardConfig, - StormguardStrategy, TopologyConfig, - 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, + MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig, + StormguardStrategy, TopologyConfig, 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, @@ -95,12 +95,57 @@ pub use topology_runtime_state::{ TopologyAttachmentHealthStateFile, TopologyEffectiveAttachmentState, TopologyEffectiveNodeState, TopologyEffectiveStateFile, TopologyRuntimeStateError, TopologyRuntimeStatusFile, TopologyShapingCircuitInput, TopologyShapingDeviceInput, - TopologyShapingInputsFile, TopologyShapingResolutionSource, + TopologyShapingInputsFile, TopologyShapingResolutionSource, active_runtime_shaping_inputs_path, compute_effective_network_generation, compute_topology_source_generation, - topology_attachment_health_state_path, topology_compiled_shaping_path, - topology_effective_network_path, topology_effective_state_path, topology_import_path, - topology_runtime_status_path, topology_shaping_inputs_path, + load_active_runtime_shaping_inputs, topology_attachment_health_state_path, + topology_compiled_shaping_path, topology_effective_network_path, topology_effective_state_path, + topology_import_path, topology_runtime_status_path, topology_shaping_inputs_path, }; +/// Returns whether an integration-backed topology ingress is enabled in the +/// current config. +/// +/// This function is pure: it has no side effects. +pub fn integration_ingress_enabled(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) +} + +/// Returns whether the current integration import artifact advertises any shaped devices. +/// +/// This is a lightweight readiness predicate used by migration and runtime callers that need to +/// know whether integration publication has taken over from legacy manual files. +/// +/// This function is pure: it has no side effects. +pub fn topology_import_has_shaped_devices(config: &Config) -> bool { + let import_path = config.topology_state_file_path(TOPOLOGY_IMPORT_FILENAME); + let Ok(raw) = std::fs::read_to_string(&import_path) else { + return false; + }; + let Ok(decoded) = serde_json::from_str::(&raw) else { + return false; + }; + + decoded + .get("imported") + .and_then(|imported| imported.get("shaped_devices")) + .and_then(serde_json::Value::as_array) + .is_some_and(|devices| !devices.is_empty()) +} + /// Used as a constant in determining buffer preallocation pub const SUPPORTED_CUSTOMERS: usize = 100_000; diff --git a/src/rust/lqos_config/src/network_json.rs b/src/rust/lqos_config/src/network_json.rs index a25b198a..500584a3 100644 --- a/src/rust/lqos_config/src/network_json.rs +++ b/src/rust/lqos_config/src/network_json.rs @@ -47,28 +47,17 @@ impl NetworkJson { (self.nodes.len(), self.nodes.capacity()) } - fn path_for_config(cfg: &crate::Config) -> PathBuf { + /// Resolves the active `network.json` path for one concrete config snapshot. + /// + /// When an integration-backed topology ingress is enabled, this prefers the + /// runtime-generated effective tree under `state/topology/`. When integration + /// ingress is disabled, this uses the operator-owned manual tree and ignores + /// any stale runtime effective export. + /// + /// This function is pure: it has no side effects. + pub fn path_for_config(cfg: &crate::Config) -> PathBuf { let base_path = Path::new(&cfg.lqos_directory); - let effective_path = cfg.topology_state_read_path("network.effective.json"); - let integration_ingress_enabled = cfg.uisp_integration.enable_uisp - || cfg.splynx_integration.enable_splynx - || cfg - .netzur_integration - .as_ref() - .is_some_and(|integration| integration.enable_netzur) - || cfg - .visp_integration - .as_ref() - .is_some_and(|integration| integration.enable_visp) - || cfg.powercode_integration.enable_powercode - || cfg.sonar_integration.enable_sonar - || cfg - .wispgate_integration - .as_ref() - .is_some_and(|integration| integration.enable_wispgate); - if effective_path.exists() { - effective_path - } else if integration_ingress_enabled { + if crate::integration_ingress_enabled(cfg) { cfg.topology_state_file_path("network.effective.json") } else if cfg.long_term_stats.enable_insight_topology.unwrap_or(false) { let tmp_path = base_path.join("network.insight.json"); @@ -936,7 +925,9 @@ mod test { #[test] fn path_for_config_prefers_runtime_effective_tree_over_other_tree_inputs() { let temp = unique_temp_dir("network-json-effective-priority"); - std::fs::write(temp.join("network.effective.json"), "{}") + let state_topology = temp.join("state/topology"); + std::fs::create_dir_all(&state_topology).expect("state topology dir should exist"); + std::fs::write(state_topology.join("network.effective.json"), "{}") .expect("effective tree should write"); std::fs::write(temp.join("network.insight.json"), "{}").expect("insight tree should write"); std::fs::write(temp.join("network.json"), "{}").expect("legacy tree should write"); @@ -945,10 +936,30 @@ mod test { state_directory: None, ..crate::Config::default() }; + config.uisp_integration.enable_uisp = true; config.long_term_stats.enable_insight_topology = Some(true); let selected = NetworkJson::path_for_config(&config); - assert_eq!(selected, temp.join("network.effective.json")); + assert_eq!(selected, state_topology.join("network.effective.json")); + } + + #[test] + fn path_for_config_ignores_stale_runtime_effective_tree_in_manual_mode() { + let temp = unique_temp_dir("network-json-manual-ignores-stale-effective"); + let state_topology = temp.join("state/topology"); + std::fs::create_dir_all(&state_topology).expect("state topology dir should exist"); + std::fs::write(state_topology.join("network.effective.json"), "{}") + .expect("effective tree should write"); + std::fs::write(temp.join("network.json"), "{}").expect("manual tree should write"); + + let config = crate::Config { + lqos_directory: temp.display().to_string(), + state_directory: None, + ..crate::Config::default() + }; + + let selected = NetworkJson::path_for_config(&config); + assert_eq!(selected, temp.join("network.json")); } #[test] diff --git a/src/rust/lqos_config/src/runtime_state_migration.rs b/src/rust/lqos_config/src/runtime_state_migration.rs index a08f6f0d..e23730cf 100644 --- a/src/rust/lqos_config/src/runtime_state_migration.rs +++ b/src/rust/lqos_config/src/runtime_state_migration.rs @@ -79,6 +79,7 @@ pub(crate) fn migrate_legacy_runtime_state( migrate_legacy_token_caches(config, &mut migrated, &mut removed_duplicate_legacy)?; quarantine_legacy_backups(config, &mut quarantined)?; + quarantine_stale_manual_source_files(config, &mut quarantined)?; if !migrated.is_empty() { info!( @@ -360,6 +361,35 @@ fn quarantine_legacy_backups( Ok(()) } +fn quarantine_stale_manual_source_files( + config: &Config, + quarantined: &mut Vec, +) -> Result<(), RuntimeStateMigrationError> { + if !integration_topology_import_is_ready(config) { + return Ok(()); + } + + for name in ["network.json", "ShapedDevices.csv"] { + let source = config.legacy_runtime_file_path(name); + if !source.is_file() { + continue; + } + let destination = unique_quarantine_destination(config, name); + move_path(&source, &destination)?; + quarantined.push(format!( + "{} -> {} (integration ingress active)", + source.display(), + destination.display() + )); + } + + Ok(()) +} + +fn integration_topology_import_is_ready(config: &Config) -> bool { + crate::integration_ingress_enabled(config) && crate::topology_import_has_shaped_devices(config) +} + fn is_obsolete_legacy_backup(name: &str) -> bool { (name.starts_with("network.json.treeguard-polluted.") && name.ends_with(".bak")) || name.starts_with("network.json.pre_") @@ -487,7 +517,7 @@ fn copy_dir_all(source: &Path, destination: &Path) -> Result<(), RuntimeStateMig #[cfg(test)] mod tests { use super::migrate_legacy_runtime_state; - use crate::Config; + use crate::{Config, TOPOLOGY_IMPORT_FILENAME}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -637,4 +667,81 @@ mod tests { let _ = fs::remove_dir_all(root); } + + #[test] + fn quarantines_stale_manual_source_files_when_topology_import_is_ready() { + let root = temp_path("integration-source-quarantine"); + let src = root.join("src"); + fs::create_dir_all(&src).expect("test should create src dir"); + fs::write(src.join("network.json"), "{\"Root\":{}}\n") + .expect("test should write legacy network source file"); + fs::write(src.join("ShapedDevices.csv"), "Circuit ID,Device ID\n") + .expect("test should write legacy shaped devices source file"); + + let mut config = test_config(&root); + config.uisp_integration.enable_uisp = true; + let topology_state = config.resolved_state_directory().join("topology"); + fs::create_dir_all(&topology_state).expect("test should create topology state dir"); + fs::write( + topology_state.join(TOPOLOGY_IMPORT_FILENAME), + serde_json::json!({ + "schema_version": 1, + "source": "test/import", + "compile_mode": "full", + "imported": { + "source": "test/import", + "compatibility_network_json": {}, + "shaped_devices": [ + { + "circuit_id": "circuit-1", + "device_id": "device-1" + } + ], + "circuit_anchors": { "anchors": [] }, + "ethernet_advisories": [] + } + }) + .to_string(), + ) + .expect("test should write topology import artifact"); + + migrate_legacy_runtime_state(&config).expect("stale manual source files should quarantine"); + + assert!(!src.join("network.json").exists()); + assert!(!src.join("ShapedDevices.csv").exists()); + assert!(root.join("state/quarantine/legacy/network.json").exists()); + assert!( + root.join("state/quarantine/legacy/ShapedDevices.csv") + .exists() + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn keeps_manual_source_files_in_place_when_topology_import_is_missing() { + let root = temp_path("manual-source-preserved"); + let src = root.join("src"); + fs::create_dir_all(&src).expect("test should create src dir"); + fs::write(src.join("network.json"), "{\"Root\":{}}\n") + .expect("test should write manual network source file"); + fs::write(src.join("ShapedDevices.csv"), "Circuit ID,Device ID\n") + .expect("test should write manual shaped devices source file"); + + let mut config = test_config(&root); + config.uisp_integration.enable_uisp = true; + migrate_legacy_runtime_state(&config) + .expect("manual source files should remain until integration publishes data"); + + assert!(src.join("network.json").exists()); + assert!(src.join("ShapedDevices.csv").exists()); + assert!(!root.join("state/quarantine/legacy/network.json").exists()); + assert!( + !root + .join("state/quarantine/legacy/ShapedDevices.csv") + .exists() + ); + + let _ = fs::remove_dir_all(root); + } } diff --git a/src/rust/lqos_config/src/topology_canonical_state.rs b/src/rust/lqos_config/src/topology_canonical_state.rs index 2e253342..7fff434b 100644 --- a/src/rust/lqos_config/src/topology_canonical_state.rs +++ b/src/rust/lqos_config/src/topology_canonical_state.rs @@ -280,7 +280,7 @@ pub(crate) fn current_topology_ingress_identity( ) -> Result, TopologyCanonicalStateError> { let integration_ingress = topology_import_ingress_enabled(config); if integration_ingress { - let topology_import_path = Path::new(&config.lqos_directory).join(TOPOLOGY_IMPORT_FILENAME); + let topology_import_path = config.topology_state_file_path(TOPOLOGY_IMPORT_FILENAME); if topology_import_path.exists() { let raw = std::fs::read_to_string(topology_import_path)?; let imported = serde_json::from_str::(&raw)?; @@ -1438,8 +1438,10 @@ mod tests { .expect("network json should serialize"), ) .expect("network json should write"); + let topology_state_dir = lqos_directory.join("state/topology"); + fs::create_dir_all(&topology_state_dir).expect("topology state dir should exist"); fs::write( - lqos_directory.join(TOPOLOGY_CANONICAL_STATE_FILENAME), + topology_state_dir.join(TOPOLOGY_CANONICAL_STATE_FILENAME), serde_json::to_string_pretty(&TopologyCanonicalStateFile::from_legacy_network_json( &json!({ "Old AP": { @@ -1455,12 +1457,12 @@ mod tests { ) .expect("canonical state should write"); fs::write( - lqos_directory.join(TOPOLOGY_EDITOR_STATE_FILENAME), + topology_state_dir.join(TOPOLOGY_EDITOR_STATE_FILENAME), "{\"schema_version\":1,\"source\":\"legacy:test\",\"nodes\":[{\"node_id\":\"uisp:device:old-ap\",\"node_name\":\"Old AP\"}]}", ) .expect("editor state should write"); fs::write( - lqos_directory.join(TOPOLOGY_EFFECTIVE_NETWORK_FILENAME), + topology_state_dir.join(TOPOLOGY_EFFECTIVE_NETWORK_FILENAME), "{\"Old AP\":{\"children\":{}}}", ) .expect("effective network should write"); @@ -1477,13 +1479,17 @@ mod tests { assert!(loaded.find_node("uisp:device:current-ap").is_some()); assert!(loaded.find_node("uisp:device:old-ap").is_none()); assert!( - !lqos_directory + !topology_state_dir .join(TOPOLOGY_CANONICAL_STATE_FILENAME) .exists() ); - assert!(!lqos_directory.join(TOPOLOGY_EDITOR_STATE_FILENAME).exists()); assert!( - !lqos_directory + !topology_state_dir + .join(TOPOLOGY_EDITOR_STATE_FILENAME) + .exists() + ); + assert!( + !topology_state_dir .join(TOPOLOGY_EFFECTIVE_NETWORK_FILENAME) .exists() ); @@ -1537,8 +1543,10 @@ mod tests { .expect("network json should serialize"), ) .expect("network json should write"); + let topology_state_dir = lqos_directory.join("state/topology"); + fs::create_dir_all(&topology_state_dir).expect("topology state dir should exist"); fs::write( - lqos_directory.join(TOPOLOGY_PARENT_CANDIDATES_FILENAME), + topology_state_dir.join(TOPOLOGY_PARENT_CANDIDATES_FILENAME), serde_json::to_string_pretty(&TopologyParentCandidatesFile { source: "uisp/full2".to_string(), ingress_identity: Some(ingress_identity.clone()), @@ -1579,12 +1587,12 @@ mod tests { compatibility_network_json: json!({}), }; fs::write( - lqos_directory.join(TOPOLOGY_CANONICAL_STATE_FILENAME), + topology_state_dir.join(TOPOLOGY_CANONICAL_STATE_FILENAME), serde_json::to_string_pretty(&canonical).expect("canonical should serialize"), ) .expect("canonical should write"); fs::write( - lqos_directory.join(TOPOLOGY_EDITOR_STATE_FILENAME), + topology_state_dir.join(TOPOLOGY_EDITOR_STATE_FILENAME), serde_json::to_string_pretty(&TopologyEditorStateFile { schema_version: 1, source: "uisp/ap_site".to_string(), @@ -1623,11 +1631,15 @@ mod tests { assert!(loaded.find_node("uisp:device:current-ap").is_some()); assert!( - lqos_directory + topology_state_dir .join(TOPOLOGY_CANONICAL_STATE_FILENAME) .exists() ); - assert!(lqos_directory.join(TOPOLOGY_EDITOR_STATE_FILENAME).exists()); + assert!( + topology_state_dir + .join(TOPOLOGY_EDITOR_STATE_FILENAME) + .exists() + ); let quarantine_directory = config.quarantine_state_directory_path(); let quarantined = if quarantine_directory.exists() { fs::read_dir(&quarantine_directory) @@ -1653,8 +1665,10 @@ mod tests { #[test] fn current_topology_ingress_identity_prefers_topology_import_artifact() { let lqos_directory = unique_temp_dir("lqos-config-topology-import-identity"); + let topology_state_dir = lqos_directory.join("state/topology"); + fs::create_dir_all(&topology_state_dir).expect("topology state dir should exist"); fs::write( - lqos_directory.join(TOPOLOGY_IMPORT_FILENAME), + topology_state_dir.join(TOPOLOGY_IMPORT_FILENAME), serde_json::to_string_pretty(&json!({ "schema_version": 1, "source": "uisp/full2", diff --git a/src/rust/lqos_config/src/topology_runtime_state.rs b/src/rust/lqos_config/src/topology_runtime_state.rs index 4bf53b5d..3bdb1e64 100644 --- a/src/rust/lqos_config/src/topology_runtime_state.rs +++ b/src/rust/lqos_config/src/topology_runtime_state.rs @@ -360,25 +360,6 @@ fn default_runtime_schema_version() -> u32 { 1 } -fn topology_import_ingress_enabled(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 operator_overrides_path(config: &Config) -> PathBuf { Path::new(&config.lqos_directory).join(OPERATOR_OVERRIDES_FILENAME) } @@ -437,6 +418,44 @@ pub fn topology_shaping_inputs_path(config: &Config) -> PathBuf { config.shaping_state_read_path(TOPOLOGY_SHAPING_INPUTS_FILENAME) } +/// Returns the currently active runtime shaping-inputs path when the topology +/// runtime has published a ready generation that matches the current source +/// inputs. +/// +/// This function is pure: it has no side effects. +pub fn active_runtime_shaping_inputs_path( + config: &Config, +) -> Result, TopologyRuntimeStateError> { + let current_generation = compute_topology_source_generation(config)?; + let status = TopologyRuntimeStatusFile::load(config)?; + if !status.ready { + return Ok(None); + } + if status.shaping_generation.trim().is_empty() { + return Ok(None); + } + if status.source_generation.trim() != current_generation { + return Ok(None); + } + let path = status.shaping_inputs_path.trim(); + if path.is_empty() { + return Ok(None); + } + Ok(Some(PathBuf::from(path))) +} + +/// Loads the currently active runtime shaping-inputs file when the topology +/// runtime has published one. +pub fn load_active_runtime_shaping_inputs( + config: &Config, +) -> Result, TopologyRuntimeStateError> { + let Some(path) = active_runtime_shaping_inputs_path(config)? else { + return Ok(None); + }; + let raw = std::fs::read_to_string(&path)?; + Ok(Some(serde_json::from_str(&raw)?)) +} + /// Returns the path of the topology runtime readiness status file. pub fn topology_runtime_status_path(config: &Config) -> PathBuf { config.topology_state_read_path(TOPOLOGY_RUNTIME_STATUS_FILENAME) @@ -494,7 +513,7 @@ pub fn compute_topology_source_generation( let topology_import_path = config.topology_state_read_path(TOPOLOGY_IMPORT_FILENAME); let topology_compiled_shaping_path = config.shaping_state_read_path(TOPOLOGY_COMPILED_SHAPING_FILENAME); - let use_topology_import = topology_import_ingress_enabled(config); + let use_topology_import = crate::integration_ingress_enabled(config); let network_path = config.legacy_runtime_file_path("network.json"); let shaped_devices_path = config.legacy_runtime_file_path("ShapedDevices.csv"); let circuit_anchors_path = config.topology_state_read_path(CIRCUIT_ANCHORS_FILENAME); @@ -812,8 +831,15 @@ mod tests { let before = compute_topology_source_generation(&config) .expect("generation should compute before source change"); + let circuit_anchors_path = config.topology_state_file_path(CIRCUIT_ANCHORS_FILENAME); + fs::create_dir_all( + circuit_anchors_path + .parent() + .expect("circuit anchors path should have a parent"), + ) + .expect("topology state dir should exist"); fs::write( - lqos_directory.join(CIRCUIT_ANCHORS_FILENAME), + circuit_anchors_path, "{\"schema_version\":1,\"source\":\"test\",\"generated_unix\":1,\"anchors\":[]}\n", ) .expect("circuit_anchors.json should write"); @@ -822,7 +848,7 @@ mod tests { assert_ne!(before, after_anchors); fs::write( - lqos_directory.join(TOPOLOGY_CANONICAL_STATE_FILENAME), + config.topology_state_file_path(TOPOLOGY_CANONICAL_STATE_FILENAME), concat!( "{\"schema_version\":1,\"source\":\"test\",\"generated_unix\":1,", "\"ingress_kind\":\"native_integration\",", @@ -845,8 +871,10 @@ mod tests { fn topology_source_generation_prefers_topology_import_over_network_json() { let lqos_directory = unique_temp_dir("lqos-config-topology-generation-import"); write_required_inputs(&lqos_directory); + let state_topology_dir = lqos_directory.join("state/topology"); + fs::create_dir_all(&state_topology_dir).expect("topology state dir should exist"); fs::write( - lqos_directory.join(TOPOLOGY_IMPORT_FILENAME), + state_topology_dir.join(TOPOLOGY_IMPORT_FILENAME), serde_json::to_string_pretty(&json!({ "schema_version": 1, "source": "uisp/full2", @@ -937,8 +965,10 @@ mod tests { fn topology_source_generation_tracks_override_files_for_integration_ingress() { let lqos_directory = unique_temp_dir("lqos-config-topology-generation-override-files"); write_required_inputs(&lqos_directory); + let state_topology_dir = lqos_directory.join("state/topology"); + fs::create_dir_all(&state_topology_dir).expect("topology state dir should exist"); fs::write( - lqos_directory.join(TOPOLOGY_IMPORT_FILENAME), + state_topology_dir.join(TOPOLOGY_IMPORT_FILENAME), serde_json::to_string_pretty(&json!({ "schema_version": 1, "source": "python/splynx", @@ -998,8 +1028,12 @@ mod tests { fn topology_source_generation_tracks_compiled_shaping_for_integration_ingress() { let lqos_directory = unique_temp_dir("lqos-config-topology-generation-compiled-shaping"); write_required_inputs(&lqos_directory); + let state_topology_dir = lqos_directory.join("state/topology"); + let state_shaping_dir = lqos_directory.join("state/shaping"); + fs::create_dir_all(&state_topology_dir).expect("topology state dir should exist"); + fs::create_dir_all(&state_shaping_dir).expect("shaping state dir should exist"); fs::write( - lqos_directory.join(TOPOLOGY_IMPORT_FILENAME), + state_topology_dir.join(TOPOLOGY_IMPORT_FILENAME), serde_json::to_string_pretty(&json!({ "schema_version": 1, "source": "uisp/full2", @@ -1025,7 +1059,7 @@ mod tests { ) .expect("topology import should write"); fs::write( - lqos_directory.join(TOPOLOGY_COMPILED_SHAPING_FILENAME), + state_shaping_dir.join(TOPOLOGY_COMPILED_SHAPING_FILENAME), serde_json::to_string_pretty(&json!({ "schema_version": 1, "source": "uisp/full", @@ -1053,7 +1087,7 @@ mod tests { let before = compute_topology_source_generation(&config) .expect("generation should compute before compiled shaping change"); fs::write( - lqos_directory.join(TOPOLOGY_COMPILED_SHAPING_FILENAME), + state_shaping_dir.join(TOPOLOGY_COMPILED_SHAPING_FILENAME), serde_json::to_string_pretty(&json!({ "schema_version": 1, "source": "uisp/full", @@ -1100,8 +1134,10 @@ mod tests { fn topology_source_generation_ignores_shaped_devices_csv_for_integration_ingress() { let lqos_directory = unique_temp_dir("lqos-config-topology-generation-no-shaped-devices"); write_required_inputs(&lqos_directory); + let state_topology_dir = lqos_directory.join("state/topology"); + fs::create_dir_all(&state_topology_dir).expect("topology state dir should exist"); fs::write( - lqos_directory.join(TOPOLOGY_IMPORT_FILENAME), + state_topology_dir.join(TOPOLOGY_IMPORT_FILENAME), serde_json::to_string_pretty(&json!({ "schema_version": 1, "source": "uisp/full2", @@ -1185,14 +1221,16 @@ mod tests { "uploadBandwidthMbps": 50 } })); + let state_topology_dir = lqos_directory.join("state/topology"); + fs::create_dir_all(&state_topology_dir).expect("topology state dir should exist"); fs::write( - lqos_directory.join(TOPOLOGY_CANONICAL_STATE_FILENAME), + state_topology_dir.join(TOPOLOGY_CANONICAL_STATE_FILENAME), serde_json::to_string_pretty(&stale_canonical) .expect("canonical json should serialize"), ) .expect("canonical state should write"); fs::write( - lqos_directory.join(TOPOLOGY_EDITOR_STATE_FILENAME), + state_topology_dir.join(TOPOLOGY_EDITOR_STATE_FILENAME), serde_json::to_string_pretty(&TopologyEditorStateFile { schema_version: 1, source: "uisp/full2".to_string(), diff --git a/src/rust/lqos_network_devices/README.md b/src/rust/lqos_network_devices/README.md index 2f12b450..4f8215e0 100644 --- a/src/rust/lqos_network_devices/README.md +++ b/src/rust/lqos_network_devices/README.md @@ -2,6 +2,7 @@ Centralized, shared access to LibreQoS topology inputs: +- active runtime shaping inputs published through `topology_runtime_status.json` - `ShapedDevices.csv` (and `ShapedDevices.insight.csv`) - `network.json` (and `network.insight.json` / `network.effective.json`) @@ -10,7 +11,8 @@ Centralized, shared access to LibreQoS topology inputs: 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. +- Watchers for the config directory and topology-state directory that coalesce + filesystem events and request reloads. Callers access state through public accessor functions; the underlying channel sender is kept in a private static. @@ -50,12 +52,15 @@ 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. + - Directory watcher requests reload when `topology_runtime_status.json`, + `ShapedDevices.csv`, or `ShapedDevices.insight.csv` changes. + - Reload precedence is: ready runtime shaping inputs, then `topology_import.json`, + then `ShapedDevices.csv`. - 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. + `network.effective.json` changes, including the runtime topology-state copy. - 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. diff --git a/src/rust/lqos_network_devices/src/combined_catalog.rs b/src/rust/lqos_network_devices/src/combined_catalog.rs index fbe20eaa..0c9d105a 100644 --- a/src/rust/lqos_network_devices/src/combined_catalog.rs +++ b/src/rust/lqos_network_devices/src/combined_catalog.rs @@ -4,8 +4,8 @@ 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; +use std::sync::Arc; fn normalize_circuit_id_key(circuit_id: &str) -> String { circuit_id.trim().to_ascii_lowercase() @@ -32,10 +32,7 @@ impl Clone for NetworkDevicesCatalog { impl NetworkDevicesCatalog { /// Builds a combined catalog from explicit snapshots. - pub fn from_snapshots( - shaped: ShapedDevicesCatalog, - dynamic: Arc>, - ) -> Self { + pub 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(); @@ -53,10 +50,10 @@ impl NetworkDevicesCatalog { } } 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); - } + if *prefix <= 128 + && let Ok(net) = IpNetwork::new(*ipv6, *prefix as u8) + { + dyn_ip_table.insert(net, idx); } } } @@ -93,7 +90,8 @@ impl NetworkDevicesCatalog { /// 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()) + self.iter_static_devices() + .chain(self.iter_dynamic_devices()) } /// Returns the longest-prefix match entry for an IP address. diff --git a/src/rust/lqos_network_devices/src/directory_watcher.rs b/src/rust/lqos_network_devices/src/directory_watcher.rs index eb6fe23c..e2886e4c 100644 --- a/src/rust/lqos_network_devices/src/directory_watcher.rs +++ b/src/rust/lqos_network_devices/src/directory_watcher.rs @@ -2,8 +2,10 @@ 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::fs; use std::path::{Path, PathBuf}; -use tracing::{debug, error, warn}; +use std::time::Duration; +use tracing::{debug, warn}; #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] enum DirectoryReloadEvent { @@ -12,58 +14,57 @@ enum DirectoryReloadEvent { } 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}"); - } - } - } - })?; + let config = lqos_config::load_config()?; + spawn_directory_watch_thread( + "Network Devices Config Dir Watcher", + "LibreQoS config directory", + PathBuf::from(&config.lqos_directory), + )?; + spawn_directory_watch_thread( + "Network Devices Topology Dir Watcher", + "LibreQoS topology state directory", + config.resolved_state_directory().join("topology"), + )?; Ok(()) } -struct NetworkDevicesDirectoryWatcher { - inner: DirectoryWatcher, +fn spawn_directory_watch_thread(thread_name: &str, watch_label: &str, path: PathBuf) -> Result<()> { + let thread_name = thread_name.to_string(); + let watch_label = watch_label.to_string(); + fs::create_dir_all(&path)?; + std::thread::Builder::new() + .name(thread_name) + .spawn(move || watch_directory_loop(&watch_label, path))?; + Ok(()) } -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 watch_directory_loop(watch_label: &str, path: PathBuf) { + let mut watcher = DirectoryWatcher::new(watch_label, path); + debug!("Watching {watch_label} for network/shaped updates"); + loop { + match watch_relevant(&mut watcher) { + Ok(events) => { + for event in events { + if let Err(err) = dispatch_reload(event) { + warn!("Network Devices directory reload dispatch failed: {err}"); + } + } } + Err(err) => { + warn!("{watch_label} watcher returned: {err}"); + std::thread::sleep(Duration::from_secs(1)); + } + } + } +} + +fn watch_relevant( + watcher: &mut DirectoryWatcher, +) -> Result, WatchedDirectoryError> { + loop { + let events = classify_changed_paths(&watcher.watch()?); + if !events.is_empty() { + return Ok(events); } } } @@ -84,9 +85,10 @@ fn classify_changed_path(path: &Path) -> Option { } match file_name { - "ShapedDevices.csv" | "ShapedDevices.insight.csv" | "topology_import.json" => { - Some(DirectoryReloadEvent::ShapedDevices) - } + "ShapedDevices.csv" + | "ShapedDevices.insight.csv" + | "topology_import.json" + | "topology_runtime_status.json" => Some(DirectoryReloadEvent::ShapedDevices), "network.effective.json" | "network.insight.json" | "network.json" => { Some(DirectoryReloadEvent::NetworkJson) } @@ -109,3 +111,37 @@ fn dispatch_reload(event: DirectoryReloadEvent) -> Result<()> { DirectoryReloadEvent::NetworkJson => request_reload_network_json("dirwatch:network.json"), } } + +#[cfg(test)] +mod tests { + use super::{DirectoryReloadEvent, classify_changed_path, classify_changed_paths}; + use std::path::PathBuf; + + #[test] + fn topology_runtime_status_triggers_shaped_device_reload() { + let event = classify_changed_path(&PathBuf::from("/tmp/topology_runtime_status.json")); + assert_eq!(event, Some(DirectoryReloadEvent::ShapedDevices)); + } + + #[test] + fn ignored_temp_files_do_not_trigger_reload() { + let event = classify_changed_path(&PathBuf::from("/tmp/network.json.tmp")); + assert_eq!(event, None); + } + + #[test] + fn changed_paths_are_deduplicated_by_reload_kind() { + let events = classify_changed_paths(&[ + PathBuf::from("/tmp/network.json"), + PathBuf::from("/tmp/network.effective.json"), + PathBuf::from("/tmp/topology_runtime_status.json"), + ]); + assert_eq!( + events, + vec![ + DirectoryReloadEvent::ShapedDevices, + DirectoryReloadEvent::NetworkJson + ] + ); + } +} diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs index 6e7a12a1..588381c6 100644 --- a/src/rust/lqos_network_devices/src/lib.rs +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -12,6 +12,7 @@ mod directory_watcher; mod dynamic; mod dynamic_store; mod hash_cache; +mod runtime_inputs; mod state; mod topology; @@ -19,7 +20,7 @@ mod topology; mod tests; use anyhow::{Context, Result}; -use lqos_config::{ConfigShapedDevices, NetworkJson}; +use lqos_config::{Config, ConfigShapedDevices, NetworkJson, TopologyShapingInputsFile}; use std::sync::Arc; use tracing::debug; @@ -53,23 +54,61 @@ pub fn start_daemon_mode(hooks: Option>) -> Result<()> { directory_watcher::start_network_devices_directory_watch() } -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) +/// Loads the currently active runtime shaping inputs for integration-backed topologies. +/// +/// Returns `Ok(None)` when integration ingress is disabled or when no ready runtime shaping +/// inputs have been published yet. +pub fn load_runtime_shaping_inputs_for_config( + config: &Config, +) -> Result> { + if !lqos_config::integration_ingress_enabled(config) { + return Ok(None); + } + runtime_inputs::load_ready_runtime_shaping_inputs(config) +} + +fn load_shaped_devices_for_config(config: &lqos_config::Config) -> Result { + if lqos_config::integration_ingress_enabled(config) { + if let Some(shaping_inputs) = load_runtime_shaping_inputs_for_config(config)? { + let shaped_devices = + runtime_inputs::shaped_devices_from_runtime_inputs(&shaping_inputs); + if !shaped_devices.devices.is_empty() { + return Ok(shaped_devices); + } + debug!( + "Active runtime shaping inputs contained 0 shaped devices; checking other integration sources" + ); + } + if !lqos_config::topology_import_has_shaped_devices(config) { + debug!( + "topology_import.json is missing or contains 0 shaped devices; keeping integration mode empty until topology is published" + ); + return Ok(ConfigShapedDevices::default()); + } + 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); + } + debug!( + "topology_import.json advertised shaped devices but loaded empty; keeping integration mode empty until topology is published" + ); + } + Ok(None) => { + debug!( + "topology_import.json advertised shaped devices but loader returned no bundle; keeping integration mode empty until topology is published" + ); + } + Err(err) => { + debug!( + "Unable to load topology_import.json ({err}); keeping integration mode empty until topology is published" + ); + } + } + return Ok(ConfigShapedDevices::default()); + } + ConfigShapedDevices::load_for_config(config).context("Unable to load ShapedDevices.csv") } /// Loads shaped-device configuration from disk. @@ -78,28 +117,7 @@ fn integration_ingress_enabled(config: &lqos_config::Config) -> bool { /// active `topology_import.json` bundle. Otherwise, it loads `ShapedDevices.csv`. 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()) { - 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"); - } - Err(err) => { - debug!( - "Unable to load topology_import.json ({err}); falling back to ShapedDevices.csv" - ); - } - } - } - ConfigShapedDevices::load().context("Unable to load ShapedDevices.csv") + load_shaped_devices_for_config(config.as_ref()) } /// Loads `network.json` from disk. diff --git a/src/rust/lqos_network_devices/src/runtime_inputs.rs b/src/rust/lqos_network_devices/src/runtime_inputs.rs new file mode 100644 index 00000000..714878a5 --- /dev/null +++ b/src/rust/lqos_network_devices/src/runtime_inputs.rs @@ -0,0 +1,118 @@ +//! Shared helpers for resolving active runtime topology artifacts. +//! +//! This module centralizes how `lqos_network_devices` discovers the currently +//! active shaping inputs published by the topology runtime and converts them +//! into the shaped-device snapshot used by `lqosd`. + +use anyhow::Result; +use lqos_config::{Config, ConfigShapedDevices, ShapedDevice, TopologyShapingInputsFile}; +use std::net::{Ipv4Addr, Ipv6Addr}; +use tracing::debug; + +fn optional_trimmed_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn parse_ipv4_entry(value: &str) -> Option<(Ipv4Addr, u32)> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let (ip, cidr) = if let Some((ip, cidr)) = trimmed.split_once('/') { + (ip.trim(), cidr.trim().parse().ok()?) + } else { + (trimmed, 32) + }; + Some((ip.parse().ok()?, cidr)) +} + +fn parse_ipv6_entry(value: &str) -> Option<(Ipv6Addr, u32)> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let (ip, cidr) = if let Some((ip, cidr)) = trimmed.split_once('/') { + (ip.trim(), cidr.trim().parse().ok()?) + } else { + (trimmed, 128) + }; + Some((ip.parse().ok()?, cidr)) +} + +fn parse_ipv4_list(values: &[String]) -> Vec<(Ipv4Addr, u32)> { + values + .iter() + .filter_map(|value| parse_ipv4_entry(value)) + .collect() +} + +fn parse_ipv6_list(values: &[String]) -> Vec<(Ipv6Addr, u32)> { + values + .iter() + .filter_map(|value| parse_ipv6_entry(value)) + .collect() +} + +/// Loads the currently active runtime shaping inputs when available. +pub(crate) fn load_ready_runtime_shaping_inputs( + config: &Config, +) -> Result> { + match lqos_config::load_active_runtime_shaping_inputs(config) { + Ok(shaping_inputs) => Ok(shaping_inputs), + Err(err) => { + debug!( + "Unable to load active runtime shaping inputs; falling back from runtime shaping inputs: {err}" + ); + Ok(None) + } + } +} + +/// Converts topology runtime shaping inputs into a `ConfigShapedDevices` +/// snapshot suitable for the shared in-memory catalog. +pub(crate) fn shaped_devices_from_runtime_inputs( + shaping_inputs: &TopologyShapingInputsFile, +) -> ConfigShapedDevices { + let mut devices = Vec::new(); + for circuit in &shaping_inputs.circuits { + let parent_node = optional_trimmed_string(&circuit.effective_parent_node_name) + .or_else(|| circuit.logical_parent_node_name.clone()) + .unwrap_or_default(); + let parent_node_id = optional_trimmed_string(&circuit.effective_parent_node_id) + .or_else(|| circuit.logical_parent_node_id.clone()); + for device in &circuit.devices { + devices.push(ShapedDevice { + circuit_id: circuit.circuit_id.clone(), + circuit_name: circuit.circuit_name.clone(), + device_id: device.device_id.clone(), + device_name: device.device_name.clone(), + parent_node: parent_node.clone(), + parent_node_id: parent_node_id.clone(), + anchor_node_id: circuit.anchor_node_id.clone(), + mac: device.mac.clone(), + ipv4: parse_ipv4_list(&device.ipv4), + ipv6: parse_ipv6_list(&device.ipv6), + download_min_mbps: circuit.download_min_mbps, + upload_min_mbps: circuit.upload_min_mbps, + download_max_mbps: circuit.download_max_mbps, + upload_max_mbps: circuit.upload_max_mbps, + comment: if device.comment.trim().is_empty() { + circuit.comment.clone() + } else { + device.comment.clone() + }, + sqm_override: circuit.sqm_override.clone(), + ..ShapedDevice::default() + }); + } + } + + let mut shaped = ConfigShapedDevices::default(); + shaped.replace_with_new_data(devices); + shaped +} diff --git a/src/rust/lqos_network_devices/src/tests.rs b/src/rust/lqos_network_devices/src/tests.rs index dc57fc96..d51828ab 100644 --- a/src/rust/lqos_network_devices/src/tests.rs +++ b/src/rust/lqos_network_devices/src/tests.rs @@ -1,14 +1,23 @@ use crate::{ - DynamicCircuit, ShapedDevicesCatalog, resolve_parent_node_reference, with_network_json_write, + DynamicCircuit, ShapedDevicesCatalog, load_shaped_devices_for_config, + resolve_parent_node_reference, runtime_inputs, with_network_json_write, }; -use lqos_config::{ConfigShapedDevices, NetworkJsonNode, ShapedDevice}; +use lqos_config::{ + CircuitAnchorsFile, Config, ConfigShapedDevices, NetworkJsonNode, ShapedDevice, + TOPOLOGY_RUNTIME_STATUS_FILENAME, TopologyShapingCircuitInput, TopologyShapingDeviceInput, + TopologyShapingInputsFile, +}; +use lqos_topology_compile::{ImportedTopologyBundle, TopologyImportFile}; use lqos_utils::rtt::RttBuffer; use lqos_utils::units::DownUpOrder; use once_cell::sync::Lazy; use parking_lot::Mutex; +use serde_json::json; use std::collections::HashSet; use std::net::Ipv4Addr; +use std::path::PathBuf; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); @@ -43,6 +52,55 @@ fn make_node( } } +fn unique_temp_dir(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be monotonic enough for tests") + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{unique}")); + std::fs::create_dir_all(&path).expect("temp directory should be creatable"); + path +} + +fn write_shaped_devices_csv(path: &std::path::Path, circuit_id: &str, ip: &str) { + std::fs::write( + path, + format!( + concat!( + "Circuit ID,Circuit Name,Device ID,Device Name,Parent Node,MAC,IPv4,IPv6,", + "Download Min Mbps,Upload Min Mbps,Download Max Mbps,Upload Max Mbps,Comment\n", + "\"{}\",\"Circuit {}\",\"device-{}\",", + "\"Device {}\",\"Tower 1\",\"aa:bb:cc:dd:ee:ff\",\"{}\",\"\",", + "\"10\",\"10\",\"100\",\"100\",\"\"\n", + ), + circuit_id, circuit_id, circuit_id, circuit_id, ip, + ), + ) + .expect("ShapedDevices.csv should write"); +} + +fn write_runtime_status( + path: &std::path::Path, + ready: bool, + shaping_inputs_path: &std::path::Path, + source_generation: &str, +) { + std::fs::write( + path, + serde_json::json!({ + "schema_version": 1, + "ready": ready, + "shaping_inputs_path": shaping_inputs_path, + "effective_state_path": "", + "effective_network_path": "", + "source_generation": source_generation, + "shaping_generation": "shape-1", + }) + .to_string(), + ) + .expect("status should write"); +} + #[test] fn catalog_device_by_hashes_prefers_device_hash() { let _guard = TEST_LOCK.lock(); @@ -234,3 +292,294 @@ fn dynamic_circuit_is_superseded_when_shaped_devices_now_cover_it() { &catalog )); } + +#[test] +fn runtime_inputs_build_shaped_devices_from_effective_parent_data() { + let _guard = TEST_LOCK.lock(); + + let shaping_inputs = TopologyShapingInputsFile { + circuits: vec![TopologyShapingCircuitInput { + circuit_id: "circuit-1".to_string(), + circuit_name: "Circuit Alpha".to_string(), + anchor_node_id: Some("anchor-1".to_string()), + effective_parent_node_name: "Parent-A".to_string(), + effective_parent_node_id: "parent-id-1".to_string(), + download_min_mbps: 10.0, + upload_min_mbps: 5.0, + download_max_mbps: 50.0, + upload_max_mbps: 10.0, + devices: vec![TopologyShapingDeviceInput { + device_id: "device-1".to_string(), + device_name: "Device Alpha".to_string(), + mac: "aa:bb:cc:dd:ee:ff".to_string(), + ipv4: vec!["192.168.1.10/32".to_string()], + comment: "device-comment".to_string(), + ..TopologyShapingDeviceInput::default() + }], + comment: "circuit-comment".to_string(), + ..TopologyShapingCircuitInput::default() + }], + ..TopologyShapingInputsFile::default() + }; + + let shaped = runtime_inputs::shaped_devices_from_runtime_inputs(&shaping_inputs); + assert_eq!(shaped.devices.len(), 1); + assert_eq!(shaped.devices[0].parent_node, "Parent-A"); + assert_eq!( + shaped.devices[0].parent_node_id.as_deref(), + Some("parent-id-1") + ); + assert_eq!( + shaped.devices[0].anchor_node_id.as_deref(), + Some("anchor-1") + ); + assert_eq!(shaped.devices[0].comment, "device-comment"); + assert_eq!(shaped.devices[0].circuit_id, "circuit-1"); +} + +#[test] +fn load_shaped_devices_uses_topology_import_when_runtime_inputs_are_empty() { + let _guard = TEST_LOCK.lock(); + + let lqos_directory = unique_temp_dir("lqos-network-devices-runtime-empty"); + let state_directory = lqos_directory.join("state"); + std::fs::create_dir_all(state_directory.join("topology")).expect("topology dir should exist"); + std::fs::create_dir_all(state_directory.join("shaping")).expect("shaping dir should exist"); + + let runtime_path = lqos_directory.join("runtime_shaping_inputs.json"); + let status_path = state_directory + .join("topology") + .join(TOPOLOGY_RUNTIME_STATUS_FILENAME); + write_shaped_devices_csv( + &lqos_directory.join("ShapedDevices.csv"), + "csv-circuit", + "192.0.2.10/32", + ); + std::fs::write( + &runtime_path, + serde_json::to_string_pretty(&TopologyShapingInputsFile::default()) + .expect("empty shaping inputs should encode"), + ) + .expect("runtime shaping inputs should write"); + let mut import_devices = ConfigShapedDevices::default(); + import_devices.replace_with_new_data(vec![ShapedDevice { + circuit_id: "import-circuit".to_string(), + circuit_name: "Import Circuit".to_string(), + device_id: "device-import".to_string(), + parent_node: "Tower Import".to_string(), + ipv4: vec![(Ipv4Addr::new(203, 0, 113, 10), 32)], + ..Default::default() + }]); + 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: import_devices, + 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 source_generation = lqos_config::compute_topology_source_generation(&config) + .expect("generation should compute"); + write_runtime_status(&status_path, true, &runtime_path, &source_generation); + + let loaded = load_shaped_devices_for_config(&config) + .expect("preferred shaped-device source should load"); + assert_eq!(loaded.devices.len(), 1); + assert_eq!(loaded.devices[0].circuit_id, "import-circuit"); +} + +#[test] +fn load_shaped_devices_uses_topology_import_when_runtime_is_not_ready() { + let _guard = TEST_LOCK.lock(); + + let lqos_directory = unique_temp_dir("lqos-network-devices-topology-import-fallback"); + let state_directory = lqos_directory.join("state"); + std::fs::create_dir_all(state_directory.join("topology")).expect("topology dir should exist"); + + let runtime_path = lqos_directory.join("runtime_shaping_inputs.json"); + let status_path = state_directory + .join("topology") + .join(TOPOLOGY_RUNTIME_STATUS_FILENAME); + std::fs::write( + &runtime_path, + serde_json::to_string_pretty(&TopologyShapingInputsFile::default()) + .expect("runtime shaping inputs should encode"), + ) + .expect("runtime shaping inputs should write"); + write_runtime_status(&status_path, false, &runtime_path, "gen-1"); + + let mut import_devices = ConfigShapedDevices::default(); + import_devices.replace_with_new_data(vec![ShapedDevice { + circuit_id: "import-circuit".to_string(), + circuit_name: "Import Circuit".to_string(), + device_id: "device-import".to_string(), + parent_node: "Tower Import".to_string(), + ipv4: vec![(Ipv4Addr::new(203, 0, 113, 10), 32)], + ..Default::default() + }]); + 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: import_devices, + 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 = + load_shaped_devices_for_config(&config).expect("topology import fallback should load"); + assert_eq!(loaded.devices.len(), 1); + assert_eq!(loaded.devices[0].circuit_id, "import-circuit"); +} + +#[test] +fn load_shaped_devices_stays_empty_when_topology_import_is_empty() { + let _guard = TEST_LOCK.lock(); + + let lqos_directory = unique_temp_dir("lqos-network-devices-topology-import-empty"); + let state_directory = lqos_directory.join("state"); + std::fs::create_dir_all(state_directory.join("topology")).expect("topology dir should exist"); + + 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 = + load_shaped_devices_for_config(&config).expect("integration mode should stay empty"); + assert!(loaded.devices.is_empty()); +} + +#[test] +fn load_shaped_devices_ignores_stale_runtime_inputs_in_manual_mode() { + let _guard = TEST_LOCK.lock(); + + let lqos_directory = unique_temp_dir("lqos-network-devices-manual-mode"); + let state_directory = lqos_directory.join("state"); + std::fs::create_dir_all(state_directory.join("topology")).expect("topology dir should exist"); + std::fs::create_dir_all(state_directory.join("shaping")).expect("shaping dir should exist"); + + let runtime_path = lqos_directory.join("runtime_shaping_inputs.json"); + let status_path = state_directory + .join("topology") + .join(TOPOLOGY_RUNTIME_STATUS_FILENAME); + std::fs::write( + &runtime_path, + serde_json::to_string_pretty(&TopologyShapingInputsFile { + circuits: vec![TopologyShapingCircuitInput { + circuit_id: "runtime-circuit".to_string(), + devices: vec![TopologyShapingDeviceInput { + device_id: "runtime-device".to_string(), + ipv4: vec!["198.51.100.10/32".to_string()], + ..TopologyShapingDeviceInput::default() + }], + ..TopologyShapingCircuitInput::default() + }], + ..TopologyShapingInputsFile::default() + }) + .expect("runtime shaping inputs should encode"), + ) + .expect("runtime shaping inputs should write"); + write_runtime_status(&status_path, true, &runtime_path, "stale-generation"); + write_shaped_devices_csv( + &lqos_directory.join("ShapedDevices.csv"), + "csv-circuit", + "192.0.2.10/32", + ); + + let config = Config { + lqos_directory: lqos_directory.to_string_lossy().to_string(), + state_directory: Some(state_directory.to_string_lossy().to_string()), + ..Config::default() + }; + + let loaded = + load_shaped_devices_for_config(&config).expect("manual mode should use shaped devices csv"); + assert_eq!(loaded.devices.len(), 1); + assert_eq!(loaded.devices[0].circuit_id, "csv-circuit"); +} + +#[test] +fn load_shaped_devices_stays_empty_when_runtime_status_is_malformed() { + let _guard = TEST_LOCK.lock(); + + let lqos_directory = unique_temp_dir("lqos-network-devices-runtime-status-malformed"); + let state_directory = lqos_directory.join("state"); + std::fs::create_dir_all(state_directory.join("topology")).expect("topology dir should exist"); + std::fs::write( + state_directory + .join("topology") + .join(TOPOLOGY_RUNTIME_STATUS_FILENAME), + "{not-json\n", + ) + .expect("malformed runtime status should write"); + + 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 = + load_shaped_devices_for_config(&config).expect("malformed status should stay empty"); + assert!(loaded.devices.is_empty()); +} diff --git a/src/rust/lqos_overrides/src/topology_overrides.rs b/src/rust/lqos_overrides/src/topology_overrides.rs index 6d87ccff..8d321193 100644 --- a/src/rust/lqos_overrides/src/topology_overrides.rs +++ b/src/rust/lqos_overrides/src/topology_overrides.rs @@ -499,7 +499,10 @@ mod tests { let loaded = load_from_path(&path).expect("failed to read topology overrides file from temp path"); - assert_eq!(loaded.schema_version, TopologyOverridesFile::default().schema_version); + assert_eq!( + loaded.schema_version, + TopologyOverridesFile::default().schema_version + ); assert!(loaded.overrides.is_empty()); assert!(loaded.attachment_probe_policies.is_empty()); assert!(loaded.manual_attachment_groups.is_empty()); diff --git a/src/rust/lqos_setup/src/bootstrap.rs b/src/rust/lqos_setup/src/bootstrap.rs index ffb571ac..0978222f 100644 --- a/src/rust/lqos_setup/src/bootstrap.rs +++ b/src/rust/lqos_setup/src/bootstrap.rs @@ -28,10 +28,8 @@ const TOKEN_TTL: Duration = Duration::from_secs(2 * 60 * 60); pub enum SetupStatus { /// Setup is not complete yet. Incomplete, - /// Setup completed, but subscriber topology inputs are still missing. - CompleteWaitingForSubscriberData, - /// Setup completed and subscriber topology inputs exist. - CompleteShapingActive, + /// Setup completed and LibreQoS can hand off to runtime services. + Complete, } impl SetupStatus { @@ -39,10 +37,7 @@ impl SetupStatus { pub fn as_str(self) -> &'static str { match self { Self::Incomplete => "Setup Incomplete", - Self::CompleteWaitingForSubscriberData => { - "Setup Complete, Waiting for Subscriber Data" - } - Self::CompleteShapingActive => "Setup Complete, Shaping Active", + Self::Complete => "Setup Complete", } } } @@ -63,8 +58,6 @@ pub struct BootstrapState { pub version: u32, /// Whether setup has been completed. pub setup_complete: bool, - /// Whether runtime subscriber inputs exist and shaping can become active. - pub shaping_ready: bool, /// Whether an admin user exists. pub first_admin_exists: bool, /// Current setup token. @@ -76,7 +69,6 @@ impl Default for BootstrapState { Self { version: BOOTSTRAP_STATE_VERSION, setup_complete: false, - shaping_ready: false, first_admin_exists: false, token: new_bootstrap_token(), } @@ -92,10 +84,6 @@ pub struct StatusSnapshot { pub display_status: SetupStatus, /// Hostname and IP hints for the operator. pub host_hints: Vec, - /// Whether `network.json` exists. - pub network_json_present: bool, - /// Whether `ShapedDevices.csv` exists. - pub shaped_devices_present: bool, /// Whether `/etc/lqos.conf` currently parses. pub config_loads: bool, /// Whether the Noble systemd hotfix is still required. @@ -124,8 +112,6 @@ struct RuntimePaths { #[derive(Clone, Debug)] struct LiveInputs { config_loads: bool, - network_json_present: bool, - shaped_devices_present: bool, first_admin_exists: bool, } @@ -191,19 +177,15 @@ fn collect_live_inputs() -> LiveInputs { let paths = runtime_paths(); LiveInputs { config_loads: lqos_config::load_config().is_ok(), - network_json_present: paths.lqos_directory.join("network.json").exists(), - shaped_devices_present: paths.lqos_directory.join("ShapedDevices.csv").exists(), first_admin_exists: auth_file_has_admin(&paths.lqos_directory), } } -fn display_status(setup_complete: bool, shaping_ready: bool) -> SetupStatus { +fn display_status(setup_complete: bool) -> SetupStatus { if !setup_complete { SetupStatus::Incomplete - } else if shaping_ready { - SetupStatus::CompleteShapingActive } else { - SetupStatus::CompleteWaitingForSubscriberData + SetupStatus::Complete } } @@ -264,10 +246,7 @@ fn build_setup_urls(token: &BootstrapToken) -> Vec { .collect() } -fn hydrate_state( - mut state: BootstrapState, - refresh_expired_token: bool, -) -> BootstrapState { +fn hydrate_state(mut state: BootstrapState, refresh_expired_token: bool) -> BootstrapState { if refresh_expired_token && state.token.expires_at_unix <= current_unix_seconds() { state.token = new_bootstrap_token(); } @@ -289,10 +268,6 @@ fn read_persisted_state() -> Result { fn bootstrap_state_from_live(live: &LiveInputs) -> BootstrapState { BootstrapState { setup_complete: live.config_loads && live.first_admin_exists, - shaping_ready: live.config_loads - && live.first_admin_exists - && live.network_json_present - && live.shaped_devices_present, first_admin_exists: live.first_admin_exists, ..BootstrapState::default() } @@ -313,7 +288,10 @@ fn load_or_create_state() -> Result { { if path.exists() { return Err(err).with_context(|| { - format!("Unable to create setup state directory {}", parent.display()) + format!( + "Unable to create setup state directory {}", + parent.display() + ) }); } return Ok(synced); @@ -331,8 +309,12 @@ fn load_or_create_state() -> Result { pub fn save_state(state: &BootstrapState) -> Result<()> { let path = bootstrap_state_path(); if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Unable to create setup state directory {}", parent.display()))?; + fs::create_dir_all(parent).with_context(|| { + format!( + "Unable to create setup state directory {}", + parent.display() + ) + })?; } let raw = serde_json::to_string_pretty(state).context("Unable to serialize bootstrap state")?; fs::write(&path, raw) @@ -345,7 +327,10 @@ pub fn store_setup_completion_report(report: &str) -> Result<()> { let path = completion_report_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| { - format!("Unable to create setup state directory {}", parent.display()) + format!( + "Unable to create setup state directory {}", + parent.display() + ) })?; } fs::write(&path, report) @@ -365,14 +350,9 @@ pub fn record_setup_success(config: &Config) -> Result { let mut state = load_or_create_state()?; let live = LiveInputs { config_loads: true, - network_json_present: Path::new(&config.lqos_directory).join("network.json").exists(), - shaped_devices_present: Path::new(&config.lqos_directory) - .join("ShapedDevices.csv") - .exists(), first_admin_exists: auth_file_has_admin(Path::new(&config.lqos_directory)), }; state.setup_complete = live.first_admin_exists; - state.shaping_ready = live.network_json_present && live.shaped_devices_present; state.first_admin_exists = live.first_admin_exists; state = hydrate_state(state, true); save_state(&state)?; @@ -394,17 +374,13 @@ pub fn status_snapshot() -> Result { bootstrap_state_from_live(&live) }; state.first_admin_exists = live.first_admin_exists; - state.shaping_ready = - state.setup_complete && live.network_json_present && live.shaped_devices_present; let host_hints = detect_host_hints(); - let display_status = display_status(state.setup_complete, state.shaping_ready); + let display_status = display_status(state.setup_complete); Ok(StatusSnapshot { state, display_status, host_hints, - network_json_present: live.network_json_present, - shaped_devices_present: live.shaped_devices_present, config_loads: live.config_loads, hotfix_required: hotfix_status.required, hotfix_detail: hotfix_status.detail, @@ -414,17 +390,16 @@ pub fn status_snapshot() -> Result { /// Renders a text report for `lqos_setup status`. pub fn render_status_report() -> Result { let snapshot = status_snapshot()?; - let mut report = format!("lqos_setup status\n\nStatus: {}", snapshot.display_status.as_str()); + let mut report = format!( + "lqos_setup status\n\nStatus: {}", + snapshot.display_status.as_str() + ); report.push_str("\nSource: persisted setup state plus current runtime diagnostics"); report.push_str(&format!( "\n- setup_complete: {}", yes_no(snapshot.state.setup_complete) )); - report.push_str(&format!( - "\n- shaping_ready: {}", - yes_no(snapshot.state.shaping_ready) - )); report.push_str(&format!( "\n- config_loads: {}", yes_no(snapshot.config_loads) @@ -433,29 +408,19 @@ pub fn render_status_report() -> Result { "\n- first_admin_exists: {}", yes_no(snapshot.state.first_admin_exists) )); - report.push_str(&format!( - "\n- network.json present: {}", - yes_no(snapshot.network_json_present) - )); - report.push_str(&format!( - "\n- ShapedDevices.csv present: {}", - yes_no(snapshot.shaped_devices_present) - )); report.push_str(&format!( "\n- hotfix required: {}", yes_no(snapshot.hotfix_required) )); - report.push_str(&format!( - "\n- hotfix detail: {}", - snapshot.hotfix_detail - )); + report.push_str(&format!("\n- hotfix detail: {}", snapshot.hotfix_detail)); if !snapshot.state.setup_complete { report.push_str("\n\nHost hints:"); for host in &snapshot.host_hints { report.push_str(&format!("\n- {host}")); } - report.push_str("\n\nRun `lqos_setup print-link` to print the current tokenized setup URL."); + report + .push_str("\n\nRun `lqos_setup print-link` to print the current tokenized setup URL."); } Ok(report) @@ -472,12 +437,10 @@ pub fn runtime_services_should_start() -> Result { } else { bootstrap_state_from_live(&live) }; - Ok( - state.setup_complete - && live.first_admin_exists - && live.config_loads - && !hotfix_status.required, - ) + Ok(state.setup_complete + && live.first_admin_exists + && live.config_loads + && !hotfix_status.required) } /// Returns true when first-run setup has not yet been completed. diff --git a/src/rust/lqos_setup/src/interfaces.rs b/src/rust/lqos_setup/src/interfaces.rs index 951f93ea..afebd119 100644 --- a/src/rust/lqos_setup/src/interfaces.rs +++ b/src/rust/lqos_setup/src/interfaces.rs @@ -1,7 +1,4 @@ -use std::{ - path::Path, - process::Command, -}; +use std::{path::Path, process::Command}; use cursive::{ Cursive, @@ -120,7 +117,9 @@ fn interface_kind_label(interface: &str) -> Option { return Some(port); } - let driver_path = Path::new("/sys/class/net").join(interface).join("device/driver"); + let driver_path = Path::new("/sys/class/net") + .join(interface) + .join("device/driver"); if let Ok(target) = std::fs::read_link(driver_path) && let Some(driver) = target.file_name().and_then(|name| name.to_str()) { diff --git a/src/rust/lqos_setup/src/main.rs b/src/rust/lqos_setup/src/main.rs index d4cf97f5..ab9a0d47 100644 --- a/src/rust/lqos_setup/src/main.rs +++ b/src/rust/lqos_setup/src/main.rs @@ -12,7 +12,6 @@ mod webusers; use clap::{Parser, Subcommand}; use lqos_setup::{bootstrap, hotfix}; use std::path::Path; -use std::{env, fmt::Write as _}; use bandwidth::bandwidth_view; use config_builder::CURRENT_CONFIG; @@ -21,13 +20,9 @@ use cursive::{ view::{Margins, Nameable, Resizable, Scrollable}, views::{Checkbox, Dialog, EditView, FixedLayout, Layer, LinearLayout, OnLayoutView, TextView}, }; -use lqos_netplan_helper::transaction::{ - HelperPaths, inspect_with_paths, -}; +use lqos_netplan_helper::transaction::{HelperPaths, inspect_with_paths}; const VERSION: &str = include_str!("../../../VERSION_STRING"); -const SKIP_IF_READY_FLAG: &str = "--skip-if-ready"; - #[derive(Parser)] #[command(about = "LibreQoS first-run setup")] struct Args { @@ -68,117 +63,21 @@ fn can_load_config() -> bool { lqos_config::load_config().is_ok() } -fn network_json_exists() -> bool { - let cfg = lqos_config::load_config().unwrap_or_default(); - let path = Path::new(&cfg.lqos_directory).join("network.json"); - path.exists() -} - -fn shaped_devices_exists() -> bool { - let cfg = lqos_config::load_config().unwrap_or_default(); - let path = Path::new(&cfg.lqos_directory).join("ShapedDevices.csv"); - path.exists() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct UpgradeReadiness { - config_present: bool, - config_loads: bool, - interfaces_ready: bool, - bandwidth_ready: bool, - network_json_present: bool, - shaped_devices_present: bool, -} - -impl UpgradeReadiness { - fn all_ready(&self) -> bool { - self.config_present - && self.config_loads - && self.interfaces_ready - && self.bandwidth_ready - && self.network_json_present - && self.shaped_devices_present - } - - fn summary(&self) -> String { - let mut message = String::from("lqos_setup --skip-if-ready checklist:"); - let checklist = [ - ("config present", self.config_present), - ("config loads", self.config_loads), - ("interfaces chosen", self.interfaces_ready), - ("bandwidth configured", self.bandwidth_ready), - ("network.json present", self.network_json_present), - ("ShapedDevices.csv present", self.shaped_devices_present), - ]; - for (label, ready) in checklist { - let _ = write!( - &mut message, - "\n- {}: {}", - label, - if ready { "yes" } else { "no" } - ); - } - message - } -} - -fn upgrade_readiness_for_config_with( - config_path_exists: bool, - config: Option<&lqos_config::Config>, - interface_ready: impl Fn(&str) -> bool, -) -> UpgradeReadiness { - let Some(config) = config else { - return UpgradeReadiness { - config_present: config_path_exists, - config_loads: false, - interfaces_ready: false, - bandwidth_ready: false, - network_json_present: false, - shaped_devices_present: false, - }; - }; - - let base = Path::new(&config.lqos_directory); - let interfaces_ready = if let Some(bridge) = &config.bridge { - !bridge.to_internet.trim().is_empty() - && !bridge.to_network.trim().is_empty() - && bridge.to_internet != bridge.to_network - && interface_ready(&bridge.to_internet) - && interface_ready(&bridge.to_network) - } else if let Some(single) = &config.single_interface { - !single.interface.trim().is_empty() && interface_ready(&single.interface) - } else { - false - }; - - UpgradeReadiness { - config_present: config_path_exists, - config_loads: true, - interfaces_ready, - bandwidth_ready: config.queues.downlink_bandwidth_mbps > 0 - && config.queues.uplink_bandwidth_mbps > 0, - network_json_present: base.join("network.json").exists(), - shaped_devices_present: base.join("ShapedDevices.csv").exists(), - } -} - -fn upgrade_readiness() -> UpgradeReadiness { - let config_path_exists = config_exists(); - let loaded_config = lqos_config::load_config().ok(); - upgrade_readiness_for_config_with(config_path_exists, loaded_config.as_deref(), |interface| { - interfaces::interface_supports_lqos(interface).is_ok() - }) -} - fn run_tui(skip_if_ready: bool) { if skip_if_ready { - let readiness = upgrade_readiness(); - println!("{}", readiness.summary()); - if readiness.all_ready() { - println!("Existing LibreQoS install looks configured; skipping interactive setup."); - return; + match bootstrap::runtime_services_should_start() { + Ok(true) => { + println!("Existing LibreQoS install looks configured; skipping interactive setup."); + return; + } + Ok(false) => { + println!("Interactive setup still required."); + } + Err(err) => { + println!("Unable to determine setup readiness: {err}"); + println!("Interactive setup still required."); + } } - println!("Interactive setup still required."); } preflight::preflight(); @@ -237,20 +136,10 @@ fn run_tui(skip_if_ready: bool) { .child( Checkbox::new() .with_enabled(false) - .with_checked(network_json_exists()) - .with_name("njs"), + .with_checked(bootstrap::first_admin_exists()) + .with_name("admin_exists"), ) - .child(TextView::new(" - Network.json exists?")), - ) - .child( - LinearLayout::horizontal() - .child( - Checkbox::new() - .with_enabled(false) - .with_checked(shaped_devices_exists()) - .with_name("sd"), - ) - .child(TextView::new(" - ShapedDevices.csv exists?")), + .child(TextView::new(" - Admin user exists?")), ) .child(TextView::new(" ")) .child( @@ -288,15 +177,7 @@ fn run_tui(skip_if_ready: bool) { } fn main() { - let args = Args::parse_from( - env::args().map(|arg| { - if arg == SKIP_IF_READY_FLAG { - "--skip-if-ready".to_string() - } else { - arg - } - }), - ); + let args = Args::parse(); match args.command { Some(Command::Status) => match bootstrap::render_status_report() { @@ -360,7 +241,9 @@ fn main() { println!(); println!("============================================================"); println!("LibreQoS first-run setup is waiting for you."); - println!("Click one of the LibreQoS Setup URLs below, or copy it into a web browser on another machine on the same network."); + println!( + "Click one of the LibreQoS Setup URLs below, or copy it into a web browser on another machine on the same network." + ); println!(); for url in urls { println!(" {url}"); @@ -495,7 +378,10 @@ fn show_hotfix_dialog(ui: &mut cursive::Cursive) { match hotfix::status() { Ok(status) => { let detail = if status.required { - format!("{}\n\nThe hotfix is required before setup can complete.", status.detail) + format!( + "{}\n\nThe hotfix is required before setup can complete.", + status.detail + ) } else { status.detail }; @@ -512,11 +398,13 @@ fn show_hotfix_dialog(ui: &mut cursive::Cursive) { ui.add_layer(dialog); } Err(err) => ui.add_layer( - Dialog::around(TextView::new(format!("Unable to determine hotfix status:\n{err:#}"))) - .title("Systemd Hotfix") - .button("OK", |s| { - s.pop_layer(); - }), + Dialog::around(TextView::new(format!( + "Unable to determine hotfix status:\n{err:#}" + ))) + .title("Systemd Hotfix") + .button("OK", |s| { + s.pop_layer(); + }), ), } } @@ -529,10 +417,10 @@ fn install_hotfix_from_tui(ui: &mut cursive::Cursive) { "{}\n\nOpen the details only if you need the installer log.\n\n{}", result.summary, result.detail ))) - .title("Hotfix Installed") - .button("OK", |s| { - s.pop_layer(); - }), + .title("Hotfix Installed") + .button("OK", |s| { + s.pop_layer(); + }), ); } Err(err) => { @@ -558,8 +446,9 @@ fn continue_finalize(ui: &mut cursive::Cursive) { ui.add_layer( Dialog::around(TextView::new(pending.prompt)) .title("Confirm Netplan Change") - .button("Confirm", move |s| { - match setup_actions::confirm_pending_commit(&operation_id) { + .button( + "Confirm", + move |s| match setup_actions::confirm_pending_commit(&operation_id) { Ok(success) => { s.pop_layer(); finish_setup(s, &success.config, success.event_log); @@ -575,10 +464,11 @@ fn continue_finalize(ui: &mut cursive::Cursive) { }), ); } - } - }) - .button("Revert", move |s| { - match setup_actions::revert_pending_commit(&revert_operation_id) { + }, + ) + .button( + "Revert", + move |s| match setup_actions::revert_pending_commit(&revert_operation_id) { Ok(message) => { s.add_layer( Dialog::around(TextView::new(message)) @@ -600,8 +490,8 @@ fn continue_finalize(ui: &mut cursive::Cursive) { }), ); } - } - }) + }, + ) .full_screen(), ); } @@ -616,82 +506,3 @@ fn continue_finalize(ui: &mut cursive::Cursive) { } } } - -#[cfg(test)] -mod test { - use super::upgrade_readiness_for_config_with; - - fn configured_bridge_config(lqos_directory: String) -> lqos_config::Config { - let mut config = lqos_config::Config { - lqos_directory, - state_directory: None, - bridge: Some(lqos_config::BridgeConfig { - use_xdp_bridge: true, - to_internet: "wan0".to_string(), - to_network: "lan0".to_string(), - }), - single_interface: None, - ..lqos_config::Config::default() - }; - config.queues.downlink_bandwidth_mbps = 1000; - config.queues.uplink_bandwidth_mbps = 1000; - config - } - - #[test] - fn upgrade_ready_when_existing_install_is_configured() { - let temp_dir = - std::env::temp_dir().join(format!("lqos-setup-upgrade-ready-{}", std::process::id())); - std::fs::create_dir_all(&temp_dir).unwrap(); - std::fs::write(temp_dir.join("network.json"), "{}").unwrap(); - std::fs::write(temp_dir.join("ShapedDevices.csv"), "Circuit ID\n").unwrap(); - - let config = configured_bridge_config(temp_dir.display().to_string()); - let readiness = upgrade_readiness_for_config_with(true, Some(&config), |_| true); - assert!(readiness.all_ready()); - - let _ = std::fs::remove_file(temp_dir.join("network.json")); - let _ = std::fs::remove_file(temp_dir.join("ShapedDevices.csv")); - let _ = std::fs::remove_dir_all(&temp_dir); - } - - #[test] - fn upgrade_not_ready_without_required_runtime_inputs() { - let temp_dir = std::env::temp_dir().join(format!( - "lqos-setup-upgrade-missing-files-{}", - std::process::id() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - - let config = configured_bridge_config(temp_dir.display().to_string()); - let readiness = upgrade_readiness_for_config_with(true, Some(&config), |_| true); - assert!(!readiness.all_ready()); - assert!(!readiness.network_json_present); - assert!(!readiness.shaped_devices_present); - - let _ = std::fs::remove_dir_all(&temp_dir); - } - - #[test] - fn upgrade_not_ready_when_bridge_interfaces_are_not_distinct() { - let temp_dir = std::env::temp_dir().join(format!( - "lqos-setup-upgrade-same-iface-{}", - std::process::id() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - std::fs::write(temp_dir.join("network.json"), "{}").unwrap(); - std::fs::write(temp_dir.join("ShapedDevices.csv"), "Circuit ID\n").unwrap(); - - let mut config = configured_bridge_config(temp_dir.display().to_string()); - if let Some(bridge) = config.bridge.as_mut() { - bridge.to_network = bridge.to_internet.clone(); - } - let readiness = upgrade_readiness_for_config_with(true, Some(&config), |_| true); - assert!(!readiness.all_ready()); - assert!(!readiness.interfaces_ready); - - let _ = std::fs::remove_file(temp_dir.join("network.json")); - let _ = std::fs::remove_file(temp_dir.join("ShapedDevices.csv")); - let _ = std::fs::remove_dir_all(&temp_dir); - } -} diff --git a/src/rust/lqos_setup/src/service_handoff.rs b/src/rust/lqos_setup/src/service_handoff.rs index 5205df8f..2265184c 100644 --- a/src/rust/lqos_setup/src/service_handoff.rs +++ b/src/rust/lqos_setup/src/service_handoff.rs @@ -99,7 +99,11 @@ fn switch_service_mode( pub(crate) fn activate_runtime_services() -> Result { switch_service_mode( &["lqos_setup.service"], - &["lqosd.service", "lqos_scheduler.service", "lqos_api.service"], + &[ + "lqosd.service", + "lqos_scheduler.service", + "lqos_api.service", + ], "Activated runtime services: lqosd, lqos_scheduler, and lqos_api.", ) } diff --git a/src/rust/lqos_setup/src/setup_actions.rs b/src/rust/lqos_setup/src/setup_actions.rs index 3219a23c..44b1dec7 100644 --- a/src/rust/lqos_setup/src/setup_actions.rs +++ b/src/rust/lqos_setup/src/setup_actions.rs @@ -79,9 +79,7 @@ pub(crate) fn build_candidate_config(existing: Option) -> l config } -pub(crate) fn inspection_report( - inspection: &lqos_netplan_helper::NetworkModeInspection, -) -> String { +pub(crate) fn inspection_report(inspection: &lqos_netplan_helper::NetworkModeInspection) -> String { let mut report = format!( "State: {}\n{}\n", inspection.inspector_state, inspection.summary @@ -135,8 +133,7 @@ pub(crate) fn prepare_commit() -> Result { let helper_paths = HelperPaths::default(); let inspection = inspect_with_paths(&helper_paths, &config); - let bootstrap_incomplete = lqos_setup::bootstrap::setup_is_incomplete() - .unwrap_or(true); + let bootstrap_incomplete = lqos_setup::bootstrap::setup_is_incomplete().unwrap_or(true); let apply_mode = if inspection.can_take_over { ApplyMode::TakeOver } else if inspection.can_adopt { @@ -169,10 +166,7 @@ pub(crate) fn prepare_commit() -> Result { PENDING_COMMITS.lock().insert( operation.operation_id.clone(), - PendingCommitState { - config, - event_log, - }, + PendingCommitState { config, event_log }, ); Ok(CommitOutcome::Pending(PendingCommit { @@ -231,20 +225,8 @@ pub(crate) fn persist_setup_success( })?; } - let state = lqos_setup::bootstrap::record_setup_success(config)?; - event_log.push(format!( - "Setup state updated: {}", - if state.shaping_ready { - "Setup Complete, Shaping Active" - } else { - "Setup Complete, Waiting for Subscriber Data" - } - )); - if !state.shaping_ready { - event_log.push( - "Subscriber topology inputs are still missing. Runtime will remain degraded until network.json and ShapedDevices.csv exist.".to_string(), - ); - } + lqos_setup::bootstrap::record_setup_success(config)?; + event_log.push("Setup state updated: Setup Complete".to_string()); Ok(()) } diff --git a/src/rust/lqos_setup/src/web.rs b/src/rust/lqos_setup/src/web.rs index ff49e568..ffca8c86 100644 --- a/src/rust/lqos_setup/src/web.rs +++ b/src/rust/lqos_setup/src/web.rs @@ -2,8 +2,7 @@ use crate::{ config_builder::{BridgeMode, CURRENT_CONFIG}, - interfaces, - service_handoff, + interfaces, service_handoff, setup_actions::{self, CommitOutcome}, }; use anyhow::{Context, Result, bail}; @@ -129,17 +128,21 @@ async fn create_admin(Form(form): Form) -> Response { return error_response(StatusCode::BAD_REQUEST, "Passwords did not match."); } if form.username.trim().is_empty() || form.password.is_empty() { - return error_response(StatusCode::BAD_REQUEST, "Username and password are required."); + return error_response( + StatusCode::BAD_REQUEST, + "Username and password are required.", + ); } match WebUsers::load_or_create_in(&bootstrap::runtime_lqos_directory()).and_then(|mut users| { users.add_or_update_user(form.username.trim(), &form.password, UserRole::Admin) }) { - Ok(()) => match render_setup_page(Some(&form.token), Some("Admin account created."), None) - { - Ok(html) => Html(html).into_response(), - Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), - }, + Ok(()) => { + match render_setup_page(Some(&form.token), Some("Admin account created."), None) { + Ok(html) => Html(html).into_response(), + Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), + } + } Err(err) => error_response( StatusCode::INTERNAL_SERVER_ERROR, &format!("Unable to create admin user: {err}"), @@ -153,8 +156,11 @@ async fn install_hotfix(Form(form): Form) -> Response { } match hotfix::install() { Ok(result) => { - match render_setup_page(Some(&form.token), Some(&result.summary), Some(&result.detail)) - { + match render_setup_page( + Some(&form.token), + Some(&result.summary), + Some(&result.detail), + ) { Ok(html) => Html(html).into_response(), Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), } @@ -188,14 +194,18 @@ async fn save_setup(Form(form): Form) -> Response { } match setup_actions::prepare_commit() { - Ok(CommitOutcome::Complete(success)) => match finalize_success_html(&success.config, success.event_log) { - Ok(html) => Html(html).into_response(), - Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), - }, - Ok(CommitOutcome::Pending(pending)) => { - Html(render_pending_page(&form.token, &pending.operation_id, &pending.prompt)) - .into_response() + Ok(CommitOutcome::Complete(success)) => { + match finalize_success_html(&success.config, success.event_log) { + Ok(html) => Html(html).into_response(), + Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), + } } + Ok(CommitOutcome::Pending(pending)) => Html(render_pending_page( + &form.token, + &pending.operation_id, + &pending.prompt, + )) + .into_response(), Err(err) => error_response(StatusCode::BAD_REQUEST, &err.to_string()), } } @@ -269,14 +279,6 @@ fn render_setup_page( "
  • Config loads: {}
  • ", yes_no(snapshot.config_loads) )); - html.push_str(&format!( - "
  • network.json present: {}
  • ", - yes_no(snapshot.network_json_present) - )); - html.push_str(&format!( - "
  • ShapedDevices.csv present: {}
  • ", - yes_no(snapshot.shaped_devices_present) - )); html.push_str(&format!( "
  • Hotfix required: {}
  • ", yes_no(snapshot.hotfix_required) @@ -300,7 +302,9 @@ fn render_setup_page( html.push_str("
    "); hidden_token(&mut html, token); html.push_str(""); - html.push_str(""); + html.push_str( + "", + ); html.push_str(""); html.push_str("
    "); } else if !snapshot.hotfix_required { @@ -378,7 +382,9 @@ fn render_setup_page( "", escape_html(&config.allow_subnets.join("\n")) )); - html.push_str(""); + html.push_str( + "", + ); } html.push_str("

    Host Hints

      "); @@ -463,7 +469,9 @@ fn ensure_valid_setup_token(token: &str) -> Result<()> { if bootstrap::validate_setup_token(token)? { Ok(()) } else { - bail!("Setup token is missing, invalid, or expired. Run `lqos_setup print-link` for a current setup URL."); + bail!( + "Setup token is missing, invalid, or expired. Run `lqos_setup print-link` for a current setup URL." + ); } } diff --git a/src/rust/lqos_setup/src/webusers.rs b/src/rust/lqos_setup/src/webusers.rs index c7bf580f..ea8e684e 100644 --- a/src/rust/lqos_setup/src/webusers.rs +++ b/src/rust/lqos_setup/src/webusers.rs @@ -4,10 +4,11 @@ use cursive::{ view::{Nameable, Resizable}, views::{Button, Dialog, EditView, LinearLayout, SelectView, TextView}, }; -use lqos_setup::bootstrap; use lqos_config::{UserRole, WebUsers}; +use lqos_setup::bootstrap; -fn load_or_create_setup_users() -> Result { +fn load_or_create_setup_users() -> Result +{ WebUsers::load_or_create_in(&bootstrap::runtime_lqos_directory()) } diff --git a/src/rust/lqos_support_tool/src/main.rs b/src/rust/lqos_support_tool/src/main.rs index c3cd3715..289d9aaa 100644 --- a/src/rust/lqos_support_tool/src/main.rs +++ b/src/rust/lqos_support_tool/src/main.rs @@ -47,17 +47,17 @@ fn read_line() -> String { } fn get_lts_key() -> String { - if let Ok(cfg) = load_config() { - if let Some(key) = &cfg.long_term_stats.license_key { - return key.clone(); - } + if let Ok(cfg) = load_config() + && let Some(key) = &cfg.long_term_stats.license_key + { + return key.clone(); } println!(); println!("{}", "No LTS Key Found!".bright_red()); println!("We prioritize helping Long-Term Stats Subscribers and Donors."); println!("Please enter your LTS key (from your email), or ENTER for none:"); - return read_line(); + read_line() } fn get_name() -> String { @@ -88,7 +88,7 @@ fn gather_dump() { .as_secs(); let filename = format!("/tmp/libreqos_{}.support", timestamp); let path = Path::new(&filename); - std::fs::write(&path, dump.serialize_and_compress().unwrap()).unwrap(); + std::fs::write(path, dump.serialize_and_compress().unwrap()).unwrap(); lqos_support_tool::console::success(&format!("Dump written to {}", filename)); } @@ -97,13 +97,13 @@ fn summarize(filename: &str) { if !path.exists() { println!("Dump not found at {filename}"); } else { - let bytes = std::fs::read(&path).unwrap(); + let bytes = std::fs::read(path).unwrap(); if let Ok(decoded) = SupportDump::from_bytes(&bytes) { println!("Sent by: {}", decoded.sender); println!("Comments: {}", decoded.comment); println!("LTS Key: {}", decoded.lts_key); - println!("{:50} {:10} {}", "Sanity Check", "Success?", "Comment"); + println!("{:50} {:10} Comment", "Sanity Check", "Success?"); for entry in decoded.sanity_checks.results.iter() { println!("{:50} {:10} {}", entry.name, entry.success, entry.comments); } @@ -148,7 +148,7 @@ fn expand(filename: &str, target: &str) { } // Load the data - let bytes = std::fs::read(&in_path).unwrap(); + let bytes = std::fs::read(in_path).unwrap(); if let Ok(decoded) = SupportDump::from_bytes(&bytes) { // Save the header let header = format!( @@ -171,14 +171,8 @@ fn expand(filename: &str, target: &str) { // Save the files for (idx, dump) in decoded.entries.iter().enumerate() { - let trimmed = dump - .name - .trim() - .replace(' ', "") - .to_lowercase() - .replace('(', "") - .replace(')', ""); - let dump_path = out_path.join(&format!("{idx:0>2}_{}", trimmed)); + let trimmed = dump.name.trim().replace([' ', '(', ')'], "").to_lowercase(); + let dump_path = out_path.join(format!("{idx:0>2}_{}", trimmed)); std::fs::write(dump_path, dump.contents.as_bytes()).unwrap(); } } diff --git a/src/rust/lqos_support_tool/src/sanity_checks.rs b/src/rust/lqos_support_tool/src/sanity_checks.rs index e578a99e..6a8ae989 100644 --- a/src/rust/lqos_support_tool/src/sanity_checks.rs +++ b/src/rust/lqos_support_tool/src/sanity_checks.rs @@ -55,10 +55,8 @@ pub fn run_sanity_checks(echo: bool) -> anyhow::Result { } } - if any_errors { - if echo { - error("ERRORS FOUND DURING SANITY CHECK"); - } + if any_errors && echo { + error("ERRORS FOUND DURING SANITY CHECK"); } Ok(SanityChecks { results }) diff --git a/src/rust/lqos_support_tool/src/sanity_checks/bridge.rs b/src/rust/lqos_support_tool/src/sanity_checks/bridge.rs index 561d0370..3509bf77 100644 --- a/src/rust/lqos_support_tool/src/sanity_checks/bridge.rs +++ b/src/rust/lqos_support_tool/src/sanity_checks/bridge.rs @@ -3,55 +3,58 @@ use lqos_config::load_config; use std::process::Command; pub fn check_bridge(results: &mut Vec) { - if let Ok(cfg) = load_config() { - if let Ok(interfaces) = get_interfaces_from_ip_link() { - // On a stick mode is bridge-free - if cfg.on_a_stick_mode() { - results.push(SanityCheck { - name: format!("Single Interface Mode: Bridges Ignored"), - success: true, - comments: "".to_string(), - }); - return; - } + if let Ok(cfg) = load_config() + && let Ok(interfaces) = get_interfaces_from_ip_link() + { + // On a stick mode is bridge-free + if cfg.on_a_stick_mode() { + results.push(SanityCheck { + name: "Single Interface Mode: Bridges Ignored".to_string(), + success: true, + comments: "".to_string(), + }); + return; + } - // Is the XDP bridge enabled? - if let Some(bridge) = &cfg.bridge { - if bridge.use_xdp_bridge { - for bridge_if in interfaces.iter().filter(|bridge_if| { - bridge_if.link_type == "ether" && bridge_if.operational_state == "UP" - }) { - // We found a bridge. Check member interfaces to check that it does NOT include any XDP - // bridge members. - let in_bridge: Vec<&IpLinkInterface> = interfaces - .iter() - .filter(|member_if| { - if let Some(master) = &member_if.master { - master == &bridge_if.name - } else { - false - } - }) - .filter(|member_if| { - member_if.name == cfg.internet_interface() - || member_if.name == cfg.isp_interface() - }) - .collect(); - - if in_bridge.len() == 2 { - results.push(SanityCheck{ - name: format!("Linux Bridge AND XDP Bridge At Once ({})", bridge_if.name), - success: false, - comments: format!("Bridge ({}) contains both the internet and ISP interfaces, and you have the xdp_bridge enabled. This is not supported.", bridge_if.name), - }); + // Is the XDP bridge enabled? + if let Some(bridge) = &cfg.bridge + && bridge.use_xdp_bridge + { + for bridge_if in interfaces.iter().filter(|bridge_if| { + bridge_if.link_type == "ether" && bridge_if.operational_state == "UP" + }) { + // We found a bridge. Check member interfaces to check that it does NOT include any XDP + // bridge members. + let in_bridge: Vec<&IpLinkInterface> = interfaces + .iter() + .filter(|member_if| { + if let Some(master) = &member_if.master { + master == &bridge_if.name } else { - results.push(SanityCheck { - name: format!("Bridge Membership Check ({})", bridge_if.name), - success: true, - comments: "".to_string(), - }); + false } - } + }) + .filter(|member_if| { + member_if.name == cfg.internet_interface() + || member_if.name == cfg.isp_interface() + }) + .collect(); + + if in_bridge.len() == 2 { + results.push(SanityCheck { + name: format!("Linux Bridge AND XDP Bridge At Once ({})", bridge_if.name), + success: false, + comments: format!( + "Bridge ({}) contains both the internet and ISP interfaces, and you have the xdp_bridge enabled. This is not supported.", + bridge_if.name + ), + }); + } else { + results.push(SanityCheck { + name: format!("Bridge Membership Check ({})", bridge_if.name), + success: true, + comments: "".to_string(), + }); } } } @@ -59,40 +62,40 @@ pub fn check_bridge(results: &mut Vec) { } pub fn check_interface_status(results: &mut Vec) { - if let Ok(cfg) = load_config() { - if let Ok(interfaces) = get_interfaces_from_ip_link() { - if let Some(stick) = &cfg.single_interface { - if let Some(iface) = interfaces.iter().find(|i| i.name == stick.interface) { - results.push(SanityCheck { - name: format!( - "Interface {} in state {}", - iface.name, iface.operational_state - ), - success: true, - comments: "".to_string(), - }); - } - } else if let Some(bridge) = &cfg.bridge { - if let Some(iface) = interfaces.iter().find(|i| i.name == bridge.to_internet) { - results.push(SanityCheck { - name: format!( - "Interface {} in state {}", - iface.name, iface.operational_state - ), - success: true, - comments: "".to_string(), - }); - } - if let Some(iface) = interfaces.iter().find(|i| i.name == bridge.to_network) { - results.push(SanityCheck { - name: format!( - "Interface {} in state {}", - iface.name, iface.operational_state - ), - success: true, - comments: "".to_string(), - }); - } + if let Ok(cfg) = load_config() + && let Ok(interfaces) = get_interfaces_from_ip_link() + { + if let Some(stick) = &cfg.single_interface { + if let Some(iface) = interfaces.iter().find(|i| i.name == stick.interface) { + results.push(SanityCheck { + name: format!( + "Interface {} in state {}", + iface.name, iface.operational_state + ), + success: true, + comments: "".to_string(), + }); + } + } else if let Some(bridge) = &cfg.bridge { + if let Some(iface) = interfaces.iter().find(|i| i.name == bridge.to_internet) { + results.push(SanityCheck { + name: format!( + "Interface {} in state {}", + iface.name, iface.operational_state + ), + success: true, + comments: "".to_string(), + }); + } + if let Some(iface) = interfaces.iter().find(|i| i.name == bridge.to_network) { + results.push(SanityCheck { + name: format!( + "Interface {} in state {}", + iface.name, iface.operational_state + ), + success: true, + comments: "".to_string(), + }); } } } 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 b10372ad..7a235630 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 @@ -23,23 +23,23 @@ pub fn check_net_json_exists(results: &mut Vec) { pub fn can_we_load_net_json(results: &mut Vec) { if let Ok(cfg) = load_config() { let path = Path::new(&cfg.lqos_directory).join("network.json"); - if path.exists() { - if let Ok(str) = std::fs::read_to_string(path) { - match serde_json::from_str::(&str) { - Ok(_json) => { - results.push(SanityCheck { - name: "network.json is parseable JSON".to_string(), - success: true, - comments: "".to_string(), - }); - } - Err(e) => { - results.push(SanityCheck { - name: "network.json is parseable JSON".to_string(), - success: false, - comments: format!("{e:?}"), - }); - } + if path.exists() + && let Ok(str) = std::fs::read_to_string(path) + { + match serde_json::from_str::(&str) { + Ok(_json) => { + results.push(SanityCheck { + name: "network.json is parseable JSON".to_string(), + success: true, + comments: "".to_string(), + }); + } + Err(e) => { + results.push(SanityCheck { + name: "network.json is parseable JSON".to_string(), + success: false, + comments: format!("{e:?}"), + }); } } } diff --git a/src/rust/lqos_support_tool/src/sanity_checks/queues.rs b/src/rust/lqos_support_tool/src/sanity_checks/queues.rs index 15376891..9e1da1ba 100644 --- a/src/rust/lqos_support_tool/src/sanity_checks/queues.rs +++ b/src/rust/lqos_support_tool/src/sanity_checks/queues.rs @@ -12,17 +12,15 @@ fn check_queues(interface: &str) -> (i32, i32) { let mut counts = (0, 0); let paths = std::fs::read_dir(sys_path).unwrap(); for path in paths { - if let Ok(path) = &path { - if path.path().is_dir() { - if let Some(filename) = path.path().file_name() { - if let Some(filename) = filename.to_str() { - if filename.starts_with("rx-") { - counts.0 += 1; - } else if filename.starts_with("tx-") { - counts.1 += 1; - } - } - } + if let Ok(path) = &path + && path.path().is_dir() + && let Some(filename) = path.path().file_name() + && let Some(filename) = filename.to_str() + { + if filename.starts_with("rx-") { + counts.0 += 1; + } else if filename.starts_with("tx-") { + counts.1 += 1; } } } diff --git a/src/rust/lqos_support_tool/src/support_info/systemctl_service_single.rs b/src/rust/lqos_support_tool/src/support_info/systemctl_service_single.rs index 5b30446a..812f5603 100644 --- a/src/rust/lqos_support_tool/src/support_info/systemctl_service_single.rs +++ b/src/rust/lqos_support_tool/src/support_info/systemctl_service_single.rs @@ -23,7 +23,7 @@ impl SupportInfo for SystemCtlService { fn gather(&mut self) -> anyhow::Result<()> { let out = Command::new("/bin/systemctl") - .args(&["--no-pager", "status", &self.target]) + .args(["--no-pager", "status", &self.target]) .output()?; self.output = String::from_utf8_lossy(&out.stdout).to_string(); diff --git a/src/rust/lqos_support_tool/src/support_info/systemctl_services.rs b/src/rust/lqos_support_tool/src/support_info/systemctl_services.rs index 9c6d2d8e..1299e8cf 100644 --- a/src/rust/lqos_support_tool/src/support_info/systemctl_services.rs +++ b/src/rust/lqos_support_tool/src/support_info/systemctl_services.rs @@ -22,7 +22,7 @@ impl SupportInfo for SystemCtlServices { fn gather(&mut self) -> anyhow::Result<()> { let out = Command::new("/bin/systemctl") - .args(&["--no-pager", "status"]) + .args(["--no-pager", "status"]) .output()?; self.output = String::from_utf8_lossy(&out.stdout).to_string(); diff --git a/src/rust/lqos_support_tool/src/support_info/task_journal.rs b/src/rust/lqos_support_tool/src/support_info/task_journal.rs index fcb82d5e..283d5be1 100644 --- a/src/rust/lqos_support_tool/src/support_info/task_journal.rs +++ b/src/rust/lqos_support_tool/src/support_info/task_journal.rs @@ -23,7 +23,7 @@ impl SupportInfo for TaskJournal { fn gather(&mut self) -> anyhow::Result<()> { let out = Command::new("/bin/journalctl") - .args(&["--no-pager", "-u", &self.target]) + .args(["--no-pager", "-u", &self.target]) .output()?; self.output = String::from_utf8_lossy(&out.stdout).to_string(); diff --git a/src/rust/lqos_utils/src/directory_watcher.rs b/src/rust/lqos_utils/src/directory_watcher.rs index 018893a3..0205e2ee 100644 --- a/src/rust/lqos_utils/src/directory_watcher.rs +++ b/src/rust/lqos_utils/src/directory_watcher.rs @@ -100,10 +100,7 @@ impl DirectoryWatcher { } } -fn collect_paths( - event: Result, - changed_paths: &mut BTreeSet, -) { +fn collect_paths(event: Result, changed_paths: &mut BTreeSet) { match event { Ok(event) => { changed_paths.extend(event.paths); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/setup_runtime.js b/src/rust/lqosd/src/node_manager/js_build/src/setup_runtime.js index 354fe822..14e58729 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/setup_runtime.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/setup_runtime.js @@ -6,13 +6,29 @@ function setText(id, text) { element.textContent = text; } +function syncIntegrationAction() { + const button = document.getElementById("btnOpenIntegrationProvider"); + const select = document.getElementById("runtimeIntegrationProvider"); + if (!button || !select) return; + + button.disabled = !select.value; + select.addEventListener("change", () => { + button.disabled = !select.value; + }); +} + function setStatusAlert(state) { const element = document.getElementById("runtimeSetupStatus"); if (!element) return; const label = state?.status_label || "Setup Required"; const summary = state?.summary || "Choose a topology source before expecting scheduler activity."; - const alertClass = state?.required ? "alert-warning" : "alert-success"; + const severity = state?.status_severity || (state?.required ? "warning" : "success"); + const alertClass = severity === "danger" + ? "alert-danger" + : (severity === "warning" + ? "alert-warning" + : (severity === "info" ? "alert-info" : "alert-success")); element.className = `alert ${alertClass} mb-4`; element.innerHTML = `
      ${label}
      @@ -32,6 +48,7 @@ function renderState(state) { ); setText("runtimeNetworkJson", state?.network_json_present ? "Present" : "Missing"); setText("runtimeShapedDevices", state?.shaped_devices_present ? "Present" : "Missing"); + syncIntegrationAction(); } function initActions() { @@ -39,6 +56,7 @@ function initActions() { const select = document.getElementById("runtimeIntegrationProvider"); if (!button || !select) return; button.addEventListener("click", () => { + if (button.disabled) return; window.location.href = select.value; }); } @@ -52,5 +70,6 @@ loadConfig((msg) => { status_label: "Setup Required", summary: "Unable to load runtime setup status right now.", }); + syncIntegrationAction(); initActions(); }); diff --git a/src/rust/lqosd/src/node_manager/local_api/scheduler.rs b/src/rust/lqosd/src/node_manager/local_api/scheduler.rs index 0506842e..377307bc 100644 --- a/src/rust/lqosd/src/node_manager/local_api/scheduler.rs +++ b/src/rust/lqosd/src/node_manager/local_api/scheduler.rs @@ -208,3 +208,147 @@ pub fn scheduler_details_data() -> SchedulerDetails { details: body, } } + +#[cfg(test)] +mod tests { + use super::{scheduler_details_data, scheduler_status_data}; + use crate::test_support::runtime_config_test_lock; + use crate::tool_status::{ + scheduler_error, scheduler_output, scheduler_progress, scheduler_seen, + }; + use lqos_bus::SchedulerProgressReport; + use std::ffi::OsString; + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_test_dir(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock before epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "libreqos-scheduler-status-{label}-{}-{unique}", + std::process::id() + )) + } + + fn write_scheduler_test_config(runtime_dir: &Path) -> PathBuf { + let config_path = runtime_dir.join("lqos.conf"); + let runtime_dir_display = runtime_dir.to_string_lossy(); + let state_dir_display = runtime_dir.join("state").to_string_lossy().into_owned(); + let raw = include_str!("../../../../lqos_config/src/etc/v15/example.toml") + .replacen( + "lqos_directory = \"/opt/libreqos/src\"", + &format!("lqos_directory = {:?}", runtime_dir_display), + 1, + ) + .replacen( + "state_directory = \"/opt/libreqos/state\"", + &format!("state_directory = {:?}", state_dir_display), + 1, + ) + .replacen("enable_splynx = false", "enable_splynx = true", 1); + fs::write(&config_path, raw).expect("write scheduler test config"); + config_path + } + + struct SchedulerStatusTestContext { + _guard: std::sync::MutexGuard<'static, ()>, + old_lqos_config: Option, + old_lqos_directory: Option, + runtime_dir: PathBuf, + } + + impl SchedulerStatusTestContext { + fn new(label: &str) -> Self { + let guard = runtime_config_test_lock() + .lock() + .expect("scheduler status test lock should not be poisoned"); + let runtime_dir = unique_test_dir(label); + fs::create_dir_all(runtime_dir.join("state")).expect("create runtime state directory"); + let config_path = write_scheduler_test_config(&runtime_dir); + 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", &runtime_dir); + } + lqos_config::clear_cached_config(); + Self { + _guard: guard, + old_lqos_config, + old_lqos_directory, + runtime_dir, + } + } + } + + impl Drop for SchedulerStatusTestContext { + fn drop(&mut self) { + scheduler_error(None); + scheduler_output(None); + scheduler_progress(None); + match &self.old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match &self.old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + lqos_config::clear_cached_config(); + let _ = fs::remove_dir_all(&self.runtime_dir); + } + } + + #[test] + fn scheduler_status_surfaces_validation_failure_for_ui_contract() { + let _context = SchedulerStatusTestContext::new("validation-failure"); + scheduler_seen(); + scheduler_error(Some( + "Scheduled shaping refresh blocked by validation: duplicate IPv4".to_string(), + )); + scheduler_output(Some( + "Scheduled shaping refresh blocked by validation: duplicate IPv4".to_string(), + )); + scheduler_progress(Some(SchedulerProgressReport { + active: false, + phase: "validation_failed".to_string(), + phase_label: "Scheduler validation failed".to_string(), + step_index: 5, + step_count: 5, + percent: 100, + updated_unix: Some(1_234_567_890), + })); + + let status = scheduler_status_data(); + let details = scheduler_details_data(); + + assert!(status.available); + assert_eq!( + status.error.as_deref(), + Some("Scheduled shaping refresh blocked by validation: duplicate IPv4") + ); + assert_eq!( + status + .progress + .as_ref() + .map(|progress| progress.phase.as_str()), + Some("validation_failed") + ); + assert!(!status.setup_required); + + assert!(details.available); + assert_eq!( + details.error.as_deref(), + Some("Scheduled shaping refresh blocked by validation: duplicate IPv4") + ); + assert_eq!( + details.output.as_deref(), + Some("Scheduled shaping refresh blocked by validation: duplicate IPv4") + ); + assert!(details.details.contains("Reported error:")); + assert!(details.details.contains("Scheduler validation failed")); + } +} diff --git a/src/rust/lqosd/src/node_manager/local_api/throughput_attribution_debug.rs b/src/rust/lqosd/src/node_manager/local_api/throughput_attribution_debug.rs index 88f846bb..e5305d53 100644 --- a/src/rust/lqosd/src/node_manager/local_api/throughput_attribution_debug.rs +++ b/src/rust/lqosd/src/node_manager/local_api/throughput_attribution_debug.rs @@ -147,16 +147,16 @@ fn match_source_for_entry<'a>( device_hash: Option, circuit_hash: Option, ) -> Option<(&'a lqos_config::ShapedDevice, ShapedDeviceMatchSource)> { - if let Some(hash) = device_hash { - if let Some(device) = catalog.device_by_hashes(Some(hash), None) { - return Some((device, ShapedDeviceMatchSource::DeviceHash)); - } + if let Some(hash) = device_hash + && let Some(device) = catalog.device_by_hashes(Some(hash), None) + { + return Some((device, ShapedDeviceMatchSource::DeviceHash)); } - if let Some(hash) = circuit_hash { - if let Some(device) = catalog.device_by_hashes(None, Some(hash)) { - return Some((device, ShapedDeviceMatchSource::CircuitHash)); - } + if let Some(hash) = circuit_hash + && let Some(device) = catalog.device_by_hashes(None, Some(hash)) + { + return Some((device, ShapedDeviceMatchSource::CircuitHash)); } catalog diff --git a/src/rust/lqosd/src/node_manager/runtime_onboarding.rs b/src/rust/lqosd/src/node_manager/runtime_onboarding.rs index a03d0bf0..ff2219d9 100644 --- a/src/rust/lqosd/src/node_manager/runtime_onboarding.rs +++ b/src/rust/lqosd/src/node_manager/runtime_onboarding.rs @@ -34,6 +34,8 @@ pub(crate) struct RuntimeOnboardingState { pub network_json_present: bool, /// Whether `ShapedDevices.csv` exists in the configured LibreQoS directory. pub shaped_devices_present: bool, + /// Alert severity for the onboarding status banner. + pub status_severity: String, } fn topology_files_present(config: &Config) -> (bool, bool) { @@ -54,15 +56,16 @@ fn from_config(config: &Config) -> RuntimeOnboardingState { if !active_integrations.is_empty() { return RuntimeOnboardingState { required: false, - status_label: "Ready".to_string(), + status_label: "Configured".to_string(), summary: format!( - "Topology source is configured through {}.", + "Topology source is configured through {}. Topology data may still be publishing.", active_integrations.join(", ") ), source_kind: RuntimeTopologySourceKind::Integration, active_integrations, network_json_present, shaped_devices_present, + status_severity: "info".to_string(), }; } @@ -75,6 +78,7 @@ fn from_config(config: &Config) -> RuntimeOnboardingState { active_integrations, network_json_present, shaped_devices_present, + status_severity: "success".to_string(), }; } @@ -100,31 +104,37 @@ fn from_config(config: &Config) -> RuntimeOnboardingState { active_integrations, network_json_present, shaped_devices_present, + status_severity: "warning".to_string(), + } +} + +fn config_error_state() -> RuntimeOnboardingState { + RuntimeOnboardingState { + required: true, + status_label: "Config Error".to_string(), + summary: "Unable to load LibreQoS configuration. Resolve the config error before continuing runtime setup.".to_string(), + source_kind: RuntimeTopologySourceKind::None, + active_integrations: Vec::new(), + network_json_present: false, + shaped_devices_present: false, + status_severity: "danger".to_string(), } } /// Loads the current runtime onboarding state from config and topology files. /// -/// On config-read failure this returns a conservative non-blocking state so the -/// UI does not loop on an internal error. +/// On config-read failure this returns a blocking error state so the UI does not +/// present an internal problem as a successful topology setup. pub(crate) fn runtime_onboarding_state() -> RuntimeOnboardingState { match load_config() { Ok(config) => from_config(config.as_ref()), - Err(_) => RuntimeOnboardingState { - required: false, - status_label: "Unknown".to_string(), - summary: "Unable to determine topology onboarding state.".to_string(), - source_kind: RuntimeTopologySourceKind::None, - active_integrations: Vec::new(), - network_json_present: false, - shaped_devices_present: false, - }, + Err(_) => config_error_state(), } } #[cfg(test)] mod tests { - use super::{RuntimeTopologySourceKind, from_config}; + use super::{RuntimeTopologySourceKind, config_error_state, from_config}; use lqos_config::Config; use std::fs; use std::path::PathBuf; @@ -146,8 +156,10 @@ mod tests { #[test] fn requires_setup_when_no_integrations_or_manual_files_exist() { let dir = test_dir("missing"); - let mut config = Config::default(); - config.lqos_directory = dir.to_string_lossy().into_owned(); + let config = Config { + lqos_directory: dir.to_string_lossy().into_owned(), + ..Config::default() + }; let state = from_config(&config); @@ -166,8 +178,10 @@ mod tests { fs::write(dir.join("ShapedDevices.csv"), "Circuit ID,Device ID\n") .expect("write shaped devices"); - let mut config = Config::default(); - config.lqos_directory = dir.to_string_lossy().into_owned(); + let config = Config { + lqos_directory: dir.to_string_lossy().into_owned(), + ..Config::default() + }; let state = from_config(&config); @@ -180,10 +194,12 @@ mod tests { } #[test] - fn enabled_integration_counts_as_ready_topology_source() { + fn enabled_integration_is_configured_but_not_marked_ready() { let dir = test_dir("integration"); - let mut config = Config::default(); - config.lqos_directory = dir.to_string_lossy().into_owned(); + let mut config = Config { + lqos_directory: dir.to_string_lossy().into_owned(), + ..Config::default() + }; config.splynx_integration.enable_splynx = true; let state = from_config(&config); @@ -191,7 +207,17 @@ mod tests { assert!(!state.required); assert_eq!(state.source_kind, RuntimeTopologySourceKind::Integration); assert_eq!(state.active_integrations, vec!["Splynx".to_string()]); + assert_eq!(state.status_label, "Configured"); + assert_eq!(state.status_severity, "info"); fs::remove_dir_all(dir).expect("remove temp dir"); } + + #[test] + fn config_error_state_blocks_runtime_onboarding() { + let state = config_error_state(); + assert!(state.required); + assert_eq!(state.status_label, "Config Error"); + assert_eq!(state.status_severity, "danger"); + } } diff --git a/src/rust/lqosd/src/node_manager/static2/setup_runtime.html b/src/rust/lqosd/src/node_manager/static2/setup_runtime.html index 9f016c6b..5bd19702 100644 --- a/src/rust/lqosd/src/node_manager/static2/setup_runtime.html +++ b/src/rust/lqosd/src/node_manager/static2/setup_runtime.html @@ -25,6 +25,7 @@