This commit is contained in:
rchac committed 2026-04-14 18:26:19 +00:00
1 parent 2b3c581a7f
commit 0bb9eda01b
45 files changed
+1748 -1337

No files matched your search

+66 -54
View File
@@ -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
+17 -1
View File
@@ -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 <username>:<username> /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
+18
View File
@@ -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 <username>:<username> /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
+1 -32
View File
@@ -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 {
@@ -308,8 +308,8 @@ mod test {
.filter(|line| !line.trim().starts_with("set_cpu_governor_performance"))
.collect::<Vec<_>>()
.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
+2 -3
View File
@@ -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<ArcSwap<Option<Arc<Config>>>> = Lazy::new(|| ArcSwap::from_pointee(None));
+8 -28
View File
@@ -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.
+55 -10
View File
@@ -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::<serde_json::Value>(&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;
+34 -23
View File
@@ -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]
@@ -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<String>,
) -> 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);
}
}
@@ -280,7 +280,7 @@ pub(crate) fn current_topology_ingress_identity(
) -> Result<Option<String>, 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::<Value>(&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",
@@ -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<Option<PathBuf>, 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<Option<TopologyShapingInputsFile>, 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(),
+8 -3
View File
@@ -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.
@@ -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<Vec<DynamicCircuit>>,
) -> Self {
pub fn from_snapshots(shaped: ShapedDevicesCatalog, dynamic: Arc<Vec<DynamicCircuit>>) -> 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<Item = &ShapedDevice> {
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.
@@ -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<Self> {
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<Vec<PathBuf>, WatchedDirectoryError> {
self.inner.watch()
}
fn watch_relevant(&mut self) -> Result<Vec<DirectoryReloadEvent>, 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<Vec<DirectoryReloadEvent>, 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<DirectoryReloadEvent> {
}
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
]
);
}
}
+58 -40
View File
@@ -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<Arc<dyn DaemonHooks>>) -> 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<Option<TopologyShapingInputsFile>> {
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<ConfigShapedDevices> {
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<ConfigShapedDevices> {
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.
@@ -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<String> {
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<Option<TopologyShapingInputsFile>> {
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
}
+351 -2
View File
@@ -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<Mutex<()>> = 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());
}
@@ -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());
+32 -69
View File
@@ -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<String>,
/// 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<String> {
.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<BootstrapState> {
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<BootstrapState> {
{
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<BootstrapState> {
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<BootstrapState> {
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<StatusSnapshot> {
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<StatusSnapshot> {
/// Renders a text report for `lqos_setup status`.
pub fn render_status_report() -> Result<String> {
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<String> {
"\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<bool> {
} 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.
+4 -5
View File
@@ -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<String> {
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())
{
+45 -234
View File
@@ -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);
}
}
+5 -1
View File
@@ -99,7 +99,11 @@ fn switch_service_mode(
pub(crate) fn activate_runtime_services() -> Result<String> {
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.",
)
}
+5 -23
View File
@@ -79,9 +79,7 @@ pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> 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<CommitOutcome> {
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<CommitOutcome> {
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(())
}
+36 -28
View File
@@ -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<AdminForm>) -> 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<HotfixForm>) -> 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<SaveForm>) -> 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(
"<li>Config loads: {}</li>",
yes_no(snapshot.config_loads)
));
html.push_str(&format!(
"<li>network.json present: {}</li>",
yes_no(snapshot.network_json_present)
));
html.push_str(&format!(
"<li>ShapedDevices.csv present: {}</li>",
yes_no(snapshot.shaped_devices_present)
));
html.push_str(&format!(
"<li>Hotfix required: {}</li>",
yes_no(snapshot.hotfix_required)
@@ -300,7 +302,9 @@ fn render_setup_page(
html.push_str("<form method=\"post\" action=\"/setup/create-admin\" onsubmit=\"return submitWithProgress(this, 'Creating admin account...');\">");
hidden_token(&mut html, token);
html.push_str("<label>Username<input type=\"text\" name=\"username\" required></label>");
html.push_str("<label>Password<input type=\"password\" name=\"password\" required></label>");
html.push_str(
"<label>Password<input type=\"password\" name=\"password\" required></label>",
);
html.push_str("<label>Confirm Password<input type=\"password\" name=\"confirm_password\" required></label>");
html.push_str("<button type=\"submit\">Create Admin</button></form></section>");
} else if !snapshot.hotfix_required {
@@ -378,7 +382,9 @@ fn render_setup_page(
"<label>Allowed Subnets<textarea name=\"allow_subnets\" rows=\"6\">{}</textarea></label>",
escape_html(&config.allow_subnets.join("\n"))
));
html.push_str("<button id=\"saveSetupButton\" type=\"submit\">Save Setup</button></form></section>");
html.push_str(
"<button id=\"saveSetupButton\" type=\"submit\">Save Setup</button></form></section>",
);
}
html.push_str("<section class=\"card\"><h2>Host Hints</h2><ul class=\"status-list\">");
@@ -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."
);
}
}
+3 -2
View File
@@ -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<WebUsers, lqos_config::authentication::AuthenticationError> {
fn load_or_create_setup_users() -> Result<WebUsers, lqos_config::authentication::AuthenticationError>
{
WebUsers::load_or_create_in(&bootstrap::runtime_lqos_directory())
}
+11 -17
View File
@@ -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();
}
}
@@ -55,10 +55,8 @@ pub fn run_sanity_checks(echo: bool) -> anyhow::Result<SanityChecks> {
}
}
if any_errors {
if echo {
error("ERRORS FOUND DURING SANITY CHECK");
}
if any_errors && echo {
error("ERRORS FOUND DURING SANITY CHECK");
}
Ok(SanityChecks { results })
@@ -3,55 +3,58 @@ use lqos_config::load_config;
use std::process::Command;
pub fn check_bridge(results: &mut Vec<SanityCheck>) {
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<SanityCheck>) {
}
pub fn check_interface_status(results: &mut Vec<SanityCheck>) {
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(),
});
}
}
}
@@ -23,23 +23,23 @@ pub fn check_net_json_exists(results: &mut Vec<SanityCheck>) {
pub fn can_we_load_net_json(results: &mut Vec<SanityCheck>) {
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::<Value>(&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::<Value>(&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:?}"),
});
}
}
}
@@ -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;
}
}
}
@@ -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();
@@ -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();
@@ -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();
+1 -4
View File
@@ -100,10 +100,7 @@ impl DirectoryWatcher {
}
}
fn collect_paths(
event: Result<Event, notify::Error>,
changed_paths: &mut BTreeSet<PathBuf>,
) {
fn collect_paths(event: Result<Event, notify::Error>, changed_paths: &mut BTreeSet<PathBuf>) {
match event {
Ok(event) => {
changed_paths.extend(event.paths);
@@ -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 = `
<div class="fw-semibold mb-1">${label}</div>
@@ -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();
});
@@ -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<OsString>,
old_lqos_directory: Option<OsString>,
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"));
}
}
@@ -147,16 +147,16 @@ fn match_source_for_entry<'a>(
device_hash: Option<i64>,
circuit_hash: Option<i64>,
) -> 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
@@ -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");
}
}
@@ -25,6 +25,7 @@
</p>
<label for="runtimeIntegrationProvider" class="form-label mt-2">Integration Provider</label>
<select class="form-select mb-3" id="runtimeIntegrationProvider">
<option value="" disabled selected>Choose provider</option>
<option value="config_uisp.html">UISP</option>
<option value="config_splynx.html">Splynx</option>
<option value="config_visp.html">VISP</option>
@@ -37,7 +38,7 @@
Save your provider settings there, then return here if the scheduler still says setup is required.
</div>
<div class="mt-auto d-grid">
<button type="button" class="btn btn-primary" id="btnOpenIntegrationProvider">
<button type="button" class="btn btn-primary" id="btnOpenIntegrationProvider" disabled>
<i class="fa fa-arrow-right"></i> Configure Provider
</button>
</div>
+28 -463
View File
@@ -1,13 +1,12 @@
use crate::node_manager::local_api::network_tree_lite::NetworkTreeLiteNode;
use crate::treeguard::actor::is_runtime_virtualized_node;
use anyhow::{Context, Result};
use anyhow::Result;
use arc_swap::ArcSwap;
use fxhash::{FxHashMap, FxHashSet};
use lqos_bus::{BusResponse, Circuit};
use lqos_config::{
ConfigShapedDevices, NetworkJsonNode, NetworkJsonTransport, ShapedDevice,
TopologyRuntimeStatusFile, TopologyShapingInputsFile, load_config,
topology_runtime_status_path, topology_shaping_inputs_path,
NetworkJsonNode, NetworkJsonTransport, TopologyShapingInputsFile, load_config,
topology_runtime_status_path,
};
use lqos_queue_tracker::EFFECTIVE_NODE_RATES;
use lqos_utils::file_watcher::FileWatcher;
@@ -17,8 +16,6 @@ use lqos_utils::units::{DownUpOrder, down_up_retransmit_sample};
use lqos_utils::unix_time::time_since_boot;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::time::Duration;
@@ -96,219 +93,33 @@ fn publish_shaping_inputs(shaping_inputs: TopologyShapingInputsFile) {
invalidate_executive_cache_snapshot();
}
fn active_runtime_shaping_inputs_path(config: &lqos_config::Config) -> Result<Option<PathBuf>> {
let status = TopologyRuntimeStatusFile::load(config)?;
if !status.ready {
return Ok(None);
}
let path = status.shaping_inputs_path.trim();
if path.is_empty() {
return Ok(None);
}
Ok(Some(PathBuf::from(path)))
}
fn load_active_runtime_shaping_inputs(
config: &lqos_config::Config,
) -> Result<Option<TopologyShapingInputsFile>> {
if let Some(active_path) = active_runtime_shaping_inputs_path(config)? {
if active_path.exists() {
let raw = std::fs::read_to_string(&active_path)?;
let shaping_inputs = serde_json::from_str(&raw)?;
return Ok(Some(shaping_inputs));
}
}
let fallback_path = topology_shaping_inputs_path(config);
if !fallback_path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&fallback_path)?;
let shaping_inputs = serde_json::from_str(&raw)?;
Ok(Some(shaping_inputs))
}
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()
}
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
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ShapedDevicesLoadSource {
RuntimeShapingInputs,
TopologyImport,
ShapedDevicesCsv,
}
fn integration_ingress_enabled(config: &lqos_config::Config) -> bool {
config.uisp_integration.enable_uisp
|| config.splynx_integration.enable_splynx
|| config
.netzur_integration
.as_ref()
.is_some_and(|integration| integration.enable_netzur)
|| config
.visp_integration
.as_ref()
.is_some_and(|integration| integration.enable_visp)
|| config.powercode_integration.enable_powercode
|| config.sonar_integration.enable_sonar
|| config
.wispgate_integration
.as_ref()
.is_some_and(|integration| integration.enable_wispgate)
}
fn load_ready_runtime_shaping_inputs(
config: &lqos_config::Config,
) -> Result<Option<TopologyShapingInputsFile>> {
let Some(active_path) = active_runtime_shaping_inputs_path(config)? else {
return Ok(None);
};
if !active_path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&active_path)
.with_context(|| format!("Unable to read shaping inputs at {}", active_path.display()))?;
let shaping_inputs = serde_json::from_str(&raw).with_context(|| {
format!(
"Unable to decode shaping inputs JSON at {}",
active_path.display()
)
})?;
Ok(Some(shaping_inputs))
}
fn load_shaped_devices_from_preferred_source(
config: &lqos_config::Config,
) -> Result<(ConfigShapedDevices, ShapedDevicesLoadSource)> {
if let Some(shaping_inputs) = load_ready_runtime_shaping_inputs(config)? {
return Ok((
shaped_devices_from_runtime_inputs(&shaping_inputs),
ShapedDevicesLoadSource::RuntimeShapingInputs,
));
}
if integration_ingress_enabled(config) {
match lqos_topology_compile::TopologyImportFile::load(config) {
Ok(Some(topology_import)) => {
let shaped_devices = topology_import.into_imported_bundle().shaped_devices;
if !shaped_devices.devices.is_empty() {
return Ok((shaped_devices, ShapedDevicesLoadSource::TopologyImport));
}
debug!(
"topology_import.json contained 0 shaped devices; falling back to ShapedDevices.csv"
);
}
Ok(None) => {
debug!("topology_import.json missing; falling back to ShapedDevices.csv");
}
Err(err) => {
debug!(
"Unable to load topology_import.json ({err}); falling back to ShapedDevices.csv"
);
}
}
}
let shaped_devices =
ConfigShapedDevices::load_for_config(config).context("Unable to load ShapedDevices.csv")?;
Ok((shaped_devices, ShapedDevicesLoadSource::ShapedDevicesCsv))
}
fn load_shaping_inputs() {
let Ok(config) = load_config() else {
warn!("Unable to load LibreQoS config while loading shaping_inputs.json");
publish_shaping_inputs(TopologyShapingInputsFile::default());
return;
};
match load_active_runtime_shaping_inputs(config.as_ref()) {
match lqos_network_devices::load_runtime_shaping_inputs_for_config(config.as_ref()) {
Ok(Some(shaping_inputs)) => {
debug!("Loaded shaping inputs from active runtime status");
publish_shaping_inputs(shaping_inputs);
}
Ok(None) => {
debug!("No active runtime shaping inputs published; clearing effective parent cache");
publish_shaping_inputs(TopologyShapingInputsFile::default());
if lqos_config::integration_ingress_enabled(config.as_ref()) {
// Preserve the last known-good parent map so a transient publication race or
// temporary read failure does not blank topology overlays between runtime writes.
debug!(
"No active runtime shaping inputs published; keeping last-known-good effective parent cache"
);
} else {
debug!("Integration ingress disabled; clearing effective parent cache");
publish_shaping_inputs(TopologyShapingInputsFile::default());
}
}
Err(err) => {
warn!("Unable to load shaping_inputs.json: {err}");
warn!(
"Unable to load shaping_inputs.json: {err}; keeping last-known-good effective parent cache"
);
}
}
}
@@ -939,14 +750,11 @@ pub fn get_circuit_by_id(desired_circuit_id: String) -> BusResponse {
mod tests {
use super::*;
use lqos_config::{
CircuitAnchorsFile, Config, TOPOLOGY_RUNTIME_STATUS_FILENAME, TopologyShapingCircuitInput,
Config, TOPOLOGY_RUNTIME_STATUS_FILENAME, TopologyShapingCircuitInput,
TopologyShapingDeviceInput, TopologyShapingInputsFile,
};
use lqos_topology_compile::{ImportedTopologyBundle, TopologyImportFile};
use lqos_utils::XdpIpAddress;
use serde_json::json;
use std::fs;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_temp_dir(prefix: &str) -> PathBuf {
@@ -959,27 +767,11 @@ mod tests {
path
}
fn write_shaped_devices_csv(path: &std::path::Path, circuit_id: &str, ip: &str) {
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,
) {
fs::write(
path,
@@ -989,7 +781,7 @@ mod tests {
"shaping_inputs_path": shaping_inputs_path,
"effective_state_path": "",
"effective_network_path": "",
"source_generation": "gen-1",
"source_generation": source_generation,
"shaping_generation": "shape-1",
})
.to_string(),
@@ -1033,46 +825,6 @@ mod tests {
assert!(map.is_empty());
}
#[test]
fn shaped_devices_from_runtime_inputs_uses_effective_parent_and_ip_lookup() {
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 = shaped_devices_from_runtime_inputs(&shaping_inputs);
let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse"));
let device = shaped
.get_device_from_ip(&ip)
.expect("lookup should resolve");
assert_eq!(device.parent_node, "Parent-A");
assert_eq!(device.parent_node_id.as_deref(), Some("parent-id-1"));
assert_eq!(device.anchor_node_id.as_deref(), Some("anchor-1"));
assert_eq!(device.comment, "device-comment");
assert_eq!(device.circuit_id, "circuit-1");
}
#[test]
fn load_active_runtime_shaping_inputs_prefers_runtime_status_path_over_state_fallback() {
let lqos_directory = unique_temp_dir("lqosd-shaped-devices-runtime-status");
@@ -1119,209 +871,22 @@ mod tests {
serde_json::to_string_pretty(&stale_inputs).expect("stale shaping should encode"),
)
.expect("stale shaping should write");
write_runtime_status(&status_path, true, &active_shaping_path);
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 mut config = config;
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, &active_shaping_path, &source_generation);
let loaded = load_active_runtime_shaping_inputs(&config)
let loaded = lqos_network_devices::load_runtime_shaping_inputs_for_config(&config)
.expect("runtime shaping should load")
.expect("runtime shaping inputs should be active");
assert_eq!(loaded.circuits.len(), 1);
assert_eq!(loaded.circuits[0].circuit_id, "active-circuit");
}
#[test]
fn load_shaped_devices_prefers_ready_runtime_even_when_empty() {
let lqos_directory = unique_temp_dir("lqosd-shaped-devices-runtime-empty");
let state_directory = lqos_directory.join("state");
fs::create_dir_all(state_directory.join("topology")).expect("topology dir should exist");
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",
);
fs::write(
&runtime_path,
serde_json::to_string_pretty(&TopologyShapingInputsFile::default())
.expect("empty shaping inputs should encode"),
)
.expect("runtime shaping inputs should write");
write_runtime_status(&status_path, true, &runtime_path);
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, source) = load_shaped_devices_from_preferred_source(&config)
.expect("preferred shaped-device source should load");
assert_eq!(source, ShapedDevicesLoadSource::RuntimeShapingInputs);
assert!(loaded.devices.is_empty());
}
#[test]
fn load_shaped_devices_uses_csv_when_runtime_is_not_ready() {
let lqos_directory = unique_temp_dir("lqosd-shaped-devices-csv-fallback");
let state_directory = lqos_directory.join("state");
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);
write_shaped_devices_csv(
&lqos_directory.join("ShapedDevices.csv"),
"csv-circuit",
"192.0.2.10/32",
);
fs::write(
&runtime_path,
serde_json::to_string_pretty(&TopologyShapingInputsFile {
circuits: vec![TopologyShapingCircuitInput {
circuit_id: "runtime-circuit".to_string(),
devices: vec![TopologyShapingDeviceInput {
device_id: "device-1".to_string(),
device_name: "Device 1".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, false, &runtime_path);
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, source) =
load_shaped_devices_from_preferred_source(&config).expect("CSV fallback should load");
assert_eq!(source, ShapedDevicesLoadSource::ShapedDevicesCsv);
assert_eq!(loaded.devices.len(), 1);
assert_eq!(loaded.devices[0].circuit_id, "csv-circuit");
}
#[test]
fn load_shaped_devices_uses_topology_import_when_runtime_is_not_ready() {
let lqos_directory = unique_temp_dir("lqosd-shaped-devices-topology-import-fallback");
let state_directory = lqos_directory.join("state");
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);
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);
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, source) = load_shaped_devices_from_preferred_source(&config)
.expect("topology import fallback should load");
assert_eq!(source, ShapedDevicesLoadSource::TopologyImport);
assert_eq!(loaded.devices.len(), 1);
assert_eq!(loaded.devices[0].circuit_id, "import-circuit");
}
#[test]
fn load_shaped_devices_falls_back_to_csv_when_topology_import_is_empty() {
let lqos_directory = unique_temp_dir("lqosd-shaped-devices-topology-import-empty");
let state_directory = lqos_directory.join("state");
fs::create_dir_all(state_directory.join("topology")).expect("topology dir should exist");
write_shaped_devices_csv(
&lqos_directory.join("ShapedDevices.csv"),
"csv-circuit",
"192.0.2.10/32",
);
let imported = ImportedTopologyBundle {
source: "test/import".to_string(),
generated_unix: Some(123),
ingress_identity: Some("import-base".to_string()),
native_canonical: None,
native_editor: None,
parent_candidates: None,
compatibility_network_json: json!({}),
shaped_devices: ConfigShapedDevices::default(),
circuit_anchors: CircuitAnchorsFile::default(),
ethernet_advisories: Vec::new(),
};
TopologyImportFile::from_imported_bundle(&imported, "full")
.save(&Config {
lqos_directory: lqos_directory.to_string_lossy().to_string(),
state_directory: Some(state_directory.to_string_lossy().to_string()),
..Config::default()
})
.expect("topology import should save");
let mut config = Config {
lqos_directory: lqos_directory.to_string_lossy().to_string(),
state_directory: Some(state_directory.to_string_lossy().to_string()),
..Config::default()
};
config.uisp_integration.enable_uisp = true;
let (loaded, source) =
load_shaped_devices_from_preferred_source(&config).expect("CSV fallback should load");
assert_eq!(source, ShapedDevicesLoadSource::ShapedDevicesCsv);
assert_eq!(loaded.devices.len(), 1);
assert_eq!(loaded.devices[0].circuit_id, "csv-circuit");
}
}
@@ -718,22 +718,7 @@ fn load_local_shaped_devices() -> anyhow::Result<Vec<ShapedDevice>> {
}
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)
lqos_config::integration_ingress_enabled(config)
}
#[cfg(test)]
+48 -11
View File
@@ -4,7 +4,7 @@ import csv
import io
import json
import chardet
from LibreQoS import refreshShapers, refreshShapersUpdateOnly
from LibreQoS import ValidationFailure, refreshShapers, refreshShapersUpdateOnly
import subprocess
import sys
import tempfile
@@ -75,11 +75,8 @@ def get_state_path(category: str, filename: str) -> str:
return os.path.join(get_state_directory(), category, filename)
def get_existing_state_path(category: str, filename: str) -> str:
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: str, filename: str) -> str:
return get_state_path(category, filename)
def set_scheduler_status_bus_enabled(enabled: bool):
@@ -149,6 +146,7 @@ def report_scheduler_runtime_failure(context: str, exc: Exception, *, startup: b
message = f"{context}: {exc}"
print(message)
scheduler_error(message)
scheduler_output(message)
if startup:
publish_scheduler_progress(
False,
@@ -160,6 +158,29 @@ def report_scheduler_runtime_failure(context: str, exc: Exception, *, startup: b
)
def report_scheduler_validation_failure(
context: str,
exc: Exception,
*,
startup: bool = False,
step_count: int = SCHEDULER_REFRESH_STEP_COUNT,
):
message = f"{context}: {exc}"
print(message)
scheduler_error(message)
scheduler_output(message)
publish_scheduler_progress(
False,
"validation_failed",
"Scheduler validation failed",
step_count,
step_count,
percent=100,
)
if startup:
return
def _integration_output_lines(output):
normalized = (output or "").replace("\r\n", "\n").strip()
if not normalized:
@@ -783,7 +804,7 @@ def load_network_json(path: str):
def topology_canonical_state_path() -> str:
return get_existing_state_path("topology", "topology_canonical_state.json")
return get_runtime_state_path("topology", "topology_canonical_state.json")
def load_topology_canonical_state(path: str):
@@ -988,7 +1009,7 @@ def importAndShapeFullReload():
phase_label="Scheduler waiting for topology runtime",
step_count=SCHEDULER_STARTUP_STEP_COUNT,
)
return
return False
publish_scheduler_progress(True, "initial_shaping_reload", "Refreshing shaper state", 5, SCHEDULER_STARTUP_STEP_COUNT)
if not enable_insight_topology():
refreshShapers()
@@ -997,6 +1018,8 @@ def importAndShapeFullReload():
shaping_runtime_hash = calculate_shaping_runtime_hash()
if shaping_runtime_hash != 0:
set_scheduler_status_bus_enabled(True)
return True
return False
def importAndShapePartialReload():
@@ -1027,6 +1050,9 @@ def importAndShapePartialReload():
publish_scheduler_progress(True, "partial_reload", "Applying incremental shaper refresh", 4, SCHEDULER_REFRESH_STEP_COUNT)
try:
refreshShapers()
except ValidationFailure as e:
report_scheduler_validation_failure("Scheduled shaping refresh blocked by validation", e)
return
except Exception as e:
report_scheduler_runtime_failure("Scheduled shaping refresh failed", e)
return
@@ -1037,7 +1063,7 @@ def importAndShapePartialReload():
def 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 _load_topology_runtime_status():
@@ -1180,6 +1206,9 @@ def topology_runtime_refresh_tick():
try:
refreshShapers()
except ValidationFailure as e:
report_scheduler_validation_failure("Topology runtime refresh blocked by validation", e)
return
except Exception as e:
report_scheduler_runtime_failure("Topology runtime refresh failed", e)
return
@@ -1204,8 +1233,16 @@ def run_scheduler_main():
scheduler_alive()
publish_scheduler_progress(True, "waiting_for_bus", "Waiting for lqosd bus", 1, SCHEDULER_STARTUP_STEP_COUNT)
try:
importAndShapeFullReload()
publish_scheduler_progress(False, "ready", "Scheduler ready", SCHEDULER_STARTUP_STEP_COUNT, SCHEDULER_STARTUP_STEP_COUNT, percent=100)
startup_ready = importAndShapeFullReload()
if startup_ready:
publish_scheduler_progress(False, "ready", "Scheduler ready", SCHEDULER_STARTUP_STEP_COUNT, SCHEDULER_STARTUP_STEP_COUNT, percent=100)
except ValidationFailure as e:
report_scheduler_validation_failure(
"Scheduler startup shaping refresh blocked by validation",
e,
startup=True,
step_count=SCHEDULER_STARTUP_STEP_COUNT,
)
except Exception as e:
report_scheduler_runtime_failure("Scheduler startup shaping refresh failed", e, startup=True)
import traceback
+9 -3
View File
@@ -124,7 +124,9 @@ class TestLibreQoSShapingInputs(unittest.TestCase):
shaping_inputs = os.path.join(temp_dir, "shaping_inputs.json")
shaped_devices = os.path.join(temp_dir, "ShapedDevices.csv")
network_json = os.path.join(temp_dir, "network.effective.json")
status_path = os.path.join(temp_dir, "topology_runtime_status.json")
topology_state = os.path.join(temp_dir, "state", "topology")
os.makedirs(topology_state, exist_ok=True)
status_path = os.path.join(topology_state, "topology_runtime_status.json")
for path in (shaping_inputs, shaped_devices, network_json):
with open(path, "w", encoding="utf-8") as handle:
@@ -158,7 +160,9 @@ class TestLibreQoSShapingInputs(unittest.TestCase):
shaping_inputs = os.path.join(temp_dir, "shaping_inputs.json")
shaped_devices = os.path.join(temp_dir, "ShapedDevices.csv")
network_json = os.path.join(temp_dir, "network.effective.json")
status_path = os.path.join(temp_dir, "topology_runtime_status.json")
topology_state = os.path.join(temp_dir, "state", "topology")
os.makedirs(topology_state, exist_ok=True)
status_path = os.path.join(topology_state, "topology_runtime_status.json")
for path in (shaping_inputs, shaped_devices, network_json):
with open(path, "w", encoding="utf-8") as handle:
@@ -192,7 +196,9 @@ class TestLibreQoSShapingInputs(unittest.TestCase):
shaping_inputs = os.path.join(temp_dir, "shaping_inputs.json")
shaped_devices = os.path.join(temp_dir, "ShapedDevices.csv")
network_json = os.path.join(temp_dir, "network.effective.json")
status_path = os.path.join(temp_dir, "topology_runtime_status.json")
topology_state = os.path.join(temp_dir, "state", "topology")
os.makedirs(topology_state, exist_ok=True)
status_path = os.path.join(topology_state, "topology_runtime_status.json")
for path in (shaping_inputs, shaped_devices, network_json):
with open(path, "w", encoding="utf-8") as handle:
+146 -12
View File
@@ -22,6 +22,9 @@ scheduler = None
def install_scheduler_stubs():
libre = types.ModuleType("LibreQoS")
class ValidationFailure(Exception):
pass
libre.ValidationFailure = ValidationFailure
libre.refreshShapers = Mock()
libre.refreshShapersUpdateOnly = Mock()
sys.modules["LibreQoS"] = libre
@@ -325,11 +328,10 @@ class TestSchedulerLogging(unittest.TestCase):
):
with patch.object(scheduler, "scheduler_error") as mock_scheduler_error:
with patch.object(scheduler, "publish_scheduler_progress") as mock_progress:
with patch.object(scheduler.atexit, "register"):
with patch.object(scheduler, "not_dead_yet"):
with patch("traceback.print_exc"):
with patch("builtins.print"):
scheduler.run_scheduler_main()
with patch.object(scheduler, "not_dead_yet"):
with patch("traceback.print_exc"):
with patch("builtins.print"):
scheduler.run_scheduler_main()
self.assertEqual(scheduler.shaping_runtime_hash, 0)
mock_scheduler_error.assert_called_once_with(
@@ -366,6 +368,45 @@ class TestSchedulerLogging(unittest.TestCase):
"Topology runtime refresh failed: bad runtime"
)
def test_topology_runtime_refresh_tick_reports_validation_failure(self):
validation_exc = scheduler.ValidationFailure("Validation failed. Will now exit.")
with patch.object(scheduler, "ensure_topology_runtime_process"):
with patch.object(
scheduler,
"topology_runtime_readiness_detail",
return_value=(True, "", "generation-1"),
):
with patch.object(scheduler, "calculate_shaping_runtime_hash", return_value=5):
with patch.object(
scheduler,
"refreshShapers",
side_effect=validation_exc,
):
with patch.object(scheduler, "scheduler_error") as mock_scheduler_error:
with patch.object(scheduler, "scheduler_output") as mock_scheduler_output:
with patch.object(scheduler, "publish_scheduler_progress") as mock_progress:
with patch("builtins.print"):
scheduler.shaping_runtime_hash = 1
scheduler.topology_runtime_refresh_tick()
mock_scheduler_error.assert_called_once_with(
"Topology runtime refresh blocked by validation: Validation failed. Will now exit."
)
mock_scheduler_output.assert_called_once_with(
"Topology runtime refresh blocked by validation: Validation failed. Will now exit."
)
self.assertTrue(
any(
call.args[:3] == (
False,
"validation_failed",
"Scheduler validation failed",
)
for call in mock_progress.call_args_list
)
)
def test_topology_runtime_refresh_tick_skips_until_initial_shaping_succeeds(self):
with patch.object(scheduler, "ensure_topology_runtime_process") as mock_ensure:
with patch.object(scheduler, "calculate_shaping_runtime_hash") as mock_hash:
@@ -388,6 +429,88 @@ class TestSchedulerLogging(unittest.TestCase):
self.assertTrue(scheduler.scheduler_status_bus_enabled)
def test_import_and_shape_full_reload_returns_false_when_topology_runtime_not_ready(self):
with patch.object(scheduler, "importFromCRM"):
with patch.object(scheduler, "ensure_topology_runtime_process", return_value=False):
with patch.object(scheduler, "report_topology_runtime_not_ready"):
with patch.object(scheduler, "publish_scheduler_progress"):
self.assertFalse(scheduler.importAndShapeFullReload())
def test_run_scheduler_main_reports_validation_failure_without_ready(self):
fake_ads = Mock()
validation_exc = scheduler.ValidationFailure("Validation failed. Because this is not the first run since boot (queues already set up) - will now exit.")
with patch.object(scheduler, "ads", fake_ads):
with patch.object(scheduler, "ensure_bus_ready"):
with patch.object(
scheduler,
"importAndShapeFullReload",
side_effect=validation_exc,
):
with patch.object(scheduler, "scheduler_error") as mock_scheduler_error:
with patch.object(scheduler, "scheduler_output") as mock_scheduler_output:
with patch.object(scheduler, "publish_scheduler_progress") as mock_progress:
with patch.object(scheduler, "not_dead_yet"):
with patch("builtins.print"):
scheduler.run_scheduler_main()
mock_scheduler_error.assert_called_once_with(
"Scheduler startup shaping refresh blocked by validation: Validation failed. Because this is not the first run since boot (queues already set up) - will now exit."
)
mock_scheduler_output.assert_called_once_with(
"Scheduler startup shaping refresh blocked by validation: Validation failed. Because this is not the first run since boot (queues already set up) - will now exit."
)
self.assertTrue(
any(
call.args[:3] == (
False,
"validation_failed",
"Scheduler validation failed",
)
for call in mock_progress.call_args_list
)
)
self.assertFalse(
any(
call.args[:3] == (
False,
"ready",
"Scheduler ready",
)
for call in mock_progress.call_args_list
)
)
def test_partial_reload_reports_validation_failure(self):
validation_exc = scheduler.ValidationFailure("Validation failed. Will now exit.")
with patch.object(scheduler, "importFromCRM"):
with patch.object(scheduler, "ensure_topology_runtime_process", return_value=True):
with patch.object(scheduler, "calculate_shaping_runtime_hash", side_effect=[2]):
with patch.object(scheduler, "shaping_runtime_hash", 1):
with patch.object(scheduler, "refreshShapers", side_effect=validation_exc):
with patch.object(scheduler, "scheduler_error") as mock_scheduler_error:
with patch.object(scheduler, "scheduler_output") as mock_scheduler_output:
with patch.object(scheduler, "publish_scheduler_progress") as mock_progress:
scheduler.importAndShapePartialReload()
mock_scheduler_error.assert_called_once_with(
"Scheduled shaping refresh blocked by validation: Validation failed. Will now exit."
)
mock_scheduler_output.assert_called_once_with(
"Scheduled shaping refresh blocked by validation: Validation failed. Will now exit."
)
self.assertTrue(
any(
call.args[:3] == (
False,
"validation_failed",
"Scheduler validation failed",
)
for call in mock_progress.call_args_list
)
)
class TestTopologyRuntimeReadiness(unittest.TestCase):
def test_missing_status_is_not_ready(self):
@@ -428,7 +551,9 @@ class TestTopologyRuntimeReadiness(unittest.TestCase):
def test_ready_false_status_blocks_current_generation(self):
with tempfile.TemporaryDirectory() as tempdir:
with open(os.path.join(tempdir, "topology_runtime_status.json"), "w", encoding="utf-8") as handle:
topology_state = os.path.join(tempdir, "state", "topology")
os.makedirs(topology_state, exist_ok=True)
with open(os.path.join(topology_state, "topology_runtime_status.json"), "w", encoding="utf-8") as handle:
json.dump(
{
"source_generation": "generation-1",
@@ -453,9 +578,11 @@ class TestTopologyRuntimeReadiness(unittest.TestCase):
def test_ready_true_matching_status_allows_current_generation(self):
with tempfile.TemporaryDirectory() as tempdir:
shaping_inputs = os.path.join(tempdir, "shaping_inputs.json")
topology_state = os.path.join(tempdir, "state", "topology")
os.makedirs(topology_state, exist_ok=True)
with open(shaping_inputs, "w", encoding="utf-8") as handle:
handle.write("{}\n")
with open(os.path.join(tempdir, "topology_runtime_status.json"), "w", encoding="utf-8") as handle:
with open(os.path.join(topology_state, "topology_runtime_status.json"), "w", encoding="utf-8") as handle:
json.dump(
{
"source_generation": "generation-1",
@@ -481,9 +608,11 @@ class TestTopologyRuntimeReadiness(unittest.TestCase):
def test_ready_true_without_shaping_generation_is_not_ready(self):
with tempfile.TemporaryDirectory() as tempdir:
shaping_inputs = os.path.join(tempdir, "shaping_inputs.json")
topology_state = os.path.join(tempdir, "state", "topology")
os.makedirs(topology_state, exist_ok=True)
with open(shaping_inputs, "w", encoding="utf-8") as handle:
handle.write("{}\n")
with open(os.path.join(tempdir, "topology_runtime_status.json"), "w", encoding="utf-8") as handle:
with open(os.path.join(topology_state, "topology_runtime_status.json"), "w", encoding="utf-8") as handle:
json.dump(
{
"source_generation": "generation-1",
@@ -507,7 +636,9 @@ class TestTopologyRuntimeReadiness(unittest.TestCase):
def test_ready_true_without_shaping_inputs_file_is_not_ready(self):
with tempfile.TemporaryDirectory() as tempdir:
missing_path = os.path.join(tempdir, "shaping_inputs.json")
with open(os.path.join(tempdir, "topology_runtime_status.json"), "w", encoding="utf-8") as handle:
topology_state = os.path.join(tempdir, "state", "topology")
os.makedirs(topology_state, exist_ok=True)
with open(os.path.join(topology_state, "topology_runtime_status.json"), "w", encoding="utf-8") as handle:
json.dump(
{
"source_generation": "generation-1",
@@ -762,12 +893,15 @@ class TestSchedulerOverrideMerge(unittest.TestCase):
self.assertEqual(rows[0][6], "uisp:site:site-93")
def test_topology_runtime_output_paths_include_shaping_inputs(self):
def test_runtime_state_path_includes_shaping_inputs(self):
# Test-only fake install root.
with patch.object(scheduler, "get_libreqos_directory", return_value="/tmp/libreqos"): # nosec B108
paths = scheduler.topology_runtime_output_paths()
shaping_inputs_path = scheduler.get_runtime_state_path("shaping", "shaping_inputs.json")
self.assertIn("/tmp/libreqos/shaping_inputs.json", paths) # nosec B108
self.assertEqual(
shaping_inputs_path,
"/tmp/libreqos/state/shaping/shaping_inputs.json",
) # nosec B108
def test_apply_network_adjustments_uses_materialized_adjustments(self):
network = {