Merge pull request #1066 from LibreQoE/fix_444

Fix 444
This commit is contained in:
Robert Chacón authored and GitHub committed 2026-04-30 07:35:17 -06:00
commit 1c8bba363d
13 files changed
+488 -97

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ Use esta guía para evitar confusión con opciones:
3. Use `full` cuando necesite jerarquía/backhaul completo, tenga margen de CPU y ya haya validado topología/overrides.
4. Use `flat` solo si la jerarquía no es necesaria y prioriza rendimiento máximo.
El ajuste `Root UISP Site` solo es obligatorio en modo de topología `full`. En los modos `flat`, `ap_only` y `ap_site`, puede dejarse vacío; LibreQoS usará los data-links de Internet de UISP para elegir la raíz de importación, o creará una raíz de Internet generada cuando UISP reporte varios sitios conectados a Internet.
## Expectativas en router mode
- Router mode en UISP es compatible, pero depende de que la topología en UISP esté bien definida.
+3
View File
@@ -11,6 +11,8 @@ Use this section first to avoid common topology-mode confusion.
3. Use `full` when you need full hierarchy/backhaul representation and have CPU headroom.
4. Use `flat` only when hierarchy is not needed and maximum performance is the priority.
The `Root UISP Site` setting is required only for `full` topology mode. In `flat`, `ap_only`, and `ap_site` modes, you may leave it blank; LibreQoS will use UISP Internet data-links to pick the import root, or create a generated Internet root when UISP reports multiple Internet-connected sites.
## Router-Mode Expectations
- UISP router mode is supported, but parent/route discovery quality depends on your UISP topology data quality.
@@ -124,6 +126,7 @@ enable_uisp = true
token = "your-api-token-here"
url = "https://uisp.your_domain.com"
site = "Root_Site_Name" # Root site for topology perspective
# Required for full topology mode; optional for flat, ap_only, and ap_site
# Suspension Handling (see table above)
suspended_strategy = "none"
+32 -80
View File
@@ -363,6 +363,32 @@ def _circuit_attachment_name_candidates(circuit):
return candidates
def _normalize_circuit_shaping_parent(circuit, parent_node_ids=None):
parent_name = str(circuit.get('ParentNode', '') or '').strip()
effective_name = str(circuit.get('effectiveParentNodeName', '') or '').strip()
parent_id = str(circuit.get('effectiveParentNodeID', '') or circuit.get('ParentNodeID', '') or '').strip()
resolved_id_parent = ''
if parent_node_ids and parent_id:
resolved_id_parent = str(parent_node_ids.get(parent_id, '') or '').strip()
if not parent_name:
parent_name = effective_name
if (not parent_name or parent_name == 'none') and resolved_id_parent:
parent_name = resolved_id_parent
if not parent_name:
parent_name = 'none'
if is_generated_parent_node_name(parent_name) or (
resolved_id_parent and resolved_id_parent != parent_name
):
parent_id = ''
circuit['shapingParentNode'] = parent_name
circuit['shapingParentNodeID'] = parent_id
circuit['shapingParentKey'] = f"id:{parent_id}" if parent_id else f"name:{parent_name}"
return parent_name, parent_id
def _validate_planned_circuit_attachment(node_name, node_data, circuit, planned_identity):
site_major = _parse_int_token(node_data.get('classMajor'))
site_up_major = _parse_int_token(node_data.get('up_classMajor'))
@@ -664,60 +690,6 @@ def planner_circuit_identity_key(circuit):
return circuit_id
def load_minor_state_from_queuing_structure(path=None):
if path is None:
path = get_queuing_structure_path()
data = _load_json_dict(path)
network = data.get("Network")
if not isinstance(network, dict):
return {"sites": {}, "circuits": {}}
sites = {}
circuits = {}
def walk(node_map, trail=()):
for node_name, node in sorted(node_map.items()):
if not isinstance(node, dict):
continue
node_path = trail + (node_name,)
site_key = "/".join(node_path)
parent_path = "/".join(trail)
queue = _parse_int_token(node.get("cpuNum"))
class_minor = _parse_int_token(node.get("classMinor"))
class_major = _parse_int_token(node.get("classMajor"))
up_class_major = _parse_int_token(node.get("up_classMajor"))
if queue is not None and class_minor is not None:
sites[site_key] = {
"class_minor": class_minor,
"queue": queue + 1,
"parent_path": parent_path,
"class_major": class_major,
"up_class_major": up_class_major,
}
if isinstance(node.get("circuits"), list):
for circuit in node.get("circuits", []):
if not isinstance(circuit, dict):
continue
circuit_id = circuit.get("circuitID")
circuit_minor = _parse_int_token(circuit.get("classMinor"))
if queue is None or circuit_id is None or circuit_minor is None:
continue
circuits[str(circuit_id)] = {
"class_minor": circuit_minor,
"queue": queue + 1,
"parent_node": circuit.get("ParentNode", node_name),
"class_major": _parse_int_token(circuit.get("classMajor")),
"up_class_major": _parse_int_token(circuit.get("up_classMajor")),
}
children = node.get("children")
if isinstance(children, dict):
walk(children, node_path)
walk(network)
return {"sites": sites, "circuits": circuits}
def calculateR2q(maxRateInMbps):
# So we've learned that r2q defaults to 10, and is used to calculate quantum. Quantum is rateInBytes/r2q by
# default. This default gives errors at high rates, and tc clamps the quantum to 200000. Setting a high quantum
@@ -1807,11 +1779,10 @@ def refreshShapers():
circuits_by_parent_id = {}
circuits_by_parent_name = {}
for circuit in subscriberCircuits:
parent_id = str(circuit.get('effectiveParentNodeID', '') or circuit.get('ParentNodeID', '') or '').strip()
parent_name, parent_id = _normalize_circuit_shaping_parent(circuit, parent_node_ids)
if parent_id:
circuits_by_parent_id.setdefault(parent_id, []).append(circuit)
for parent_name in _circuit_attachment_name_candidates(circuit):
elif parent_name and parent_name != 'none':
circuits_by_parent_name.setdefault(parent_name, []).append(circuit)
# Parse network structure and add devices from ShapedDevices.csv
@@ -2029,9 +2000,8 @@ def refreshShapers():
f"Failed to save planner state at {state_path}: {e}", stacklevel=2
)
# Seed persisted site/circuit minor assignments. When planner state is absent,
# fall back to the previous queuing structure so the first run after an upgrade
# can preserve existing class IDs.
# Load persisted top-level planner state. Full reloads rebuild TC, so
# site/circuit class minors are runtime artifacts and are not reused below.
try:
state # noqa: B018
except NameError:
@@ -2039,24 +2009,6 @@ def refreshShapers():
state_path = get_planner_state_path()
if not isinstance(state, dict) or len(state.keys()) == 0:
state = load_planner_state(state_path, None)
try:
circuit_state_from_disk = state.get("circuits", {}) if isinstance(state, dict) else {}
except Exception:
circuit_state_from_disk = {}
try:
site_state_from_disk = state.get("sites", {}) if isinstance(state, dict) else {}
except Exception:
site_state_from_disk = {}
if not isinstance(circuit_state_from_disk, dict):
circuit_state_from_disk = {}
if not isinstance(site_state_from_disk, dict):
site_state_from_disk = {}
if not circuit_state_from_disk or not site_state_from_disk:
fallback_minor_state = load_minor_state_from_queuing_structure()
if not site_state_from_disk:
site_state_from_disk = fallback_minor_state.get("sites", {}) or {}
if not circuit_state_from_disk:
circuit_state_from_disk = fallback_minor_state.get("circuits", {}) or {}
circuit_state_updated = {}
site_state_updated = {}
planner_site_inputs = []
@@ -2126,8 +2078,8 @@ def refreshShapers():
identity_plan = plan_class_identities(
planner_site_inputs,
planner_circuit_groups,
site_state=site_state_from_disk,
circuit_state=circuit_state_from_disk,
site_state={},
circuit_state={},
stick_offset=stickOffset,
circuit_padding=CIRCUIT_PADDING,
)
+91
View File
@@ -834,6 +834,97 @@ mod tests {
assert_ne!(result.sites[0].class_minor, 0x29);
}
#[test]
fn class_identity_planner_compacts_when_full_reload_ignores_saved_minors() {
const FRAGMENTED_SITE_MINOR: u16 = 0xff00;
const FRAGMENTED_CHILD_MINOR: u16 = 0xff02;
const FRAGMENTED_CIRCUIT_MINOR: u16 = 0xff10;
const CIRCUIT_PADDING: u32 = 8;
let site_inputs = vec![
SiteIdentityInput {
site_key: "CpueQueue0/site-a".to_string(),
parent_path: "CpueQueue0".to_string(),
queue: 1,
has_children: true,
},
SiteIdentityInput {
site_key: "CpueQueue0/site-a/child".to_string(),
parent_path: "CpueQueue0/site-a".to_string(),
queue: 1,
has_children: false,
},
];
let circuit_groups = vec![CircuitIdentityGroupInput {
parent_node: "child".to_string(),
queue: 1,
circuit_ids: vec!["c1".to_string(), "c2".to_string()],
}];
let fragmented_sites = BTreeMap::from([
(
"CpueQueue0/site-a".to_string(),
PlannerSiteIdentityState {
class_minor: FRAGMENTED_SITE_MINOR,
queue: 1,
parent_path: "CpueQueue0".to_string(),
class_major: 1,
up_class_major: 0x41,
},
),
(
"CpueQueue0/site-a/child".to_string(),
PlannerSiteIdentityState {
class_minor: FRAGMENTED_CHILD_MINOR,
queue: 1,
parent_path: "CpueQueue0/site-a".to_string(),
class_major: 1,
up_class_major: 0x41,
},
),
]);
let fragmented_circuits = BTreeMap::from([(
"c1".to_string(),
PlannerCircuitIdentityState {
class_minor: FRAGMENTED_CIRCUIT_MINOR,
queue: 1,
parent_node: "child".to_string(),
class_major: 1,
up_class_major: 0x41,
},
)]);
let sticky_result = plan_class_identities(
&site_inputs,
&circuit_groups,
&fragmented_sites,
&fragmented_circuits,
0x40,
CIRCUIT_PADDING,
);
assert!(sticky_result.last_used_minor_by_queue[&1] > u32::from(FRAGMENTED_CIRCUIT_MINOR));
let full_reload_result = plan_class_identities(
&site_inputs,
&circuit_groups,
&BTreeMap::new(),
&BTreeMap::new(),
0x40,
CIRCUIT_PADDING,
);
let expected_last_minor = u32::from(full_reload_result.circuits[1].class_minor)
.saturating_add(1)
.saturating_add(CIRCUIT_PADDING);
assert_eq!(full_reload_result.sites[0].class_minor, 3);
assert_eq!(full_reload_result.sites[1].class_minor, 5);
assert_eq!(full_reload_result.circuits[0].class_minor, 6);
assert_eq!(full_reload_result.circuits[1].class_minor, 7);
assert_eq!(
full_reload_result.last_used_minor_by_queue[&1],
expected_last_minor
);
}
#[test]
fn class_identity_planner_runtime_constraints_prefer_runtime_band() {
let site_inputs = vec![SiteIdentityInput {
@@ -6,6 +6,30 @@ import {
secretWillExistAfterSave,
} from "./config/config_helper";
function currentTopologyMode() {
return (
window.config?.topology?.compile_mode
?? document.getElementById("uispStrategy").value.trim()
);
}
function rootSiteRequiredForMode(mode) {
return mode === "full" || mode === "full2";
}
function updateRootSiteRequirement() {
const required = rootSiteRequiredForMode(currentTopologyMode());
const siteInput = document.getElementById("uispSite");
const siteLabel = document.getElementById("uispSiteLabel");
const siteHelp = document.getElementById("uispSiteHelp");
siteInput.required = required;
siteLabel.textContent = required ? "Root UISP Site" : "Root UISP Site (Optional)";
siteHelp.textContent = required
? "Required for full topology mode. Enter the exact UISP site name at the root of the network."
: "Optional in this topology mode. Leave blank to let LibreQoS choose the import root.";
}
function validateConfig() {
// Validate required fields when enabled
if (document.getElementById("enableUisp").checked) {
@@ -27,8 +51,8 @@ function validateConfig() {
}
const site = document.getElementById("uispSite").value.trim();
if (!site) {
alert("UISP Site is required when UISP integration is enabled");
if (rootSiteRequiredForMode(currentTopologyMode()) && !site) {
alert("Root UISP Site is required when full topology mode is selected");
return false;
}
@@ -148,6 +172,7 @@ loadConfig(() => {
document.getElementById("uispSite").value = uisp.site ?? "";
document.getElementById("uispStrategy").value = window.config.topology?.compile_mode ?? uisp.strategy ?? "ap_site";
document.getElementById("uispSuspendedStrategy").value = uisp.suspended_strategy ?? "none";
updateRootSiteRequirement();
// Numeric fields
document.getElementById("uispAirmaxCapacity").value = uisp.airmax_capacity ?? 1.0;
@@ -170,6 +195,8 @@ loadConfig(() => {
clearButtonId: "clearUispToken",
});
document.getElementById("uispStrategy").addEventListener("change", updateRootSiteRequirement);
// Add save button click handler
document.getElementById('saveButton').addEventListener('click', () => {
if (validateConfig()) {
@@ -3,6 +3,7 @@ import { Encoder, decode } from "../lq_js_common/helpers/cbor-x";
const ACK_TEXT = "I accept that this is an unstable, internal API and is unsupported";
const EXPECTED_UI_VERSION = (window.LQOS_UI_VERSION || "").trim() || null;
const USER_TOKEN_COOKIE = "User-Token";
const VERSION_RELOAD_KEY = "lqosWsVersionReload";
const encoder = new Encoder({ useRecords: false, variableMapSize: true });
const DIAGNOSTIC_CHANNELS = new Set(["Cpu", "Ram", "RttHistogram"]);
@@ -78,6 +79,28 @@ function get_user_token() {
return get_cookie_value(USER_TOKEN_COOKIE);
}
function versionReloadKey(serverVersion) {
const clientVersion = EXPECTED_UI_VERSION || "missing";
const wsVersion = (serverVersion || "missing").toString().trim() || "missing";
return `${clientVersion}->${wsVersion}`;
}
function hasReloadedForVersionMismatch(mismatchKey) {
try {
if (window.sessionStorage.getItem(VERSION_RELOAD_KEY) === mismatchKey) {
return true;
}
window.sessionStorage.setItem(VERSION_RELOAD_KEY, mismatchKey);
return false;
} catch (_) {
if (window.__lqosWsVersionReload === mismatchKey) {
return true;
}
window.__lqosWsVersionReload = mismatchKey;
return false;
}
}
export function ws_proto() {
if (window.location.protocol.startsWith("https")) {
return "wss://";
@@ -95,6 +118,7 @@ export class WsClient {
this.reconnectTimer = null;
this.reconnectDelayMs = 1000;
this.manualClose = false;
this.versionReloading = false;
}
connect() {
@@ -159,7 +183,9 @@ export class WsClient {
});
this.ws = null;
this.handshake_done = false;
this._scheduleReconnect();
if (!this.versionReloading) {
this._scheduleReconnect();
}
};
socket.onerror = () => {
@@ -171,7 +197,9 @@ export class WsClient {
});
this.ws = null;
this.handshake_done = false;
this._scheduleReconnect();
if (!this.versionReloading) {
this._scheduleReconnect();
}
};
}
@@ -194,6 +222,54 @@ export class WsClient {
this.handlers.clear();
}
_dropSocket(reason = "unspecified") {
dashboardWsDebug("drop-socket", {
reason,
desiredChannels: Array.from(this.desiredChannels.keys()),
});
const oldSocket = this.ws;
this.ws = null;
this.handshake_done = false;
if (oldSocket) {
try {
oldSocket.close();
} catch (_) {
// Ignore close errors; reconnect/reload handling continues below.
}
}
}
_reloadForVersionMismatch(serverVersion) {
const mismatchKey = versionReloadKey(serverVersion);
dashboardWsDebug("version-mismatch", {
expected: EXPECTED_UI_VERSION || "missing",
actual: serverVersion || "missing",
mismatchKey,
});
if (!hasReloadedForVersionMismatch(mismatchKey)) {
this.versionReloading = true;
this._dropSocket("version-mismatch");
window.location.reload();
window.setTimeout(() => {
if (this.versionReloading) {
this.versionReloading = false;
this._scheduleReconnect();
}
}, 3000);
return true;
}
console.error(
"Websocket version mismatch persisted after reload:",
`client=${EXPECTED_UI_VERSION || "missing"}`,
`server=${serverVersion || "missing"}`,
);
this._dropSocket("version-mismatch-persistent");
this._scheduleReconnect();
return true;
}
refreshSubscriptions(reason = "unspecified") {
dashboardWsDebug("refresh-subscriptions", {
reason,
@@ -318,9 +394,14 @@ export class WsClient {
"Websocket version mismatch:",
hello ? hello.version : "missing",
);
this.close();
this._reloadForVersionMismatch(hello ? hello.version : null);
return;
}
try {
window.sessionStorage.removeItem(VERSION_RELOAD_KEY);
} catch (_) {
// Storage cleanup is best-effort.
}
this.handshake_done = true;
dashboardWsDebug("handshake-complete", {
desiredChannels: Array.from(this.desiredChannels.keys()),
@@ -39,9 +39,9 @@
</div>
<div class="mb-3">
<label for="uispSite" class="form-label">UISP Site</label>
<label for="uispSite" class="form-label" id="uispSiteLabel">Root UISP Site</label>
<input type="text" class="form-control" id="uispSite">
<div class="form-text">Optional site identifier in UISP.</div>
<div class="form-text" id="uispSiteHelp">Required only for full topology mode. Leave blank in other modes to let LibreQoS choose the import root.</div>
</div>
<div class="mb-3 form-check">
+2 -2
View File
@@ -11,8 +11,8 @@ pub enum UispIntegrationError {
UnknownIntegrationStrategy,
#[error("Error contacting UISP")]
UispConnectError,
#[error("Root site not found")]
NoRootSite,
#[error("Root site not found: {0}")]
NoRootSite(String),
#[error("Unknown Site Type")]
UnknownSiteType,
#[error("CSV Error")]
@@ -23,7 +23,7 @@ pub fn find_root_site(
if !sites.iter().any(|s| s.name == root_site_name) {
error!("Site {root_site_name} (from /etc/lqos.conf) not found in the UISP sites list");
return Err(UispIntegrationError::NoRootSite);
return Err(UispIntegrationError::NoRootSite(root_site_name));
} else {
info!("{root_site_name} found in the sites list.");
}
@@ -45,7 +45,9 @@ fn handle_multiple_internet_connected_sites(
if candidates.is_empty() {
error!("Unable to find a root site in the sites/data-links.");
return Err(UispIntegrationError::NoRootSite);
return Err(UispIntegrationError::NoRootSite(
"no Internet-connected UISP sites were found".to_string(),
));
} else if candidates.len() == 1 {
info!(
"Found only one site with an Internet connection: {root_site_name}, using it as root"
@@ -81,7 +83,9 @@ pub fn set_root_site(sites: &mut [UispSite], root_site: &str) -> Result<(), Uisp
.count();
if number_of_roots > 1 {
error!("More than one root present in the tree! That's not going to work. Bailing.");
return Err(UispIntegrationError::NoRootSite);
return Err(UispIntegrationError::NoRootSite(
"more than one root site was tagged".to_string(),
));
} else {
info!("Single root tagged in the tree");
}
@@ -123,7 +127,10 @@ mod test {
let data_links = vec![];
let result = find_root_site(&cfg, &mut sites, &data_links);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), UispIntegrationError::NoRootSite);
assert_eq!(
result.unwrap_err(),
UispIntegrationError::NoRootSite("TEST".to_string())
);
}
#[test]
@@ -22,7 +22,7 @@ pub fn walk_tree_for_routing(
// Make sure we know where the root is
let Some(root_idx) = sites.iter().position(|s| s.name == root_site) else {
tracing::error!("Unable to build a path-weights graph because I can't find the root node");
return Err(UispIntegrationError::NoRootSite);
return Err(UispIntegrationError::NoRootSite(root_site.to_string()));
};
// Now we iterate through every node that ISN'T the root
@@ -40,6 +40,8 @@ use std::time::Instant;
use tracing::{debug, info, warn};
type GraphType = petgraph::Graph<GraphMapping, LinkMapping, Directed>;
const GENERATED_INTERNET_ROOT_NAME: &str = "INSERTED_INTERNET";
const GENERATED_INTERNET_ROOT_ID: &str = "ROOT-001";
#[derive(Clone, Debug)]
struct LegacyShapedDevice {
@@ -152,9 +154,145 @@ fn anchor_for_site_ap(
})
}
#[derive(Debug, PartialEq, Eq)]
enum ResolvedRootSite {
Existing(String),
GeneratedInternet,
}
impl ResolvedRootSite {
fn name(&self) -> String {
match self {
ResolvedRootSite::Existing(name) => name.clone(),
ResolvedRootSite::GeneratedInternet => GENERATED_INTERNET_ROOT_NAME.to_string(),
}
}
}
fn generated_internet_root() -> GraphMapping {
GraphMapping::Root {
name: GENERATED_INTERNET_ROOT_NAME.to_string(),
id: GENERATED_INTERNET_ROOT_ID.to_string(),
latitude: None,
longitude: None,
}
}
fn resolve_root_site_from_names(
configured_site: &str,
site_names: &[String],
internet_site_candidates: &[String],
require_configured_root_site: bool,
) -> Result<ResolvedRootSite, UispIntegrationError> {
let configured_site = configured_site.trim();
if !configured_site.is_empty() {
if site_names.iter().any(|site_name| site_name == configured_site) {
info!("Using root UISP site from /etc/lqos.conf: {configured_site}");
return Ok(ResolvedRootSite::Existing(configured_site.to_string()));
}
let mut sorted_site_names = site_names.to_vec();
sorted_site_names.sort_unstable();
let sample_sites = sorted_site_names
.iter()
.take(10)
.cloned()
.collect::<Vec<_>>()
.join(", ");
if require_configured_root_site {
tracing::error!(
"Configured UISP root site '{configured_site}' was not found. Available non-client site sample: {sample_sites}"
);
return Err(UispIntegrationError::NoRootSite(configured_site.to_string()));
}
warn!(
"Configured UISP root site '{configured_site}' was not found. Non-full topology mode will auto-detect a root instead."
);
} else {
warn!("Root UISP site is blank; attempting to auto-detect an import root.");
}
let mut candidates = internet_site_candidates
.iter()
.filter(|candidate| site_names.iter().any(|site_name| site_name == *candidate))
.cloned()
.collect::<Vec<_>>();
candidates.sort_unstable();
candidates.dedup();
match candidates.len() {
0 => {
tracing::error!("Unable to auto-detect a UISP root site from Internet data-links.");
Err(UispIntegrationError::NoRootSite(
"no Internet-connected UISP sites were found".to_string(),
))
}
1 => {
info!(
"Auto-detected root UISP site from Internet data-links: {}",
candidates[0]
);
Ok(ResolvedRootSite::Existing(candidates.remove(0)))
}
_ => {
warn!(
"Multiple Internet-connected UISP sites detected. Creating a generated Internet root."
);
Ok(ResolvedRootSite::GeneratedInternet)
}
}
}
fn resolve_root_site(
config: &Config,
uisp_data: &UispData,
require_configured_root_site: bool,
) -> Result<ResolvedRootSite, UispIntegrationError> {
let mut site_names = uisp_data
.sites_raw
.iter()
.filter(|site| !site.is_client_site())
.map(|site| site.name_or_blank())
.collect::<Vec<_>>();
site_names.sort_unstable();
site_names.dedup();
let mut internet_site_ids = uisp_data
.data_links_raw
.iter()
.filter(|link| !link.can_delete)
.filter_map(|link| {
link.from
.site
.as_ref()
.map(|site| site.identification.id.clone())
})
.collect::<Vec<_>>();
internet_site_ids.sort_unstable();
internet_site_ids.dedup();
let mut internet_site_candidates = uisp_data
.sites_raw
.iter()
.filter(|site| internet_site_ids.iter().any(|site_id| site_id == &site.id))
.map(|site| site.name_or_blank())
.collect::<Vec<_>>();
internet_site_candidates.sort_unstable();
internet_site_candidates.dedup();
resolve_root_site_from_names(
&config.uisp_integration.site,
&site_names,
&internet_site_candidates,
require_configured_root_site,
)
}
pub async fn build_imported_full2_bundle(
config: Arc<Config>,
ip_ranges: IpRanges,
require_configured_root_site: bool,
) -> Result<ImportedTopologyBundle, UispIntegrationError> {
let build_started = Instant::now();
let ethernet_policy = EthernetPortLimitPolicy::from(&config.integration_common);
@@ -170,7 +308,9 @@ pub async fn build_imported_full2_bundle(
let mut graph = GraphType::new();
// Find the root
let root_site_name = config.uisp_integration.site.clone();
let resolved_root_site =
resolve_root_site(&config, &uisp_data, require_configured_root_site)?;
let root_site_name = resolved_root_site.name();
// Add all sites to the graph
let graph_build_started = Instant::now();
@@ -183,7 +323,14 @@ pub async fn build_imported_full2_bundle(
&mut site_map,
&mut root_idx,
);
let root_idx = root_idx.expect("Root site not found");
let root_idx = match (root_idx, resolved_root_site) {
(Some(root_idx), _) => root_idx,
(None, ResolvedRootSite::GeneratedInternet) => graph.add_node(generated_internet_root()),
(None, ResolvedRootSite::Existing(root_site_name)) => {
tracing::error!("Root UISP site '{root_site_name}' was not added to the graph.");
return Err(UispIntegrationError::NoRootSite(root_site_name));
}
};
let site_graph_elapsed_ms = graph_build_started.elapsed().as_millis();
// Iterate all UISP devices and if their parent site is in the graph, add them
@@ -3090,6 +3237,50 @@ mod tests {
.expect("expected node to exist")
}
#[test]
fn root_resolution_accepts_configured_site() {
let sites = vec!["Main".to_string(), "Backup".to_string()];
let internet_candidates = vec!["Backup".to_string()];
let root =
resolve_root_site_from_names("Main", &sites, &internet_candidates, true).unwrap();
assert_eq!(root, ResolvedRootSite::Existing("Main".to_string()));
}
#[test]
fn root_resolution_rejects_bad_configured_site_for_full_mode() {
let sites = vec!["Main".to_string(), "Backup".to_string()];
let internet_candidates = vec!["Backup".to_string()];
let error =
resolve_root_site_from_names("Old Root", &sites, &internet_candidates, true)
.unwrap_err();
assert_eq!(error, UispIntegrationError::NoRootSite("Old Root".to_string()));
}
#[test]
fn root_resolution_auto_detects_for_non_full_mode() {
let sites = vec!["Main".to_string(), "Backup".to_string()];
let internet_candidates = vec!["Backup".to_string()];
let root =
resolve_root_site_from_names("Old Root", &sites, &internet_candidates, false).unwrap();
assert_eq!(root, ResolvedRootSite::Existing("Backup".to_string()));
}
#[test]
fn root_resolution_uses_generated_root_for_multiple_internet_sites() {
let sites = vec!["Main".to_string(), "Backup".to_string()];
let internet_candidates = vec!["Main".to_string(), "Backup".to_string()];
let root = resolve_root_site_from_names("", &sites, &internet_candidates, false).unwrap();
assert_eq!(root, ResolvedRootSite::GeneratedInternet);
}
#[test]
fn squash_candidate_collection_is_deterministic() {
let config = Arc::new(Config::default());
@@ -79,7 +79,11 @@ pub async fn build_with_strategy(
blackboard(BlackboardSystem::System, "UISP", strategy.as_str()).await;
let mode = compile_mode_for_strategy(&strategy)?;
info!("Strategy selected: {strategy}");
let imported = full2::build_imported_full2_bundle(config.clone(), ip_ranges).await?;
let require_configured_root_site =
matches!(mode, TopologyCompileMode::Full | TopologyCompileMode::Full2);
let imported =
full2::build_imported_full2_bundle(config.clone(), ip_ranges, require_configured_root_site)
.await?;
let topology_import = TopologyImportFile::from_imported_bundle(&imported, strategy.clone());
let compiled = compile_topology(imported, mode).map_err(|e| {
error!("Unable to compile UISP topology for mode '{strategy}': {e:?}");
+33
View File
@@ -111,6 +111,39 @@ class TestLibreQoSShapingInputs(unittest.TestCase):
)
self.assertEqual(candidates, ["Generated_PN_2"])
def test_canonical_shaping_parent_ignores_logical_anchor_for_generated_parent(self):
circuit = {
"ParentNode": "Generated_PN_1",
"effectiveParentNodeName": "Generated_PN_1",
"ParentNodeID": "uisp:device:glenn-s1",
"effectiveParentNodeID": "uisp:device:glenn-s1",
"logicalParentNode": "glenn-s1.streamitnet.com",
"parentResolutionSource": "topology_anchor",
}
parent_name, parent_id = LibreQoS._normalize_circuit_shaping_parent(circuit)
self.assertEqual(parent_name, "Generated_PN_1")
self.assertEqual(parent_id, "")
self.assertEqual(circuit["shapingParentNode"], "Generated_PN_1")
self.assertEqual(circuit["shapingParentNodeID"], "")
self.assertEqual(circuit["shapingParentKey"], "name:Generated_PN_1")
def test_canonical_shaping_parent_drops_mismatched_parent_id(self):
circuit = {
"ParentNode": "Physical_AP",
"ParentNodeID": "uisp:device:logical-ap",
}
parent_name, parent_id = LibreQoS._normalize_circuit_shaping_parent(
circuit,
{"uisp:device:logical-ap": "Logical_AP"},
)
self.assertEqual(parent_name, "Physical_AP")
self.assertEqual(parent_id, "")
self.assertEqual(circuit["shapingParentKey"], "name:Physical_AP")
def test_validate_planned_circuit_attachment_rejects_major_mismatch(self):
with self.assertRaisesRegex(ValueError, "Planned circuit class majors do not match"):
LibreQoS._validate_planned_circuit_attachment(