mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 06:17:39 -05:00
Merge pull request #977 from LibreQoE/fix_454
Fix adaptive SQM override rate regression and require Ubuntu 24.04 networkd hotfix on affected hosts
This commit is contained in:
23 files changed
+1253
-260
No files matched your search
@@ -39,6 +39,7 @@ LQOS_FILES=(
|
||||
scheduler.py
|
||||
ShapedDevices.example.csv
|
||||
shaping_skip_report.py
|
||||
systemd_hotfix.sh
|
||||
virtual_tree_nodes.py
|
||||
mikrotikDHCPRouterList.template.csv
|
||||
integrationUISPbandwidths.template.csv
|
||||
@@ -115,6 +116,18 @@ popd > /dev/null || exit
|
||||
pushd "$DEBIAN_DIR" > /dev/null || exit
|
||||
cat <<EOF > postinst
|
||||
#!/bin/bash
|
||||
if /opt/libreqos/src/systemd_hotfix.sh should-offer >/dev/null 2>&1; then
|
||||
cat >&2 <<'HOTFIX'
|
||||
LibreQoS requires the Ubuntu 24.04 systemd-networkd hotfix on this host before installation can continue.
|
||||
|
||||
Run:
|
||||
HOTFIX_BASE_URL=https://downloads.libreqos.com sudo /opt/libreqos/src/systemd_hotfix.sh install
|
||||
|
||||
Then re-run the LibreQoS package installation.
|
||||
HOTFIX
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install Python Dependencies
|
||||
pushd /opt/libreqos
|
||||
# - Setup Python dependencies as a post-install task
|
||||
|
||||
@@ -135,6 +135,8 @@ fi
|
||||
echo "-----------------------------------------------------------------"
|
||||
echo "Don't forget to setup /etc/lqos.conf!"
|
||||
echo "Template .service files can be found in bin/"
|
||||
echo "Use ./systemd_hotfix.sh to evaluate or install the Ubuntu 24.04 networkd hotfix bundle."
|
||||
echo "LibreQoS package installs on affected Ubuntu 24.04 hosts now require the hotfix first."
|
||||
echo ""
|
||||
echo "Run sudo rust/remove_pinned_maps.sh before you restart lqosd"
|
||||
echo "This ensures that any data-format changes will apply correctly."
|
||||
+44
-33
@@ -41,9 +41,23 @@ def build_online_ip_maps(customersOnline):
|
||||
temp = ipv6sForService.get(service_id, [])
|
||||
if ipv6 not in temp:
|
||||
temp.append(ipv6)
|
||||
ipv6sForService[service_id] = temp
|
||||
ipv6sForService[service_id] = temp
|
||||
return ipv4sForService, ipv6sForService
|
||||
|
||||
|
||||
def stable_splynx_device_id(service_id, equipment_id=None):
|
||||
"""
|
||||
Build a deterministic device ID for Splynx-generated shaped devices.
|
||||
|
||||
For the current importer model, each service maps to a single device row, so the
|
||||
service ID is the stable identity. The optional equipment_id parameter is reserved
|
||||
for a future equipment-aware importer mode.
|
||||
"""
|
||||
base_id = f"splynx_service_{service_id}"
|
||||
if equipment_id is None:
|
||||
return base_id
|
||||
return f"{base_id}_equipment_{equipment_id}"
|
||||
|
||||
def supplement_existing_devices_with_online_ips(net, allServices, service_ids_handled, customersOnline, cust_id_to_name, allocated_ipv4s, allocated_ipv6s, device_by_service_id=None):
|
||||
"""
|
||||
For services already handled (devices created from static IPs), supplement missing
|
||||
@@ -91,7 +105,7 @@ def supplement_existing_devices_with_online_ips(net, allServices, service_ids_ha
|
||||
matched_via_supplementation += 1
|
||||
return matched_via_supplementation
|
||||
|
||||
def create_devices_from_online_for_unhandled_services(net, allServices, service_ids_handled, customersOnline, cust_id_to_name, downloadForTariffID, uploadForTariffID, device_counter, allocated_ipv4s, allocated_ipv6s, parent_selector=None, device_by_service_id=None):
|
||||
def create_devices_from_online_for_unhandled_services(net, allServices, service_ids_handled, customersOnline, cust_id_to_name, downloadForTariffID, uploadForTariffID, allocated_ipv4s, allocated_ipv6s, parent_selector=None, device_by_service_id=None):
|
||||
"""
|
||||
For services that didn't produce devices via static IPs, create devices for those
|
||||
with online IPs. Optionally select a parent node via parent_selector(serviceItem).
|
||||
@@ -119,33 +133,32 @@ def create_devices_from_online_for_unhandled_services(net, allServices, service_
|
||||
except Exception:
|
||||
parent_id = None
|
||||
# Create customer circuit node
|
||||
customer = NetworkNode(
|
||||
type=NodeType.client,
|
||||
id=circuit_id,
|
||||
parentId=parent_id,
|
||||
displayName=customer_name,
|
||||
customer = NetworkNode(
|
||||
type=NodeType.client,
|
||||
id=circuit_id,
|
||||
parentId=parent_id,
|
||||
displayName=customer_name,
|
||||
address=customer_name,
|
||||
customerName=customer_name,
|
||||
download=service_download,
|
||||
upload=service_upload
|
||||
)
|
||||
net.addRawNode(customer)
|
||||
# Create device node under the client circuit
|
||||
device = NetworkNode(
|
||||
id=device_counter[0],
|
||||
displayName=customer_name,
|
||||
type=NodeType.device,
|
||||
parentId=circuit_id,
|
||||
mac=service.get('mac', ''),
|
||||
ipv4=ipv4,
|
||||
ipv6=ipv6
|
||||
)
|
||||
net.addRawNode(device)
|
||||
if device_by_service_id is not None:
|
||||
device_by_service_id[service['id']] = device
|
||||
device_counter[0] += 1
|
||||
service_ids_handled.append(service['id'])
|
||||
matched_via_alternate_method += 1
|
||||
download=service_download,
|
||||
upload=service_upload
|
||||
)
|
||||
net.addRawNode(customer)
|
||||
# Create device node under the client circuit
|
||||
device = NetworkNode(
|
||||
id=stable_splynx_device_id(service['id']),
|
||||
displayName=customer_name,
|
||||
type=NodeType.device,
|
||||
parentId=circuit_id,
|
||||
mac=service.get('mac', ''),
|
||||
ipv4=ipv4,
|
||||
ipv6=ipv6
|
||||
)
|
||||
net.addRawNode(device)
|
||||
if device_by_service_id is not None:
|
||||
device_by_service_id[service['id']] = device
|
||||
service_ids_handled.append(service['id'])
|
||||
matched_via_alternate_method += 1
|
||||
return matched_via_alternate_method
|
||||
|
||||
def run_splynx_pipeline(strategy_name: str):
|
||||
@@ -339,7 +352,6 @@ def run_splynx_pipeline(strategy_name: str):
|
||||
build_infrastructure()
|
||||
allocated_ipv4s = {}
|
||||
allocated_ipv6s = {}
|
||||
device_counter = [200000]
|
||||
service_ids_handled = []
|
||||
device_by_service_id = {}
|
||||
static_created = 0
|
||||
@@ -349,7 +361,7 @@ def run_splynx_pipeline(strategy_name: str):
|
||||
if ipv4_list or ipv6_list:
|
||||
parent_node_id = select_parent(serviceItem)
|
||||
circuit_id = createClientAndDevice(
|
||||
net, serviceItem, cust_id_to_name, downloadForTariffID, uploadForTariffID, device_counter, parent_node_id, ipv4_list, ipv6_list
|
||||
net, serviceItem, cust_id_to_name, downloadForTariffID, uploadForTariffID, parent_node_id, ipv4_list, ipv6_list
|
||||
)
|
||||
service_ids_handled.append(serviceItem['id'])
|
||||
# Last added node is the device
|
||||
@@ -363,7 +375,7 @@ def run_splynx_pipeline(strategy_name: str):
|
||||
)
|
||||
matched_via_alternate_method = create_devices_from_online_for_unhandled_services(
|
||||
net, allServices, service_ids_handled, customersOnline, cust_id_to_name,
|
||||
downloadForTariffID, uploadForTariffID, device_counter, allocated_ipv4s, allocated_ipv6s,
|
||||
downloadForTariffID, uploadForTariffID, allocated_ipv4s, allocated_ipv6s,
|
||||
parent_selector=select_parent, device_by_service_id=device_by_service_id
|
||||
)
|
||||
|
||||
@@ -592,7 +604,7 @@ def extractServiceIPs(serviceItem, cust_id_to_name, allocated_ipv4s, allocated_i
|
||||
|
||||
return ipv4_list, ipv6_list
|
||||
|
||||
def createClientAndDevice(net, serviceItem, cust_id_to_name, downloadForTariffID, uploadForTariffID, device_counter, parent_node_id, ipv4_list, ipv6_list):
|
||||
def createClientAndDevice(net, serviceItem, cust_id_to_name, downloadForTariffID, uploadForTariffID, parent_node_id, ipv4_list, ipv6_list):
|
||||
"""
|
||||
Create client and device nodes for a service.
|
||||
"""
|
||||
@@ -621,7 +633,7 @@ def createClientAndDevice(net, serviceItem, cust_id_to_name, downloadForTariffID
|
||||
|
||||
# Always create a device for each service
|
||||
device = NetworkNode(
|
||||
id=device_counter[0],
|
||||
id=stable_splynx_device_id(serviceItem['id']),
|
||||
displayName=customer_name,
|
||||
type=NodeType.device,
|
||||
parentId=circuit_id,
|
||||
@@ -630,7 +642,6 @@ def createClientAndDevice(net, serviceItem, cust_id_to_name, downloadForTariffID
|
||||
ipv6=ipv6_list
|
||||
)
|
||||
net.addRawNode(device)
|
||||
device_counter[0] += 1
|
||||
|
||||
return circuit_id
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ use tracing::{debug, error, info, warn};
|
||||
use super::BUS_SOCKET_DIRECTORY;
|
||||
use super::protocol::{decode_session_cbor, encode_reply_cbor, read_frame, write_frame};
|
||||
|
||||
fn dropped_reply_response_count(reply: &BusReply) -> usize {
|
||||
reply.responses.len()
|
||||
}
|
||||
|
||||
/// Implements a Tokio-friendly server using Unix Sockets and the bus protocol.
|
||||
/// Requests are handled and then forwarded to the handler.
|
||||
pub struct UnixSocketServer {}
|
||||
@@ -111,8 +115,11 @@ impl UnixSocketServer {
|
||||
if let Some((reply_channel, msg)) = ret {
|
||||
let mut response = BusReply { responses: Vec::with_capacity(8) };
|
||||
handle_bus_requests(&[msg], &mut response.responses);
|
||||
if let Err(e) = reply_channel.send(response) {
|
||||
warn!("Unable to send response back to client: {:?}", e);
|
||||
if let Err(reply) = reply_channel.send(response) {
|
||||
warn!(
|
||||
dropped_response_count = dropped_reply_response_count(&reply),
|
||||
"Unable to send response back to client; receiver dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -226,3 +233,18 @@ pub enum UnixSocketServerError {
|
||||
#[error("Unable to write to socket")]
|
||||
WriteFail,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::dropped_reply_response_count;
|
||||
use crate::{BusReply, BusResponse};
|
||||
|
||||
#[test]
|
||||
fn dropped_reply_summary_only_counts_responses() {
|
||||
let reply = BusReply {
|
||||
responses: vec![BusResponse::Ack, BusResponse::Ack, BusResponse::Ack],
|
||||
};
|
||||
|
||||
assert_eq!(dropped_reply_response_count(&reply), 3);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,13 @@ pub enum CircuitAdjustment {
|
||||
/// Replacement maximum upload bandwidth in Mbps.
|
||||
max_upload_bandwidth: Option<f32>,
|
||||
},
|
||||
/// Replaces the SQM override token for a specific device without changing any other fields.
|
||||
DeviceAdjustSqm {
|
||||
/// Device identifier to update.
|
||||
device_id: String,
|
||||
/// Replacement SQM override token. `None` or empty removes the override.
|
||||
sqm_override: Option<String>,
|
||||
},
|
||||
/// Removes a circuit from generated output by circuit ID.
|
||||
RemoveCircuit {
|
||||
/// Circuit identifier to remove.
|
||||
@@ -198,10 +205,16 @@ fn merge_persistent_devices_by_id(
|
||||
for dev in operator_devices {
|
||||
by_id.insert(dev.device_id.as_str(), dev.clone());
|
||||
}
|
||||
for dev in stormguard_devices {
|
||||
for dev in stormguard_devices
|
||||
.iter()
|
||||
.filter(|dev| !persistent_device_carries_sqm(dev))
|
||||
{
|
||||
by_id.insert(dev.device_id.as_str(), dev.clone());
|
||||
}
|
||||
for dev in treeguard_devices {
|
||||
for dev in treeguard_devices
|
||||
.iter()
|
||||
.filter(|dev| !persistent_device_carries_sqm(dev))
|
||||
{
|
||||
by_id.insert(dev.device_id.as_str(), dev.clone());
|
||||
}
|
||||
|
||||
@@ -219,7 +232,10 @@ fn merge_persistent_devices_by_id(
|
||||
}
|
||||
}
|
||||
|
||||
for dev in stormguard_devices {
|
||||
for dev in stormguard_devices
|
||||
.iter()
|
||||
.filter(|dev| !persistent_device_carries_sqm(dev))
|
||||
{
|
||||
let did = dev.device_id.as_str();
|
||||
if seen.contains(did) {
|
||||
continue;
|
||||
@@ -230,7 +246,10 @@ fn merge_persistent_devices_by_id(
|
||||
}
|
||||
}
|
||||
|
||||
for dev in treeguard_devices {
|
||||
for dev in treeguard_devices
|
||||
.iter()
|
||||
.filter(|dev| !persistent_device_carries_sqm(dev))
|
||||
{
|
||||
let did = dev.device_id.as_str();
|
||||
if seen.contains(did) {
|
||||
continue;
|
||||
@@ -244,6 +263,108 @@ fn merge_persistent_devices_by_id(
|
||||
out
|
||||
}
|
||||
|
||||
fn persistent_device_carries_sqm(device: &ShapedDevice) -> bool {
|
||||
device
|
||||
.sqm_override
|
||||
.as_deref()
|
||||
.is_some_and(|sqm| !sqm.trim().is_empty())
|
||||
}
|
||||
|
||||
fn legacy_sqm_adjustments_from_devices(devices: &[ShapedDevice]) -> Vec<CircuitAdjustment> {
|
||||
devices
|
||||
.iter()
|
||||
.filter_map(|device| {
|
||||
let sqm_override = device.sqm_override.as_deref()?.trim();
|
||||
if sqm_override.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id: device.device_id.clone(),
|
||||
sqm_override: Some(sqm_override.to_string()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn circuit_adjustment_merge_key(adj: &CircuitAdjustment) -> (&'static str, &str) {
|
||||
match adj {
|
||||
CircuitAdjustment::CircuitAdjustSpeed { circuit_id, .. } => ("circuit_speed", circuit_id),
|
||||
CircuitAdjustment::DeviceAdjustSpeed { device_id, .. } => ("device_speed", device_id),
|
||||
CircuitAdjustment::DeviceAdjustSqm { device_id, .. } => ("device_sqm", device_id),
|
||||
CircuitAdjustment::RemoveCircuit { circuit_id } => ("remove_circuit", circuit_id),
|
||||
CircuitAdjustment::RemoveDevice { device_id } => ("remove_device", device_id),
|
||||
CircuitAdjustment::ReparentCircuit { circuit_id, .. } => ("reparent_circuit", circuit_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_circuit_adjustments_owned(
|
||||
operator_adjustments: &[CircuitAdjustment],
|
||||
stormguard_adjustments: &[CircuitAdjustment],
|
||||
treeguard_adjustments: &[CircuitAdjustment],
|
||||
stormguard_devices: &[ShapedDevice],
|
||||
treeguard_devices: &[ShapedDevice],
|
||||
) -> Vec<CircuitAdjustment> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
let mut by_key: HashMap<(&'static str, &str), CircuitAdjustment> = HashMap::new();
|
||||
let stormguard_combined: Vec<CircuitAdjustment> = stormguard_adjustments
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(legacy_sqm_adjustments_from_devices(stormguard_devices))
|
||||
.collect();
|
||||
let treeguard_combined: Vec<CircuitAdjustment> = treeguard_adjustments
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(legacy_sqm_adjustments_from_devices(treeguard_devices))
|
||||
.collect();
|
||||
|
||||
for adj in operator_adjustments {
|
||||
by_key.insert(circuit_adjustment_merge_key(adj), adj.clone());
|
||||
}
|
||||
for adj in &stormguard_combined {
|
||||
by_key.insert(circuit_adjustment_merge_key(adj), adj.clone());
|
||||
}
|
||||
for adj in &treeguard_combined {
|
||||
by_key.insert(circuit_adjustment_merge_key(adj), adj.clone());
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut seen: HashSet<(&'static str, &str)> = HashSet::new();
|
||||
|
||||
for adj in operator_adjustments {
|
||||
let key = circuit_adjustment_merge_key(adj);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(merged) = by_key.get(&key) {
|
||||
out.push(merged.clone());
|
||||
seen.insert(key);
|
||||
}
|
||||
}
|
||||
for adj in &stormguard_combined {
|
||||
let key = circuit_adjustment_merge_key(adj);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(merged) = by_key.get(&key) {
|
||||
out.push(merged.clone());
|
||||
seen.insert(key);
|
||||
}
|
||||
}
|
||||
for adj in &treeguard_combined {
|
||||
let key = circuit_adjustment_merge_key(adj);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(merged) = by_key.get(&key) {
|
||||
out.push(merged.clone());
|
||||
seen.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn merge_network_adjustments_owned(
|
||||
operator_adjustments: &[NetworkAdjustment],
|
||||
stormguard_adjustments: &[NetworkAdjustment],
|
||||
@@ -376,12 +497,20 @@ fn merge_owned_sections(
|
||||
stormguard: OverrideFile,
|
||||
treeguard: OverrideFile,
|
||||
) -> OverrideFile {
|
||||
let operator_circuit = std::mem::take(&mut operator.circuit_adjustments);
|
||||
let operator_devices = std::mem::take(&mut operator.persistent_devices);
|
||||
operator.persistent_devices = merge_persistent_devices_by_id(
|
||||
&operator_devices,
|
||||
&stormguard.persistent_devices,
|
||||
&treeguard.persistent_devices,
|
||||
);
|
||||
operator.circuit_adjustments = merge_circuit_adjustments_owned(
|
||||
&operator_circuit,
|
||||
&stormguard.circuit_adjustments,
|
||||
&treeguard.circuit_adjustments,
|
||||
&stormguard.persistent_devices,
|
||||
&treeguard.persistent_devices,
|
||||
);
|
||||
|
||||
let operator_network = std::mem::take(&mut operator.network_adjustments);
|
||||
operator.network_adjustments = merge_network_adjustments_owned(
|
||||
@@ -515,6 +644,62 @@ impl OverrideFile {
|
||||
self.circuit_adjustments.push(adj);
|
||||
}
|
||||
|
||||
/// Add or replace an SQM override token for `device_id`. Returns true if changed.
|
||||
pub fn set_device_sqm_override_return_changed(
|
||||
&mut self,
|
||||
device_id: String,
|
||||
sqm_override: Option<String>,
|
||||
) -> bool {
|
||||
let normalized = sqm_override.and_then(|sqm| {
|
||||
let trimmed = sqm.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
});
|
||||
|
||||
if self.circuit_adjustments.iter().any(|adj| {
|
||||
matches!(
|
||||
adj,
|
||||
CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id: current,
|
||||
sqm_override: existing_sqm,
|
||||
} if current == &device_id && existing_sqm == &normalized
|
||||
)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.circuit_adjustments.retain(|adj| {
|
||||
!matches!(
|
||||
adj,
|
||||
CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id: current, ..
|
||||
} if current == &device_id
|
||||
)
|
||||
});
|
||||
self.circuit_adjustments.push(CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id,
|
||||
sqm_override: normalized,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove any SQM override adjustments for `device_id`. Returns number removed.
|
||||
pub fn remove_device_sqm_override_by_device_count(&mut self, device_id: &str) -> usize {
|
||||
let before = self.circuit_adjustments.len();
|
||||
self.circuit_adjustments.retain(|adj| {
|
||||
!matches!(
|
||||
adj,
|
||||
CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id: current, ..
|
||||
} if current == device_id
|
||||
)
|
||||
});
|
||||
before.saturating_sub(self.circuit_adjustments.len())
|
||||
}
|
||||
|
||||
/// Remove a circuit adjustment by index. Returns true if removed.
|
||||
pub fn remove_circuit_adjustment_by_index(&mut self, index: usize) -> bool {
|
||||
if index < self.circuit_adjustments.len() {
|
||||
@@ -725,43 +910,111 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn shaped_device_with_comment(device_id: &str, comment: &str) -> ShapedDevice {
|
||||
ShapedDevice {
|
||||
device_id: device_id.to_string(),
|
||||
comment: comment.to_string(),
|
||||
..ShapedDevice::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_merge_treeguard_wins_for_persistent_devices() {
|
||||
let mut operator = OverrideFile::default();
|
||||
operator
|
||||
.add_persistent_shaped_device_return_changed(shaped_device_with_sqm("dev1", "cake"));
|
||||
operator
|
||||
.add_persistent_shaped_device_return_changed(shaped_device_with_sqm("dev2", "cake"));
|
||||
operator.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev1", "operator",
|
||||
));
|
||||
operator.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev2", "operator",
|
||||
));
|
||||
|
||||
let mut stormguard = OverrideFile::default();
|
||||
stormguard.add_persistent_shaped_device_return_changed(shaped_device_with_sqm(
|
||||
"dev1", "fq_codel",
|
||||
stormguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev1", "stormguard",
|
||||
));
|
||||
stormguard.add_persistent_shaped_device_return_changed(shaped_device_with_sqm(
|
||||
"dev3", "fq_codel",
|
||||
stormguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev3", "stormguard",
|
||||
));
|
||||
|
||||
let mut treeguard = OverrideFile::default();
|
||||
treeguard
|
||||
.add_persistent_shaped_device_return_changed(shaped_device_with_sqm("dev1", "cake"));
|
||||
treeguard
|
||||
.add_persistent_shaped_device_return_changed(shaped_device_with_sqm("dev4", "cake"));
|
||||
treeguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev1", "treeguard",
|
||||
));
|
||||
treeguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev4", "treeguard",
|
||||
));
|
||||
|
||||
let merged = merge_owned_sections(operator, stormguard, treeguard);
|
||||
let sqm_by_id: std::collections::HashMap<&str, &str> = merged
|
||||
let comments_by_id: std::collections::HashMap<&str, &str> = merged
|
||||
.persistent_devices
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
d.sqm_override
|
||||
.as_deref()
|
||||
.map(|sqm| (d.device_id.as_str(), sqm))
|
||||
})
|
||||
.map(|d| (d.device_id.as_str(), d.comment.as_str()))
|
||||
.collect();
|
||||
|
||||
assert_eq!(sqm_by_id.get("dev1"), Some(&"cake"));
|
||||
assert_eq!(comments_by_id.get("dev1"), Some(&"treeguard"));
|
||||
assert_eq!(comments_by_id.get("dev2"), Some(&"operator"));
|
||||
assert_eq!(comments_by_id.get("dev3"), Some(&"stormguard"));
|
||||
assert_eq!(comments_by_id.get("dev4"), Some(&"treeguard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_merge_moves_adaptive_legacy_sqm_into_circuit_adjustments() {
|
||||
let mut operator = OverrideFile::default();
|
||||
let mut operator_device = shaped_device_with_sqm("operator_dev", "cake");
|
||||
operator_device.download_max_mbps = 100.0;
|
||||
operator.add_persistent_shaped_device_return_changed(operator_device);
|
||||
|
||||
let mut stormguard = OverrideFile::default();
|
||||
let mut sg_device = shaped_device_with_sqm("dev1", "fq_codel");
|
||||
sg_device.download_max_mbps = 10.0;
|
||||
stormguard.add_persistent_shaped_device_return_changed(sg_device);
|
||||
|
||||
let mut treeguard = OverrideFile::default();
|
||||
let mut tg_device = shaped_device_with_sqm("dev2", "cake");
|
||||
tg_device.download_max_mbps = 20.0;
|
||||
treeguard.add_persistent_shaped_device_return_changed(tg_device);
|
||||
|
||||
let merged = merge_owned_sections(operator, stormguard, treeguard);
|
||||
|
||||
let merged_ids: Vec<&str> = merged
|
||||
.persistent_devices()
|
||||
.iter()
|
||||
.map(|d| d.device_id.as_str())
|
||||
.collect();
|
||||
assert_eq!(merged_ids, vec!["operator_dev"]);
|
||||
|
||||
let sqm_by_id: std::collections::HashMap<&str, &str> = merged
|
||||
.circuit_adjustments()
|
||||
.iter()
|
||||
.filter_map(|adj| match adj {
|
||||
CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id,
|
||||
sqm_override,
|
||||
} => sqm_override.as_deref().map(|sqm| (device_id.as_str(), sqm)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sqm_by_id.get("dev1"), Some(&"fq_codel"));
|
||||
assert_eq!(sqm_by_id.get("dev2"), Some(&"cake"));
|
||||
assert_eq!(sqm_by_id.get("dev3"), Some(&"fq_codel"));
|
||||
assert_eq!(sqm_by_id.get("dev4"), Some(&"cake"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_device_sqm_override_is_idempotent() {
|
||||
let mut of = OverrideFile::default();
|
||||
assert!(of.set_device_sqm_override_return_changed(
|
||||
"dev1".to_string(),
|
||||
Some("fq_codel".to_string())
|
||||
));
|
||||
assert!(!of.set_device_sqm_override_return_changed(
|
||||
"dev1".to_string(),
|
||||
Some("fq_codel".to_string())
|
||||
));
|
||||
assert!(of.set_device_sqm_override_return_changed(
|
||||
"dev1".to_string(),
|
||||
Some("cake".to_string())
|
||||
));
|
||||
assert_eq!(of.remove_device_sqm_override_by_device_count("dev1"), 1);
|
||||
assert_eq!(of.remove_device_sqm_override_by_device_count("dev1"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -668,6 +668,10 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(overrides_persistent_devices, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(overrides_persistent_devices_effective, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(overrides_circuit_adjustments, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(
|
||||
overrides_circuit_adjustments_effective,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(wrap_pyfunction!(overrides_network_adjustments, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(
|
||||
overrides_network_adjustments_effective,
|
||||
@@ -1084,6 +1088,115 @@ fn overrides_circuit_adjustments(py: Python<'_>) -> PyResult<Vec<PyObject>> {
|
||||
d.set_item("max_upload_bandwidth", *v)?;
|
||||
}
|
||||
}
|
||||
lqos_overrides::CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id,
|
||||
sqm_override,
|
||||
} => {
|
||||
d.set_item("type", "device_adjust_sqm")?;
|
||||
d.set_item("device_id", device_id.clone())?;
|
||||
if let Some(value) = sqm_override {
|
||||
d.set_item("sqm_override", value.clone())?;
|
||||
}
|
||||
}
|
||||
lqos_overrides::CircuitAdjustment::RemoveCircuit { circuit_id } => {
|
||||
d.set_item("type", "remove_circuit")?;
|
||||
d.set_item("circuit_id", circuit_id.clone())?;
|
||||
}
|
||||
lqos_overrides::CircuitAdjustment::RemoveDevice { device_id } => {
|
||||
d.set_item("type", "remove_device")?;
|
||||
d.set_item("device_id", device_id.clone())?;
|
||||
}
|
||||
lqos_overrides::CircuitAdjustment::ReparentCircuit {
|
||||
circuit_id,
|
||||
parent_node,
|
||||
} => {
|
||||
d.set_item("type", "reparent_circuit")?;
|
||||
d.set_item("circuit_id", circuit_id.clone())?;
|
||||
d.set_item("parent_node", parent_node.clone())?;
|
||||
}
|
||||
}
|
||||
let obj: PyObject = d.unbind().into();
|
||||
out.push(obj);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Returns the list of circuit adjustments as Python dicts, using the effective overrides view
|
||||
/// (operator + adaptive layers when enabled).
|
||||
#[pyfunction]
|
||||
fn overrides_circuit_adjustments_effective(py: Python<'_>) -> PyResult<Vec<PyObject>> {
|
||||
let config = lqos_config::load_config().map_err(|e| PyOSError::new_err(e.to_string()))?;
|
||||
let apply_stormguard = config
|
||||
.stormguard
|
||||
.as_ref()
|
||||
.is_some_and(|sg| sg.enabled && !sg.dry_run);
|
||||
let apply_treeguard = config.treeguard.enabled;
|
||||
|
||||
let overrides =
|
||||
match lqos_overrides::OverrideStore::load_effective(apply_stormguard, apply_treeguard) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return Err(PyOSError::new_err(e.to_string())),
|
||||
};
|
||||
|
||||
let mut out: Vec<PyObject> = Vec::new();
|
||||
for adj in overrides.circuit_adjustments().iter() {
|
||||
let d = PyDict::new(py);
|
||||
match adj {
|
||||
lqos_overrides::CircuitAdjustment::CircuitAdjustSpeed {
|
||||
circuit_id,
|
||||
min_download_bandwidth,
|
||||
max_download_bandwidth,
|
||||
min_upload_bandwidth,
|
||||
max_upload_bandwidth,
|
||||
} => {
|
||||
d.set_item("type", "circuit_adjust_speed")?;
|
||||
d.set_item("circuit_id", circuit_id.clone())?;
|
||||
if let Some(v) = min_download_bandwidth {
|
||||
d.set_item("min_download_bandwidth", *v)?;
|
||||
}
|
||||
if let Some(v) = max_download_bandwidth {
|
||||
d.set_item("max_download_bandwidth", *v)?;
|
||||
}
|
||||
if let Some(v) = min_upload_bandwidth {
|
||||
d.set_item("min_upload_bandwidth", *v)?;
|
||||
}
|
||||
if let Some(v) = max_upload_bandwidth {
|
||||
d.set_item("max_upload_bandwidth", *v)?;
|
||||
}
|
||||
}
|
||||
lqos_overrides::CircuitAdjustment::DeviceAdjustSpeed {
|
||||
device_id,
|
||||
min_download_bandwidth,
|
||||
max_download_bandwidth,
|
||||
min_upload_bandwidth,
|
||||
max_upload_bandwidth,
|
||||
} => {
|
||||
d.set_item("type", "device_adjust_speed")?;
|
||||
d.set_item("device_id", device_id.clone())?;
|
||||
if let Some(v) = min_download_bandwidth {
|
||||
d.set_item("min_download_bandwidth", *v)?;
|
||||
}
|
||||
if let Some(v) = max_download_bandwidth {
|
||||
d.set_item("max_download_bandwidth", *v)?;
|
||||
}
|
||||
if let Some(v) = min_upload_bandwidth {
|
||||
d.set_item("min_upload_bandwidth", *v)?;
|
||||
}
|
||||
if let Some(v) = max_upload_bandwidth {
|
||||
d.set_item("max_upload_bandwidth", *v)?;
|
||||
}
|
||||
}
|
||||
lqos_overrides::CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id,
|
||||
sqm_override,
|
||||
} => {
|
||||
d.set_item("type", "device_adjust_sqm")?;
|
||||
d.set_item("device_id", device_id.clone())?;
|
||||
if let Some(value) = sqm_override {
|
||||
d.set_item("sqm_override", value.clone())?;
|
||||
}
|
||||
}
|
||||
lqos_overrides::CircuitAdjustment::RemoveCircuit { circuit_id } => {
|
||||
d.set_item("type", "remove_circuit")?;
|
||||
d.set_item("circuit_id", circuit_id.clone())?;
|
||||
|
||||
@@ -135,7 +135,8 @@ pub fn clear_circuit_fallback(
|
||||
|
||||
pub fn load_persisted_circuit_fallbacks() -> Result<HashMap<String, PersistedCircuitFallback>> {
|
||||
let overrides = OverrideStore::load_layer(OverrideLayer::Stormguard)?;
|
||||
Ok(group_circuit_fallbacks(overrides.persistent_devices()))
|
||||
let current_devices = ConfigShapedDevices::load()?.devices;
|
||||
Ok(group_circuit_fallbacks(&overrides, ¤t_devices))
|
||||
}
|
||||
|
||||
fn current_site_override(
|
||||
@@ -185,7 +186,19 @@ fn conflicting_sqm_owner(devices: &[ShapedDevice]) -> Result<Option<String>> {
|
||||
}
|
||||
|
||||
fn has_sqm_override_for_device_ids(overrides: &OverrideFile, device_ids: &HashSet<&str>) -> bool {
|
||||
overrides.persistent_devices().iter().any(|device| {
|
||||
overrides.circuit_adjustments().iter().any(|adj| {
|
||||
matches!(
|
||||
adj,
|
||||
lqos_overrides::CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id,
|
||||
sqm_override,
|
||||
} if device_ids.contains(device_id.as_str())
|
||||
&& sqm_override
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|token| !token.is_empty())
|
||||
)
|
||||
}) || overrides.persistent_devices().iter().any(|device| {
|
||||
device_ids.contains(device.device_id.as_str())
|
||||
&& device
|
||||
.sqm_override
|
||||
@@ -196,21 +209,15 @@ fn has_sqm_override_for_device_ids(overrides: &OverrideFile, device_ids: &HashSe
|
||||
|
||||
fn set_devices_sqm_override(base_devices: &[ShapedDevice], sqm_override: &str) -> Result<bool> {
|
||||
let mut overrides = OverrideStore::load_layer(OverrideLayer::Stormguard)?;
|
||||
let effective = OverrideStore::load_effective(false, true)?;
|
||||
let effective_devices: HashMap<&str, &ShapedDevice> = effective
|
||||
.persistent_devices()
|
||||
.iter()
|
||||
.map(|device| (device.device_id.as_str(), device))
|
||||
.collect();
|
||||
let mut changed = false;
|
||||
for base_device in base_devices {
|
||||
let mut device = effective_devices
|
||||
.get(base_device.device_id.as_str())
|
||||
.copied()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| base_device.clone());
|
||||
device.sqm_override = Some(sqm_override.to_string());
|
||||
if overrides.add_persistent_shaped_device_return_changed(device) {
|
||||
if overrides.set_device_sqm_override_return_changed(
|
||||
base_device.device_id.clone(),
|
||||
Some(sqm_override.to_string()),
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
if overrides.remove_persistent_shaped_device_by_device_count(&base_device.device_id) > 0 {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -227,8 +234,9 @@ fn clear_device_overrides(device_ids: &[String]) -> Result<bool> {
|
||||
let mut overrides = OverrideStore::load_layer(OverrideLayer::Stormguard)?;
|
||||
let mut removed_any = false;
|
||||
for device_id in device_ids {
|
||||
let removed = overrides.remove_persistent_shaped_device_by_device_count(device_id);
|
||||
if removed > 0 {
|
||||
let removed_adjustments = overrides.remove_device_sqm_override_by_device_count(device_id);
|
||||
let removed_devices = overrides.remove_persistent_shaped_device_by_device_count(device_id);
|
||||
if removed_adjustments > 0 || removed_devices > 0 {
|
||||
removed_any = true;
|
||||
}
|
||||
}
|
||||
@@ -321,28 +329,75 @@ fn ip_list(devices: &[ShapedDevice]) -> String {
|
||||
ips.join(",")
|
||||
}
|
||||
|
||||
fn group_circuit_fallbacks(devices: &[ShapedDevice]) -> HashMap<String, PersistedCircuitFallback> {
|
||||
fn group_circuit_fallbacks(
|
||||
overrides: &OverrideFile,
|
||||
current_devices: &[ShapedDevice],
|
||||
) -> HashMap<String, PersistedCircuitFallback> {
|
||||
let devices_by_id: HashMap<&str, &ShapedDevice> = current_devices
|
||||
.iter()
|
||||
.map(|device| (device.device_id.as_str(), device))
|
||||
.collect();
|
||||
let mut grouped: BTreeMap<String, PersistedCircuitFallback> = BTreeMap::new();
|
||||
for device in devices {
|
||||
let Some(token) = device.sqm_override.as_deref().map(str::trim) else {
|
||||
|
||||
for adj in overrides.circuit_adjustments() {
|
||||
let lqos_overrides::CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id,
|
||||
sqm_override,
|
||||
} = adj
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(token) = sqm_override.as_deref().map(str::trim) else {
|
||||
continue;
|
||||
};
|
||||
let Some(device) = devices_by_id.get(device_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if token.is_empty() || device.circuit_id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry =
|
||||
grouped
|
||||
.entry(device.circuit_id.clone())
|
||||
.or_insert_with(|| PersistedCircuitFallback {
|
||||
sqm_override: token.to_string(),
|
||||
devices: Vec::new(),
|
||||
});
|
||||
let entry = grouped
|
||||
.entry(device.circuit_id.clone())
|
||||
.or_insert_with(|| PersistedCircuitFallback {
|
||||
sqm_override: token.to_string(),
|
||||
devices: Vec::new(),
|
||||
});
|
||||
if entry.sqm_override != token {
|
||||
continue;
|
||||
}
|
||||
entry.devices.push((*device).clone());
|
||||
}
|
||||
|
||||
for device in overrides.persistent_devices() {
|
||||
let Some(token) = device.sqm_override.as_deref().map(str::trim) else {
|
||||
continue;
|
||||
};
|
||||
let Some(current_device) = devices_by_id.get(device.device_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if token.is_empty() || current_device.circuit_id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry = grouped
|
||||
.entry(current_device.circuit_id.clone())
|
||||
.or_insert_with(|| PersistedCircuitFallback {
|
||||
sqm_override: token.to_string(),
|
||||
devices: Vec::new(),
|
||||
});
|
||||
|
||||
if entry.sqm_override != token {
|
||||
continue;
|
||||
}
|
||||
entry.devices.push(device.clone());
|
||||
if entry
|
||||
.devices
|
||||
.iter()
|
||||
.any(|existing| existing.device_id == current_device.device_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
entry.devices.push((*current_device).clone());
|
||||
}
|
||||
grouped.into_iter().collect()
|
||||
}
|
||||
@@ -370,7 +425,21 @@ mod tests {
|
||||
let mut d3 = sample_device("dev3", "c2");
|
||||
d3.sqm_override = Some("cake".to_string());
|
||||
|
||||
let grouped = group_circuit_fallbacks(&[d1, d2, d3]);
|
||||
let mut overrides = OverrideFile::default();
|
||||
assert!(overrides.set_device_sqm_override_return_changed(
|
||||
"dev1".to_string(),
|
||||
Some("fq_codel".to_string())
|
||||
));
|
||||
assert!(overrides.set_device_sqm_override_return_changed(
|
||||
"dev2".to_string(),
|
||||
Some("fq_codel".to_string())
|
||||
));
|
||||
assert!(overrides.set_device_sqm_override_return_changed(
|
||||
"dev3".to_string(),
|
||||
Some("cake".to_string())
|
||||
));
|
||||
|
||||
let grouped = group_circuit_fallbacks(&overrides, &[d1, d2, d3]);
|
||||
assert_eq!(grouped.len(), 2);
|
||||
assert_eq!(
|
||||
grouped
|
||||
@@ -405,7 +474,21 @@ mod tests {
|
||||
let mut d3 = sample_device("dev3", "c2");
|
||||
d3.sqm_override = Some(" ".to_string());
|
||||
|
||||
let grouped = group_circuit_fallbacks(&[d1, d2, d3]);
|
||||
let mut overrides = OverrideFile::default();
|
||||
assert!(overrides.set_device_sqm_override_return_changed(
|
||||
"dev1".to_string(),
|
||||
Some("fq_codel".to_string())
|
||||
));
|
||||
assert!(overrides.set_device_sqm_override_return_changed(
|
||||
"dev2".to_string(),
|
||||
Some("cake".to_string())
|
||||
));
|
||||
assert!(overrides.set_device_sqm_override_return_changed(
|
||||
"dev3".to_string(),
|
||||
Some(" ".to_string())
|
||||
));
|
||||
|
||||
let grouped = group_circuit_fallbacks(&overrides, &[d1, d2, d3]);
|
||||
assert_eq!(
|
||||
grouped
|
||||
.get("c1")
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
use lqos_bakery::BakeryCommands;
|
||||
use lqos_bus::StormguardDebugEntry;
|
||||
use lqos_config::NetworkJsonTransport;
|
||||
use lqos_queue_tracker::QUEUE_STRUCTURE_CHANGED_STORMGUARD;
|
||||
use parking_lot::Mutex;
|
||||
use std::time::Duration;
|
||||
@@ -36,6 +37,7 @@ pub static STORMGUARD_DEBUG: Mutex<Vec<StormguardDebugEntry>> = Mutex::new(Vec::
|
||||
/// nothing to do.
|
||||
pub async fn start_stormguard(
|
||||
bakery: crossbeam_channel::Sender<BakeryCommands>,
|
||||
network_map_provider: fn() -> Vec<(usize, NetworkJsonTransport)>,
|
||||
) -> anyhow::Result<()> {
|
||||
let _ = tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
@@ -90,9 +92,12 @@ pub async fn start_stormguard(
|
||||
if let (Some(cfg), Some(tracker)) = (&config, &mut site_state_tracker) {
|
||||
let (active_ping_sample, active_ping_updated) = active_ping.latest();
|
||||
// Update all the ring buffers
|
||||
tracker
|
||||
.read_new_tick_data(cfg, active_ping_sample, active_ping_updated)
|
||||
.await;
|
||||
tracker.read_new_tick_data(
|
||||
cfg,
|
||||
active_ping_sample,
|
||||
active_ping_updated,
|
||||
network_map_provider(),
|
||||
);
|
||||
|
||||
// Check for state changes
|
||||
tracker.check_state(cfg);
|
||||
|
||||
@@ -21,7 +21,8 @@ use crate::site_state::stormguard_state::StormguardState;
|
||||
use crate::{MOVING_AVERAGE_BUFFER_SIZE, READING_ACCUMULATOR_SIZE};
|
||||
use crossbeam_channel::Sender;
|
||||
use lqos_bakery::BakeryCommands;
|
||||
use lqos_bus::{BusRequest, BusResponse, StormguardDebugDirection, StormguardDebugEntry, TcHandle};
|
||||
use lqos_bus::{StormguardDebugDirection, StormguardDebugEntry, TcHandle};
|
||||
use lqos_config::NetworkJsonTransport;
|
||||
use lqos_queue_tracker::QUEUE_STRUCTURE;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -185,63 +186,53 @@ impl SiteStateTracker {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_new_tick_data(
|
||||
pub fn read_new_tick_data(
|
||||
&mut self,
|
||||
config: &StormguardConfig,
|
||||
active_ping_sample: Option<TimedRtt>,
|
||||
active_ping_updated: bool,
|
||||
all_nodes: Vec<(usize, NetworkJsonTransport)>,
|
||||
) {
|
||||
let requests = vec![BusRequest::GetFullNetworkMap];
|
||||
let Ok(responses) = lqos_bus::bus_request(requests).await else {
|
||||
info!("Failed to get lqosd stats");
|
||||
return;
|
||||
};
|
||||
|
||||
for site in self.sites.values_mut() {
|
||||
site.current_throughput = (0.0, 0.0);
|
||||
site.clear_tick_rtt_state();
|
||||
}
|
||||
|
||||
for response in responses {
|
||||
let BusResponse::NetworkMap(all_nodes) = response else {
|
||||
for (_, node_info) in all_nodes {
|
||||
let Some(target) = self.sites.get_mut(&node_info.name) else {
|
||||
continue;
|
||||
};
|
||||
for (_, node_info) in all_nodes {
|
||||
let Some(target) = self.sites.get_mut(&node_info.name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Record throughput (Mbps)
|
||||
let down_mbps = (node_info.current_throughput.0 as f64 * 8.0) / 1_000_000.0;
|
||||
let up_mbps = (node_info.current_throughput.1 as f64 * 8.0) / 1_000_000.0;
|
||||
target.throughput_down.add(down_mbps);
|
||||
target.throughput_up.add(up_mbps);
|
||||
target.current_throughput = (down_mbps, up_mbps);
|
||||
// Record throughput (Mbps)
|
||||
let down_mbps = (node_info.current_throughput.0 as f64 * 8.0) / 1_000_000.0;
|
||||
let up_mbps = (node_info.current_throughput.1 as f64 * 8.0) / 1_000_000.0;
|
||||
target.throughput_down.add(down_mbps);
|
||||
target.throughput_up.add(up_mbps);
|
||||
target.current_throughput = (down_mbps, up_mbps);
|
||||
|
||||
// Retransmits (as a percentage of TCP packets)
|
||||
let retransmits_down = if node_info.current_tcp_packets.0 > 0 {
|
||||
node_info.current_retransmits.0 as f64 / node_info.current_tcp_packets.0 as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let retransmits_up = if node_info.current_tcp_packets.1 > 0 {
|
||||
node_info.current_retransmits.1 as f64 / node_info.current_tcp_packets.1 as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
target.retransmits_down.add(retransmits_down);
|
||||
target.retransmits_up.add(retransmits_up);
|
||||
// Retransmits (as a percentage of TCP packets)
|
||||
let retransmits_down = if node_info.current_tcp_packets.0 > 0 {
|
||||
node_info.current_retransmits.0 as f64 / node_info.current_tcp_packets.0 as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let retransmits_up = if node_info.current_tcp_packets.1 > 0 {
|
||||
node_info.current_retransmits.1 as f64 / node_info.current_tcp_packets.1 as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
target.retransmits_down.add(retransmits_down);
|
||||
target.retransmits_up.add(retransmits_up);
|
||||
|
||||
// Round-Trip Time
|
||||
if !node_info.rtts.is_empty() {
|
||||
let mut my_round_trip_times = node_info.rtts.clone();
|
||||
my_round_trip_times.sort_by(|a, b| a.total_cmp(b));
|
||||
let samples = my_round_trip_times.len();
|
||||
let mut idx = ((samples as f32) * 0.9).floor() as usize;
|
||||
idx = idx.min(samples.saturating_sub(1));
|
||||
let p90 = my_round_trip_times[idx] as f64;
|
||||
target.record_passive_rtt_sample(p90);
|
||||
}
|
||||
// Round-Trip Time
|
||||
if !node_info.rtts.is_empty() {
|
||||
let mut my_round_trip_times = node_info.rtts.clone();
|
||||
my_round_trip_times.sort_by(|a, b| a.total_cmp(b));
|
||||
let samples = my_round_trip_times.len();
|
||||
let mut idx = ((samples as f32) * 0.9).floor() as usize;
|
||||
idx = idx.min(samples.saturating_sub(1));
|
||||
let p90 = my_round_trip_times[idx] as f64;
|
||||
target.record_passive_rtt_sample(p90);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -336,7 +336,12 @@ fn main() -> Result<()> {
|
||||
Err(e) => error!("Insight control channel failed to start: {:#}", e),
|
||||
}
|
||||
|
||||
match lqos_stormguard::start_stormguard(bakery_sender_for_async).await {
|
||||
match lqos_stormguard::start_stormguard(
|
||||
bakery_sender_for_async,
|
||||
shaped_devices_tracker::full_network_map_snapshot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => info!("StormGuard started successfully"),
|
||||
Err(e) => error!("StormGuard failed to start: {:#}", e),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
use crate::throughput_tracker::flow_data;
|
||||
|
||||
pub fn flow_map_data() -> Vec<(f64, f64, String, u64, f32)> {
|
||||
flow_data::RECENT_FLOWS.lat_lon_endpoints()
|
||||
}
|
||||
|
||||
pub fn endpoints_by_country_data() -> Vec<(
|
||||
String,
|
||||
lqos_utils::units::DownUpOrder<u64>,
|
||||
[f32; 2],
|
||||
String,
|
||||
)> {
|
||||
flow_data::RECENT_FLOWS.country_summary()
|
||||
}
|
||||
|
||||
pub fn endpoint_latlon_data() -> Vec<(f64, f64, String, u64, f32)> {
|
||||
flow_data::RECENT_FLOWS.lat_lon_endpoints()
|
||||
}
|
||||
@@ -143,7 +143,7 @@ async fn one_second_cadence(
|
||||
),
|
||||
ticker_with_timeout(
|
||||
"endpoint_latlon",
|
||||
endpoint_latlon::endpoint_latlon(channels.clone(), bus_tx.clone())
|
||||
endpoint_latlon::endpoint_latlon(channels.clone())
|
||||
),
|
||||
ticker_with_timeout(
|
||||
"tree_summary",
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::node_manager::local_api::flow_map;
|
||||
use crate::node_manager::ws::messages::WsResponse;
|
||||
use crate::node_manager::ws::publish_subscribe::PubSub;
|
||||
use crate::node_manager::ws::published_channels::PublishedChannels;
|
||||
use lqos_bus::{BusReply, BusRequest, BusResponse};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
pub async fn endpoint_latlon(
|
||||
channels: Arc<PubSub>,
|
||||
bus_tx: Sender<(tokio::sync::oneshot::Sender<lqos_bus::BusReply>, BusRequest)>,
|
||||
) {
|
||||
pub async fn endpoint_latlon(channels: Arc<PubSub>) {
|
||||
if !channels
|
||||
.is_channel_alive(PublishedChannels::EndpointLatLon)
|
||||
.await
|
||||
@@ -17,29 +13,10 @@ pub async fn endpoint_latlon(
|
||||
return;
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<BusReply>();
|
||||
let request = BusRequest::CurrentEndpointLatLon;
|
||||
if let Err(e) = bus_tx.send((tx, request)).await {
|
||||
tracing::warn!("EndpointLatLon: failed to send request to bus: {:?}", e);
|
||||
return;
|
||||
}
|
||||
let replies = match rx.await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"EndpointLatLon: failed to receive response from bus: {:?}",
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
let message = WsResponse::EndpointLatLon {
|
||||
data: flow_map::endpoint_latlon_data(),
|
||||
};
|
||||
|
||||
for reply in replies.responses.into_iter() {
|
||||
if let BusResponse::CurrentLatLon(points) = reply {
|
||||
let message = WsResponse::EndpointLatLon { data: points };
|
||||
channels
|
||||
.send(PublishedChannels::EndpointLatLon, message)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
channels
|
||||
.send(PublishedChannels::EndpointLatLon, message)
|
||||
.await;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::node_manager::local_api::flow_map;
|
||||
use crate::node_manager::ws::messages::{EtherProtocolsData, WsResponse};
|
||||
use crate::node_manager::ws::publish_subscribe::PubSub;
|
||||
use crate::node_manager::ws::published_channels::PublishedChannels;
|
||||
@@ -8,7 +9,7 @@ use tokio::sync::mpsc::Sender;
|
||||
|
||||
pub async fn endpoints_by_country(
|
||||
channels: Arc<PubSub>,
|
||||
bus_tx: Sender<(tokio::sync::oneshot::Sender<lqos_bus::BusReply>, BusRequest)>,
|
||||
_bus_tx: Sender<(tokio::sync::oneshot::Sender<lqos_bus::BusReply>, BusRequest)>,
|
||||
) {
|
||||
if !channels
|
||||
.is_channel_alive(PublishedChannels::EndpointsByCountry)
|
||||
@@ -17,30 +18,12 @@ pub async fn endpoints_by_country(
|
||||
return;
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<BusReply>();
|
||||
let request = BusRequest::CurrentEndpointsByCountry;
|
||||
if let Err(e) = bus_tx.send((tx, request)).await {
|
||||
tracing::warn!("EndpointsByCountry: failed to send request to bus: {:?}", e);
|
||||
return;
|
||||
}
|
||||
let replies = match rx.await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"EndpointsByCountry: failed to receive throughput from bus: {:?}",
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
let message = WsResponse::EndpointsByCountry {
|
||||
data: flow_map::endpoints_by_country_data(),
|
||||
};
|
||||
for reply in replies.responses.into_iter() {
|
||||
if let BusResponse::CurrentEndpointsByCountry(countries) = reply {
|
||||
let message = WsResponse::EndpointsByCountry { data: countries };
|
||||
channels
|
||||
.send(PublishedChannels::EndpointsByCountry, message)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
channels
|
||||
.send(PublishedChannels::EndpointsByCountry, message)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn ether_protocols(
|
||||
|
||||
@@ -150,17 +150,17 @@ pub fn get_one_network_map_layer(parent_idx: usize) -> BusResponse {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_full_network_map() -> BusResponse {
|
||||
pub fn full_network_map_snapshot() -> Vec<(usize, NetworkJsonTransport)> {
|
||||
let nj = NETWORK_JSON.read();
|
||||
let data = {
|
||||
nj.get_nodes_when_ready()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (i, n.clone_to_transit()))
|
||||
.collect::<Vec<(usize, NetworkJsonTransport)>>()
|
||||
};
|
||||
nj.get_nodes_when_ready()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (i, n.clone_to_transit()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
BusResponse::NetworkMap(data)
|
||||
pub fn get_full_network_map() -> BusResponse {
|
||||
BusResponse::NetworkMap(full_network_map_snapshot())
|
||||
}
|
||||
|
||||
pub fn get_top_n_root_queues(n_queues: usize) -> BusResponse {
|
||||
|
||||
@@ -1212,7 +1212,6 @@ pub fn current_endpoints_by_country() -> BusResponse {
|
||||
/// Current endpoint lat/lon
|
||||
pub fn current_lat_lon() -> BusResponse {
|
||||
let summary = flow_data::RECENT_FLOWS.lat_lon_endpoints();
|
||||
debug!("CurrentLatLon payload: {:?}", summary);
|
||||
BusResponse::CurrentLatLon(summary)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,15 @@ use lqos_overrides::{NetworkAdjustment, OverrideFile, OverrideLayer, OverrideSto
|
||||
use lqos_utils::hash_to_i64;
|
||||
use lqos_utils::units::DownUpOrder;
|
||||
use lqos_utils::unix_time::{time_since_boot, unix_now};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
static TREEGUARD_SENDER: OnceLock<Sender<TreeguardCommand>> = OnceLock::new();
|
||||
static TREEGUARD_STATUS_CACHE: OnceLock<RwLock<TreeguardStatusData>> = OnceLock::new();
|
||||
static TREEGUARD_ACTIVITY_CACHE: OnceLock<RwLock<Vec<TreeguardActivityEntry>>> = OnceLock::new();
|
||||
|
||||
const ACTIVITY_RING_CAPACITY: usize = 200;
|
||||
const UTIL_EWMA_ALPHA: f64 = 0.1;
|
||||
@@ -60,6 +63,9 @@ pub(crate) fn start_treeguard_actor(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = TREEGUARD_STATUS_CACHE.set(RwLock::new(empty_status_snapshot()));
|
||||
let _ = TREEGUARD_ACTIVITY_CACHE.set(RwLock::new(Vec::new()));
|
||||
|
||||
let (tx, rx) = crossbeam_channel::bounded::<TreeguardCommand>(64);
|
||||
let _ = TREEGUARD_SENDER.set(tx);
|
||||
|
||||
@@ -70,6 +76,14 @@ pub(crate) fn start_treeguard_actor(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn cached_status_snapshot() -> Option<TreeguardStatusData> {
|
||||
Some(TREEGUARD_STATUS_CACHE.get()?.read().clone())
|
||||
}
|
||||
|
||||
pub(crate) fn cached_activity_snapshot() -> Option<Vec<TreeguardActivityEntry>> {
|
||||
Some(TREEGUARD_ACTIVITY_CACHE.get()?.read().clone())
|
||||
}
|
||||
|
||||
/// Requests a status snapshot from the TreeGuard actor.
|
||||
///
|
||||
/// This function is not pure: it sends a message to the TreeGuard actor thread.
|
||||
@@ -110,18 +124,9 @@ fn treeguard_actor_loop(
|
||||
) {
|
||||
debug!("TreeGuard actor started");
|
||||
|
||||
let mut status = TreeguardStatusData {
|
||||
enabled: false,
|
||||
dry_run: true,
|
||||
cpu_max_pct: None,
|
||||
managed_nodes: 0,
|
||||
managed_circuits: 0,
|
||||
virtualized_nodes: 0,
|
||||
fq_codel_circuits: 0,
|
||||
last_action_summary: None,
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
let mut status = empty_status_snapshot();
|
||||
let mut activity: VecDeque<TreeguardActivityEntry> = VecDeque::new();
|
||||
update_cached_snapshots(&status, &activity);
|
||||
|
||||
let mut runtime_state = TreeguardRuntimeState::default();
|
||||
|
||||
@@ -143,6 +148,7 @@ fn treeguard_actor_loop(
|
||||
&mut tick_seconds,
|
||||
&mut runtime_state,
|
||||
);
|
||||
update_cached_snapshots(&status, &activity);
|
||||
}
|
||||
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
|
||||
warn!("TreeGuard actor command channel disconnected; exiting actor");
|
||||
@@ -171,6 +177,32 @@ fn handle_command(
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_status_snapshot() -> TreeguardStatusData {
|
||||
TreeguardStatusData {
|
||||
enabled: false,
|
||||
dry_run: true,
|
||||
cpu_max_pct: None,
|
||||
managed_nodes: 0,
|
||||
managed_circuits: 0,
|
||||
virtualized_nodes: 0,
|
||||
fq_codel_circuits: 0,
|
||||
last_action_summary: None,
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_cached_snapshots(
|
||||
status: &TreeguardStatusData,
|
||||
activity: &VecDeque<TreeguardActivityEntry>,
|
||||
) {
|
||||
if let Some(cache) = TREEGUARD_STATUS_CACHE.get() {
|
||||
*cache.write() = status.clone();
|
||||
}
|
||||
if let Some(cache) = TREEGUARD_ACTIVITY_CACHE.get() {
|
||||
*cache.write() = activity.iter().cloned().rev().collect();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TreeguardRuntimeState {
|
||||
link_states: FxHashMap<String, LinkState>,
|
||||
@@ -364,17 +396,7 @@ fn run_tick(
|
||||
|
||||
let operator_sqm_device_overrides: FxHashSet<String> = operator_overrides_snapshot
|
||||
.as_ref()
|
||||
.map(|o| {
|
||||
o.persistent_devices()
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.sqm_override
|
||||
.as_deref()
|
||||
.is_some_and(|t| !t.trim().is_empty())
|
||||
})
|
||||
.map(|d| d.device_id.clone())
|
||||
.collect()
|
||||
})
|
||||
.map(overrides_sqm_device_ids)
|
||||
.unwrap_or_default();
|
||||
|
||||
// --- Link sampling + decisions (virtualization) ---
|
||||
@@ -1310,11 +1332,7 @@ fn run_tick(
|
||||
// Cleanup for removed circuits or disabled circuits/TreeGuard.
|
||||
if !manage_circuits {
|
||||
let removed: Vec<String> = match OverrideStore::load_layer(OverrideLayer::Treeguard) {
|
||||
Ok(of) => of
|
||||
.persistent_devices()
|
||||
.iter()
|
||||
.map(|d| d.device_id.clone())
|
||||
.collect(),
|
||||
Ok(of) => overrides_sqm_device_ids(&of).into_iter().collect(),
|
||||
Err(e) => {
|
||||
status.warnings.push(format!(
|
||||
"TreeGuard circuits: unable to load TreeGuard overrides for cleanup: {e}"
|
||||
@@ -1352,12 +1370,7 @@ fn run_tick(
|
||||
// Reconcile device IDs removed from allowlisted circuits.
|
||||
let treeguard_device_ids_with_overrides: FxHashSet<String> = treeguard_overrides_snapshot
|
||||
.as_ref()
|
||||
.map(|of| {
|
||||
of.persistent_devices()
|
||||
.iter()
|
||||
.map(|d| d.device_id.clone())
|
||||
.collect()
|
||||
})
|
||||
.map(overrides_sqm_device_ids)
|
||||
.unwrap_or_default();
|
||||
let removed: Vec<String> = treeguard_device_ids_with_overrides
|
||||
.iter()
|
||||
@@ -1667,7 +1680,9 @@ fn run_tick(
|
||||
let mut persisted_ok = false;
|
||||
let mut can_apply_live = true;
|
||||
if tg.circuits.persist_sqm_overrides {
|
||||
match overrides::set_devices_sqm_override(&devices, &token) {
|
||||
let device_ids: Vec<String> =
|
||||
devices.iter().map(|device| device.device_id.clone()).collect();
|
||||
match overrides::set_devices_sqm_override(&device_ids, &token) {
|
||||
Ok(_) => {
|
||||
persisted_ok = true;
|
||||
}
|
||||
@@ -1795,7 +1810,73 @@ fn overrides_node_virtual(overrides: &OverrideFile, node_name: &str) -> Option<b
|
||||
})
|
||||
}
|
||||
|
||||
/// Infers an SQM override token for a circuit, preferring persisted device overlays.
|
||||
/// Returns the current SQM override token for `device_id`, if present.
|
||||
///
|
||||
/// This function is pure: it has no side effects.
|
||||
fn overrides_device_sqm(overrides: &OverrideFile, device_id: &str) -> Option<String> {
|
||||
for adj in overrides.circuit_adjustments() {
|
||||
if let lqos_overrides::CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id: current,
|
||||
sqm_override,
|
||||
} = adj
|
||||
{
|
||||
if current != device_id {
|
||||
continue;
|
||||
}
|
||||
return sqm_override
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
for dev in overrides.persistent_devices() {
|
||||
if dev.device_id != device_id {
|
||||
continue;
|
||||
}
|
||||
if let Some(token) = dev.sqm_override.as_deref().map(str::trim)
|
||||
&& !token.is_empty()
|
||||
{
|
||||
return Some(token.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the set of device IDs carrying an SQM override in an overrides file.
|
||||
///
|
||||
/// This function is pure: it has no side effects.
|
||||
fn overrides_sqm_device_ids(overrides: &OverrideFile) -> FxHashSet<String> {
|
||||
let mut out = FxHashSet::default();
|
||||
for adj in overrides.circuit_adjustments() {
|
||||
if let lqos_overrides::CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id,
|
||||
sqm_override,
|
||||
} = adj
|
||||
&& sqm_override
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|token| !token.is_empty())
|
||||
{
|
||||
out.insert(device_id.clone());
|
||||
}
|
||||
}
|
||||
for dev in overrides.persistent_devices() {
|
||||
if dev
|
||||
.sqm_override
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|token| !token.is_empty())
|
||||
{
|
||||
out.insert(dev.device_id.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Infers an SQM override token for a circuit, preferring persisted override entries.
|
||||
///
|
||||
/// This function is pure: it has no side effects.
|
||||
fn infer_circuit_sqm_override_token(
|
||||
@@ -1810,15 +1891,9 @@ fn infer_circuit_sqm_override_token(
|
||||
.collect();
|
||||
|
||||
if let Some(overrides) = overrides {
|
||||
for dev in overrides.persistent_devices() {
|
||||
if !circuit_device_ids.contains(dev.device_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(token) = dev.sqm_override.as_deref() {
|
||||
let token = token.trim();
|
||||
if !token.is_empty() {
|
||||
return Some(token.to_string());
|
||||
}
|
||||
for device_id in &circuit_device_ids {
|
||||
if let Some(token) = overrides_device_sqm(overrides, device_id) {
|
||||
return Some(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! This module writes TreeGuard-owned changes to `lqos_overrides.treeguard.json`.
|
||||
|
||||
use crate::treeguard::TreeguardError;
|
||||
use lqos_config::ShapedDevice;
|
||||
use lqos_overrides::{NetworkAdjustment, OverrideFile, OverrideLayer, OverrideStore};
|
||||
|
||||
/// Returns the current virtual override value for `node_name`, if present.
|
||||
@@ -75,14 +74,13 @@ pub(crate) fn clear_node_virtual(node_name: &str) -> Result<bool, TreeguardError
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Persists a per-device SQM override token for a list of devices by storing persistent shaped
|
||||
/// device overlays.
|
||||
/// Persists a per-device SQM override token for a list of devices.
|
||||
///
|
||||
/// This function is not pure: it reads and writes `lqos_overrides.treeguard.json`.
|
||||
///
|
||||
/// Returns `Ok(true)` if the file was changed, `Ok(false)` if no change was needed.
|
||||
pub(crate) fn set_devices_sqm_override(
|
||||
base_devices: &[ShapedDevice],
|
||||
device_ids: &[String],
|
||||
sqm_override: &str,
|
||||
) -> Result<bool, TreeguardError> {
|
||||
let mut overrides = OverrideStore::load_layer(OverrideLayer::Treeguard).map_err(|e| {
|
||||
@@ -92,10 +90,14 @@ pub(crate) fn set_devices_sqm_override(
|
||||
})?;
|
||||
|
||||
let mut changed = false;
|
||||
for base_device in base_devices {
|
||||
let mut device = base_device.clone();
|
||||
device.sqm_override = Some(sqm_override.to_string());
|
||||
if overrides.add_persistent_shaped_device_return_changed(device) {
|
||||
for device_id in device_ids {
|
||||
if overrides.set_device_sqm_override_return_changed(
|
||||
device_id.clone(),
|
||||
Some(sqm_override.to_string()),
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
if overrides.remove_persistent_shaped_device_by_device_count(device_id) > 0 {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -112,7 +114,7 @@ pub(crate) fn set_devices_sqm_override(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Removes any persistent shaped device overlay entries for a list of device IDs.
|
||||
/// Removes any persisted SQM override entries for a list of device IDs.
|
||||
///
|
||||
/// This function is not pure: it reads and writes `lqos_overrides.treeguard.json`.
|
||||
///
|
||||
@@ -126,8 +128,9 @@ pub(crate) fn clear_device_overrides(device_ids: &[String]) -> Result<bool, Tree
|
||||
|
||||
let mut removed_any = false;
|
||||
for device_id in device_ids {
|
||||
let removed = overrides.remove_persistent_shaped_device_by_device_count(device_id);
|
||||
if removed > 0 {
|
||||
let removed_adjustments = overrides.remove_device_sqm_override_by_device_count(device_id);
|
||||
let removed_devices = overrides.remove_persistent_shaped_device_by_device_count(device_id);
|
||||
if removed_adjustments > 0 || removed_devices > 0 {
|
||||
removed_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ use lqos_config::load_config;
|
||||
/// This function is not pure: it sends a request to the TreeGuard actor, and may fall back to
|
||||
/// reading the current configuration from disk (via the config cache).
|
||||
pub async fn treeguard_status_snapshot() -> TreeguardStatusData {
|
||||
if let Some(snapshot) = actor::cached_status_snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
if let Some(snapshot) = actor::request_status_snapshot().await {
|
||||
return snapshot;
|
||||
}
|
||||
@@ -71,5 +75,9 @@ pub async fn treeguard_status_snapshot() -> TreeguardStatusData {
|
||||
/// This function is not pure: it sends a request to the TreeGuard actor.
|
||||
/// If the actor isn't available, it returns an empty list.
|
||||
pub async fn treeguard_activity_snapshot() -> Vec<TreeguardActivityEntry> {
|
||||
if let Some(snapshot) = actor::cached_activity_snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
actor::request_activity_snapshot().await.unwrap_or_default()
|
||||
}
|
||||
+16
-2
@@ -12,7 +12,7 @@ from liblqos_python import automatic_import_uisp, automatic_import_splynx, queue
|
||||
automatic_import_powercode, automatic_import_sonar, influx_db_enabled, get_libreqos_directory, \
|
||||
blackboard_finish, blackboard_submit, automatic_import_wispgate, enable_insight_topology, insight_topology_role, \
|
||||
automatic_import_netzur, automatic_import_visp, calculate_hash, efficiency_core_ids, scheduler_alive, scheduler_error, \
|
||||
overrides_persistent_devices_effective, overrides_circuit_adjustments, overrides_network_adjustments_effective, \
|
||||
overrides_persistent_devices_effective, overrides_circuit_adjustments_effective, overrides_network_adjustments_effective, \
|
||||
scheduler_output
|
||||
|
||||
from apscheduler.schedulers.background import BlockingScheduler
|
||||
@@ -347,7 +347,7 @@ def apply_lqos_overrides():
|
||||
|
||||
# 2) Circuit adjustments: speed changes, removals, reparenting
|
||||
try:
|
||||
adjustments = overrides_circuit_adjustments()
|
||||
adjustments = overrides_circuit_adjustments_effective()
|
||||
except Exception as e:
|
||||
print(f"Failed to read circuit adjustments: {e}")
|
||||
adjustments = []
|
||||
@@ -360,6 +360,11 @@ def apply_lqos_overrides():
|
||||
except Exception:
|
||||
return current_str
|
||||
|
||||
def set_row_value(row, index, value):
|
||||
while len(row) <= index:
|
||||
row.append('')
|
||||
row[index] = value
|
||||
|
||||
if adjustments:
|
||||
for adj in adjustments:
|
||||
t = adj.get('type')
|
||||
@@ -381,6 +386,15 @@ def apply_lqos_overrides():
|
||||
r[9] = set_if_some(adj.get('min_upload_bandwidth'), r[9] if len(r) > 9 else '')
|
||||
r[11] = set_if_some(adj.get('max_upload_bandwidth'), r[11] if len(r) > 11 else '')
|
||||
changed = True
|
||||
elif t == 'device_adjust_sqm':
|
||||
did = adj.get('device_id', '')
|
||||
sqm_override = (adj.get('sqm_override') or '').strip()
|
||||
for r in merged_rows:
|
||||
if len(r) >= 3 and r[2] == did:
|
||||
current_sqm = r[13] if len(r) > 13 else ''
|
||||
if current_sqm != sqm_override:
|
||||
set_row_value(r, 13, sqm_override)
|
||||
changed = True
|
||||
elif t == 'remove_circuit':
|
||||
cid = adj.get('circuit_id', '')
|
||||
before = len(merged_rows)
|
||||
|
||||
Executable
+254
@@ -0,0 +1,254 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOTFIX_BASE_URL="${HOTFIX_BASE_URL:-https://downloads.libreqos.com}"
|
||||
HOTFIX_PACKAGE_VERSION="${HOTFIX_PACKAGE_VERSION:-255.4-1ubuntu8.12+libreqos1}"
|
||||
SUPPORTED_UBUNTU_SYSTEMD_VERSION_GLOBS="${SUPPORTED_UBUNTU_SYSTEMD_VERSION_GLOBS:-255.4-1ubuntu8 255.4-1ubuntu8.*}"
|
||||
HOTFIX_MARKER="${HOTFIX_MARKER:-/opt/libreqos/src/.systemd_hotfix_installed}"
|
||||
ARCH="${ARCH:-amd64}"
|
||||
|
||||
HOTFIX_CORE_PACKAGES=(
|
||||
"libsystemd0"
|
||||
"libsystemd-shared"
|
||||
"libudev1"
|
||||
"systemd-dev"
|
||||
"systemd"
|
||||
"systemd-sysv"
|
||||
"systemd-resolved"
|
||||
"systemd-timesyncd"
|
||||
"udev"
|
||||
)
|
||||
|
||||
HOTFIX_OPTIONAL_PACKAGES=(
|
||||
"libpam-systemd"
|
||||
"libnss-systemd"
|
||||
"libnss-resolve"
|
||||
"libnss-myhostname"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 <command>
|
||||
|
||||
Commands:
|
||||
status Show whether this host should be offered the Noble systemd hotfix
|
||||
should-offer Exit 0 when the hotfix should be offered on this host
|
||||
download Download the hotfix bundle into a temporary directory
|
||||
install Download and install the hotfix bundle with apt
|
||||
packages Print the package filenames expected for this host
|
||||
urls Print the expected package URLs for this host
|
||||
|
||||
Environment:
|
||||
HOTFIX_BASE_URL Base URL hosting the .deb files
|
||||
HOTFIX_PACKAGE_VERSION Backported package version suffix
|
||||
SUPPORTED_UBUNTU_SYSTEMD_VERSION_GLOBS Space-separated stock Ubuntu version globs eligible for replacement
|
||||
HOTFIX_MARKER Marker file written after install
|
||||
ARCH Debian architecture suffix, defaults to amd64
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf 'Error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"
|
||||
}
|
||||
|
||||
current_systemd_version() {
|
||||
dpkg-query -W -f='${Version}\n' systemd 2>/dev/null || true
|
||||
}
|
||||
|
||||
package_is_installed() {
|
||||
local package="$1"
|
||||
dpkg-query -W -f='${db:Status-Abbrev}\n' "$package" 2>/dev/null | grep -q '^ii'
|
||||
}
|
||||
|
||||
package_arch_suffix() {
|
||||
local package="$1"
|
||||
|
||||
case "$package" in
|
||||
systemd-dev)
|
||||
printf 'all\n'
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$ARCH"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
package_filename() {
|
||||
local package="$1"
|
||||
printf '%s_%s_%s.deb\n' \
|
||||
"$package" \
|
||||
"$HOTFIX_PACKAGE_VERSION" \
|
||||
"$(package_arch_suffix "$package")"
|
||||
}
|
||||
|
||||
resolved_hotfix_packages() {
|
||||
local package
|
||||
|
||||
for package in "${HOTFIX_CORE_PACKAGES[@]}"; do
|
||||
package_filename "$package"
|
||||
done
|
||||
|
||||
for package in "${HOTFIX_OPTIONAL_PACKAGES[@]}"; do
|
||||
if package_is_installed "$package"; then
|
||||
package_filename "$package"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
is_supported_os() {
|
||||
[[ -r /etc/os-release ]] || return 1
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
[[ "${ID:-}" == "ubuntu" && "${VERSION_ID:-}" == "24.04" && "${VERSION_CODENAME:-}" == "noble" ]]
|
||||
}
|
||||
|
||||
is_installed_hotfix() {
|
||||
local version
|
||||
version="$(current_systemd_version)"
|
||||
[[ "$version" == *"+libreqos"* ]]
|
||||
}
|
||||
|
||||
is_supported_stock_version() {
|
||||
local version supported
|
||||
version="$(current_systemd_version)"
|
||||
for supported in $SUPPORTED_UBUNTU_SYSTEMD_VERSION_GLOBS; do
|
||||
[[ "$version" == $supported ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
uses_systemd_networkd() {
|
||||
local enabled_state active_state
|
||||
|
||||
enabled_state="$(systemctl is-enabled systemd-networkd 2>/dev/null || true)"
|
||||
active_state="$(systemctl is-active systemd-networkd 2>/dev/null || true)"
|
||||
|
||||
[[ "$enabled_state" == "enabled" || "$enabled_state" == "static" || "$active_state" == "active" ]]
|
||||
}
|
||||
|
||||
print_urls() {
|
||||
local package
|
||||
while IFS= read -r package; do
|
||||
printf '%s/%s\n' "$HOTFIX_BASE_URL" "$package"
|
||||
done < <(resolved_hotfix_packages)
|
||||
}
|
||||
|
||||
print_packages() {
|
||||
resolved_hotfix_packages
|
||||
}
|
||||
|
||||
status() {
|
||||
local version
|
||||
version="$(current_systemd_version)"
|
||||
|
||||
if ! is_supported_os; then
|
||||
log "Host is not Ubuntu 24.04 Noble. Hotfix not applicable."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "$version" ]]; then
|
||||
log "systemd is not installed via dpkg query. Hotfix not applicable."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! uses_systemd_networkd; then
|
||||
log "systemd-networkd is not enabled or active. Hotfix not applicable."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if is_installed_hotfix; then
|
||||
log "LibreQoS hotfix already installed: $version"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if is_supported_stock_version; then
|
||||
log "Hotfix should be offered. Installed systemd version: $version"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Installed systemd version is not in the supported replacement list: $version"
|
||||
return 1
|
||||
}
|
||||
|
||||
download_bundle() {
|
||||
local workdir package
|
||||
require_command curl
|
||||
workdir="$(mktemp -d /tmp/libreqos-systemd-hotfix.XXXXXX)"
|
||||
while IFS= read -r package; do
|
||||
curl -fL --retry 3 --output "${workdir}/${package}" "${HOTFIX_BASE_URL}/${package}"
|
||||
done < <(resolved_hotfix_packages)
|
||||
printf '%s\n' "$workdir"
|
||||
}
|
||||
|
||||
write_marker() {
|
||||
local package
|
||||
{
|
||||
printf 'installed_at=%s\n' "$(date -Iseconds)"
|
||||
printf 'package_version=%s\n' "$HOTFIX_PACKAGE_VERSION"
|
||||
printf 'base_url=%s\n' "$HOTFIX_BASE_URL"
|
||||
printf 'systemd_version=%s\n' "$(current_systemd_version)"
|
||||
while IFS= read -r package; do
|
||||
printf 'package_file=%s\n' "$package"
|
||||
printf 'package_url=%s/%s\n' "$HOTFIX_BASE_URL" "$package"
|
||||
done < <(resolved_hotfix_packages)
|
||||
} | sudo tee "$HOTFIX_MARKER" >/dev/null
|
||||
}
|
||||
|
||||
install_bundle() {
|
||||
local workdir package_paths=() package
|
||||
|
||||
status >/dev/null || fail "Hotfix is not applicable on this host."
|
||||
require_command sudo
|
||||
require_command apt-get
|
||||
|
||||
workdir="$(download_bundle)"
|
||||
while IFS= read -r package; do
|
||||
package_paths+=("${workdir}/${package}")
|
||||
done < <(resolved_hotfix_packages)
|
||||
|
||||
sudo apt-get install -y "${package_paths[@]}"
|
||||
write_marker
|
||||
log "Hotfix installed. Reboot before validating networkd behavior."
|
||||
}
|
||||
|
||||
main() {
|
||||
local command="${1:-}"
|
||||
|
||||
case "$command" in
|
||||
status)
|
||||
status
|
||||
;;
|
||||
should-offer)
|
||||
status >/dev/null
|
||||
;;
|
||||
download)
|
||||
status >/dev/null || fail "Hotfix is not applicable on this host."
|
||||
download_bundle
|
||||
;;
|
||||
packages)
|
||||
print_packages
|
||||
;;
|
||||
install)
|
||||
install_bundle
|
||||
;;
|
||||
urls)
|
||||
print_urls
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,113 @@
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
def install_splynx_stubs():
|
||||
lqlib = types.ModuleType("liblqos_python")
|
||||
lqlib.allowed_subnets = lambda: ["0.0.0.0/0"]
|
||||
lqlib.ignore_subnets = lambda: []
|
||||
lqlib.generated_pn_download_mbps = lambda: 1000
|
||||
lqlib.generated_pn_upload_mbps = lambda: 1000
|
||||
lqlib.circuit_name_use_address = lambda: False
|
||||
lqlib.upstream_bandwidth_capacity_download_mbps = lambda: 1000
|
||||
lqlib.upstream_bandwidth_capacity_upload_mbps = lambda: 1000
|
||||
lqlib.find_ipv6_using_mikrotik = lambda: False
|
||||
lqlib.exclude_sites = lambda: []
|
||||
lqlib.bandwidth_overhead_factor = lambda: 1.0
|
||||
lqlib.committed_bandwidth_multiplier = lambda: 1.0
|
||||
lqlib.exception_cpes = lambda: []
|
||||
lqlib.promote_to_root_list = lambda: []
|
||||
lqlib.client_bandwidth_multiplier = lambda: 1.0
|
||||
lqlib.splynx_api_key = lambda: ""
|
||||
lqlib.splynx_api_secret = lambda: ""
|
||||
lqlib.splynx_api_url = lambda: "http://example.invalid"
|
||||
lqlib.splynx_strategy = lambda: "flat"
|
||||
lqlib.overwrite_network_json_always = False
|
||||
sys.modules["liblqos_python"] = lqlib
|
||||
|
||||
|
||||
install_splynx_stubs()
|
||||
integrationSplynx = importlib.import_module("integrationSplynx")
|
||||
integrationCommon = importlib.import_module("integrationCommon")
|
||||
|
||||
|
||||
class TestIntegrationSplynxStableIds(unittest.TestCase):
|
||||
def test_stable_splynx_device_id_uses_service_id(self):
|
||||
self.assertEqual(
|
||||
integrationSplynx.stable_splynx_device_id(93),
|
||||
"splynx_service_93",
|
||||
)
|
||||
|
||||
def test_stable_splynx_device_id_supports_future_equipment_suffix(self):
|
||||
self.assertEqual(
|
||||
integrationSplynx.stable_splynx_device_id(93, "5045"),
|
||||
"splynx_service_93_equipment_5045",
|
||||
)
|
||||
|
||||
def test_create_client_and_device_uses_stable_device_id(self):
|
||||
net = integrationCommon.NetworkGraph()
|
||||
circuit_id = integrationSplynx.createClientAndDevice(
|
||||
net,
|
||||
serviceItem={
|
||||
"id": 93,
|
||||
"customer_id": 29547,
|
||||
"tariff_id": 32,
|
||||
"mac": "2CC81BBDC9AB, 00E04C687E6A",
|
||||
},
|
||||
cust_id_to_name={29547: "Charles Massey and Alex Soto"},
|
||||
downloadForTariffID={32: 330.0},
|
||||
uploadForTariffID={32: 330.0},
|
||||
parent_node_id="ap_244",
|
||||
ipv4_list=["66.185.224.210", "66.185.224.210/32"],
|
||||
ipv6_list=[],
|
||||
)
|
||||
|
||||
self.assertEqual(circuit_id, 93)
|
||||
self.assertEqual(len(net.nodes), 3)
|
||||
client = net.nodes[1]
|
||||
device = net.nodes[2]
|
||||
self.assertEqual(client.id, 93)
|
||||
self.assertEqual(device.id, "splynx_service_93")
|
||||
self.assertEqual(device.parentId, 93)
|
||||
self.assertEqual(device.ipv4, ["66.185.224.210", "66.185.224.210/32"])
|
||||
|
||||
def test_online_fallback_uses_stable_device_id(self):
|
||||
net = integrationCommon.NetworkGraph()
|
||||
matched = integrationSplynx.create_devices_from_online_for_unhandled_services(
|
||||
net,
|
||||
allServices=[
|
||||
{
|
||||
"id": 93,
|
||||
"customer_id": 29547,
|
||||
"status": "active",
|
||||
"tariff_id": 32,
|
||||
"mac": "2CC81BBDC9AB",
|
||||
}
|
||||
],
|
||||
service_ids_handled=[],
|
||||
customersOnline=[
|
||||
{
|
||||
"service_id": 93,
|
||||
"ipv4": "66.185.224.210",
|
||||
"ipv6": "",
|
||||
}
|
||||
],
|
||||
cust_id_to_name={29547: "Charles Massey and Alex Soto"},
|
||||
downloadForTariffID={32: 330.0},
|
||||
uploadForTariffID={32: 330.0},
|
||||
allocated_ipv4s={},
|
||||
allocated_ipv6s={},
|
||||
parent_selector=lambda service: "ap_244",
|
||||
device_by_service_id={},
|
||||
)
|
||||
|
||||
self.assertEqual(matched, 1)
|
||||
self.assertEqual(len(net.nodes), 3)
|
||||
self.assertEqual(net.nodes[2].id, "splynx_service_93")
|
||||
self.assertEqual(net.nodes[2].ipv4, ["66.185.224.210"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+56
-1
@@ -32,7 +32,7 @@ def install_scheduler_stubs():
|
||||
lqlib.scheduler_error = Mock()
|
||||
lqlib.scheduler_output = Mock()
|
||||
lqlib.overrides_persistent_devices_effective = lambda: []
|
||||
lqlib.overrides_circuit_adjustments = lambda: []
|
||||
lqlib.overrides_circuit_adjustments_effective = lambda: []
|
||||
lqlib.overrides_network_adjustments_effective = lambda: []
|
||||
sys.modules["liblqos_python"] = lqlib
|
||||
|
||||
@@ -228,5 +228,60 @@ class TestSchedulerErrorReporting(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestSchedulerOverrideMerge(unittest.TestCase):
|
||||
def test_merge_rows_replaces_matching_device_id(self):
|
||||
existing = [["93", "Name", "splynx_service_93", "Name", "AP", "MAC", "1.1.1.1", "", "1", "1", "330", "330", "", ""]]
|
||||
override = [["93", "Name", "splynx_service_93", "Name", "AP", "MAC", "1.1.1.1/32", "", "1", "1", "330", "330", "", "fq_codel/fq_codel"]]
|
||||
|
||||
merged, changed = scheduler.merge_rows_replace_by_device_id(existing, override)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(len(merged), 1)
|
||||
self.assertEqual(merged[0][2], "splynx_service_93")
|
||||
self.assertEqual(merged[0][6], "1.1.1.1/32")
|
||||
self.assertEqual(merged[0][13], "fq_codel/fq_codel")
|
||||
|
||||
def test_merge_rows_appends_unmatched_non_splynx_override(self):
|
||||
existing = [["93", "Name", "splynx_service_93", "Name", "AP", "MAC", "1.1.1.1", "", "1", "1", "330", "330", "", ""]]
|
||||
override = [["145", "Other", "legacy_device_1", "Other", "AP", "MAC2", "2.2.2.2", "", "1", "1", "300", "300", "", ""]]
|
||||
|
||||
merged, changed = scheduler.merge_rows_replace_by_device_id(existing, override)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(len(merged), 2)
|
||||
self.assertEqual(merged[1][2], "legacy_device_1")
|
||||
|
||||
def test_apply_lqos_overrides_device_adjust_sqm_only_updates_sqm_column(self):
|
||||
header = [
|
||||
"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", "SQM"
|
||||
]
|
||||
rows = [[
|
||||
"93", "Name", "splynx_service_93", "Name", "AP", "MAC", "1.1.1.1", "",
|
||||
"1", "1", "330", "330", "", ""
|
||||
]]
|
||||
|
||||
with patch.object(scheduler, "shaped_devices_csv_path", return_value="/tmp/ShapedDevices.csv"):
|
||||
with patch.object(scheduler, "read_shaped_devices_csv", return_value=(header, rows)):
|
||||
with patch.object(scheduler, "overrides_persistent_devices_effective", return_value=[]):
|
||||
with patch.object(
|
||||
scheduler,
|
||||
"overrides_circuit_adjustments_effective",
|
||||
return_value=[{
|
||||
"type": "device_adjust_sqm",
|
||||
"device_id": "splynx_service_93",
|
||||
"sqm_override": "fq_codel/fq_codel",
|
||||
}],
|
||||
):
|
||||
with patch.object(scheduler, "write_shaped_devices_csv") as mock_write:
|
||||
scheduler.apply_lqos_overrides()
|
||||
|
||||
written_rows = mock_write.call_args.args[2]
|
||||
self.assertEqual(written_rows[0][10], "330")
|
||||
self.assertEqual(written_rows[0][11], "330")
|
||||
self.assertEqual(written_rows[0][13], "fq_codel/fq_codel")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user