diff --git a/docs/v2.0/components.md b/docs/v2.0/components.md index 28db7e9f..397b3a53 100644 --- a/docs/v2.0/components.md +++ b/docs/v2.0/components.md @@ -38,6 +38,7 @@ flowchart LR - Every X minutes: Update queues, pulling new configuration from CRM integration, if enabled. - The default minute interval is 30, so the refresh occurs every 30 minutes by default. - The minute interval is adjustable with the setting `queue_refresh_interval_mins` in `/etc/lqos.conf`. + - Current packages build `liblqos_python.so` with PyO3 `abi3` using a Python 3.10 floor, so the shipped extension is intended to remain import-compatible across newer CPython 3.x minor versions supported by PyO3 rather than matching only the build host's exact Python minor. ### lqos_topology runtime contract diff --git a/docs/v2.0/troubleshooting.md b/docs/v2.0/troubleshooting.md index 2f152584..650d6e43 100644 --- a/docs/v2.0/troubleshooting.md +++ b/docs/v2.0/troubleshooting.md @@ -134,6 +134,20 @@ This exports a log file to lqos_sched_log.txt. You can review this file to see w If the scheduler fails immediately after a restart with a message like `Socket (typically /run/lqos/bus) not found`, that indicates `lqosd` had not finished binding the local bus yet. Current builds wait briefly for bus readiness at scheduler startup instead of crashing immediately, so repeated startup panics after restart should no longer be expected. +If a host upgrades to a newer CPython 3.x minor release than the machine used to build LibreQoS, current packages should no longer require an exact Python minor match for `liblqos_python.so`. The extension is now built in PyO3 `abi3` mode with a Python 3.10 floor. If you still see interpreter crashes or import failures after such an upgrade, treat that as a bug and capture: + +```bash +python3 --version +python3 - <<'PY' +import sysconfig +print(sysconfig.get_config_var("SOABI")) +PY +file /opt/libreqos/src/liblqos_python.so +ldd /opt/libreqos/src/liblqos_python.so +``` + +Routine package upgrades now invoke `lqos_setup --skip-if-ready` during `postinst`. If `/etc/lqos.conf` loads, shaping interfaces are already selected and present, bandwidth is non-zero, and `network.json` plus `ShapedDevices.csv` already exist, the package skips the interactive Cursive setup screen instead of trying to launch a TUI during the upgrade. + If startup shaping fails because `shaping_inputs.json` is missing or stale, current builds leave the scheduler running in a degraded state and wait for the next scheduled full refresh to recover. The high-frequency topology refresh tick stays disabled until one shaping pass completes successfully, so repeated 3-second refresh attempts should not continue hammering a fresh install that has not produced runtime topology inputs yet. Current builds no longer gate startup shaping on file mtimes. Instead, `lqos_topology` publishes `/opt/libreqos/src/topology_runtime_status.json` with a `source_generation` derived from the current shaping inputs, and `lqos_scheduler` waits for `ready: true` for that exact generation before calling `refreshShapers()`. diff --git a/src/build_dpkg.sh b/src/build_dpkg.sh index df633249..00a1bd23 100755 --- a/src/build_dpkg.sh +++ b/src/build_dpkg.sh @@ -1,11 +1,12 @@ #!/bin/bash +set -euo pipefail #################################################### # Copyright (c) 2022, Herbert Wolverson and LibreQoE # This is all GPL2. BUILD_DATE=$(date +%Y%m%d%H%M) -[ "$1" = "--nostamp" ] && BUILD_DATE="" +[ "${1:-}" = "--nostamp" ] && BUILD_DATE="" PACKAGE=libreqos VERSION=$(cat ./VERSION_STRING).$BUILD_DATE @@ -149,7 +150,7 @@ fi sudo chown -R root:root /opt/libreqos # - Run lqsetup -/opt/libreqos/src/bin/lqos_setup +/opt/libreqos/src/bin/lqos_setup --skip-if-ready # - Setup the services install -m 0644 /opt/libreqos/src/bin/lqosd.service.example /etc/systemd/system/lqosd.service install -m 0644 /opt/libreqos/src/bin/lqos_scheduler.service.example /etc/systemd/system/lqos_scheduler.service @@ -176,11 +177,11 @@ popd > /dev/null || exit # Copy files into the LibreQoS directory for file in "${LQOS_FILES[@]}"; do - cp "$file" "$LQOS_DIR" || echo "Error copying $file" + cp "$file" "$LQOS_DIR" done if [ -f deb-requirements-constraints.txt ]; then - cp deb-requirements-constraints.txt "$LQOS_DIR" || echo "Error copying deb-requirements-constraints.txt" + cp deb-requirements-constraints.txt "$LQOS_DIR" fi # Ensure helper scripts are executable in the package @@ -190,7 +191,7 @@ fi # Copy files into the LibreQoS/bin directory for file in "${LQOS_BIN_FILES[@]}"; do - cp "bin/$file" "$LQOS_DIR/bin" || echo "Error copying $file" + cp "bin/$file" "$LQOS_DIR/bin" done # Copy the remove pinned maps @@ -208,7 +209,7 @@ popd || exit cp rust/target/release/liblqos_python.so "$LQOS_DIR" # - The main executables for prog in "${RUSTPROGS[@]}"; do - cp rust/target/release/"$prog" "$LQOS_DIR"/bin || echo "Error copying $prog" + cp rust/target/release/"$prog" "$LQOS_DIR"/bin done cp -r bin/static2/* "$LQOS_DIR"/bin/static2 diff --git a/src/rust/lqos_python/Cargo.toml b/src/rust/lqos_python/Cargo.toml index aab57e9d..7ffde011 100644 --- a/src/rust/lqos_python/Cargo.toml +++ b/src/rust/lqos_python/Cargo.toml @@ -9,7 +9,7 @@ name = "lqos_python" crate-type = ["cdylib"] [dependencies] -pyo3 = { workspace = true, features = ["extension-module"] } +pyo3 = { workspace = true, features = ["extension-module", "abi3", "abi3-py310"] } lqos_bakery = { path = "../lqos_bakery" } lqos_bus = { path = "../lqos_bus" } lqos_sys = { path = "../lqos_sys" } diff --git a/src/rust/lqos_setup/src/interfaces.rs b/src/rust/lqos_setup/src/interfaces.rs index 9b5f91d7..14ef6ead 100644 --- a/src/rust/lqos_setup/src/interfaces.rs +++ b/src/rust/lqos_setup/src/interfaces.rs @@ -10,13 +10,13 @@ use crate::config_builder::{BridgeMode, CURRENT_CONFIG}; pub fn get_interfaces() -> anyhow::Result> { let interfaces = nix::ifaddrs::getifaddrs()? - .filter(|iface| check_queues(&iface.interface_name).is_ok()) + .filter(|iface| interface_supports_lqos(&iface.interface_name).is_ok()) .map(|iface| iface.interface_name) .collect::>(); Ok(interfaces) } -fn check_queues(interface: &str) -> anyhow::Result<()> { +pub(crate) fn interface_supports_lqos(interface: &str) -> anyhow::Result<()> { let path = format!("/sys/class/net/{interface}/queues/"); let sys_path = Path::new(&path); if !sys_path.exists() { diff --git a/src/rust/lqos_setup/src/main.rs b/src/rust/lqos_setup/src/main.rs index 54d2889b..f739371d 100644 --- a/src/rust/lqos_setup/src/main.rs +++ b/src/rust/lqos_setup/src/main.rs @@ -7,6 +7,7 @@ mod preflight; mod webusers; use std::path::Path; +use std::{env, fmt::Write as _}; use bandwidth::bandwidth_view; use config_builder::CURRENT_CONFIG; @@ -19,6 +20,7 @@ use cursive::{ const VERSION: &str = include_str!("../../../VERSION_STRING"); const DEFAULT_NETWORK_JSON: &str = include_str!("../../../network.example.json"); const DEFAULT_SHAPED_DEVICES: &str = include_str!("../../../ShapedDevices.example.csv"); +const SKIP_IF_READY_FLAG: &str = "--skip-if-ready"; fn config_exists() -> bool { let config_path = Path::new("/etc/lqos.conf"); @@ -41,7 +43,111 @@ fn shaped_devices_exists() -> bool { 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 should_skip_interactive_setup() -> bool { + env::args().skip(1).any(|arg| arg == SKIP_IF_READY_FLAG) +} + fn main() { + if should_skip_interactive_setup() { + let readiness = upgrade_readiness(); + println!("{}", readiness.summary()); + if readiness.all_ready() { + println!("Existing LibreQoS install looks configured; skipping interactive setup."); + return; + } + println!("Interactive setup still required."); + } + preflight::preflight(); let mut ui = cursive::default(); ui.add_global_callback('q', |s| s.quit()); @@ -147,6 +253,82 @@ fn main() { ui.run(); } +#[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::default(); + config.lqos_directory = lqos_directory; + config.bridge = Some(lqos_config::BridgeConfig { + use_xdp_bridge: true, + to_internet: "wan0".to_string(), + to_network: "lan0".to_string(), + }); + config.single_interface = None; + 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); + } +} + fn finalize(ui: &mut cursive::Cursive) { // If we cannot load the config but a file exists, warn the user and // take a backup before proceeding to create a new config. diff --git a/src/rust/lqosd/src/node_manager/local_api/topology_manager.rs b/src/rust/lqosd/src/node_manager/local_api/topology_manager.rs index 510c99a5..f2b06103 100644 --- a/src/rust/lqosd/src/node_manager/local_api/topology_manager.rs +++ b/src/rust/lqosd/src/node_manager/local_api/topology_manager.rs @@ -2,7 +2,7 @@ use crate::node_manager::auth::LoginResult; use axum::http::StatusCode; use lqos_config::{ Config, TopologyAllowedParent, TopologyAttachmentHealthStateFile, TopologyCanonicalStateFile, - TopologyEditorStateFile, load_config, + TopologyEditorStateFile, compute_topology_source_generation, load_config, }; use lqos_overrides::{ManualAttachment, TopologyAttachmentMode, TopologyOverridesFile}; use lqos_topology::{ @@ -206,7 +206,10 @@ fn publish_candidate_overrides( .save() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if publish_effective_topology_artifacts(config, &artifacts).is_err() { + let source_generation = + compute_topology_source_generation(config).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if publish_effective_topology_artifacts(config, &artifacts, &source_generation).is_err() { let _ = previous_overrides.save(); return Err(StatusCode::INTERNAL_SERVER_ERROR); }