mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-24 22:07:40 -05:00
changes
This commit is contained in:
1 parent
2b3c581a7f
commit
0bb9eda01b
45 files changed
+1748
-1337
No files matched your search
+66
-54
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user