Merge pull request #1042 from LibreQoE/bridge_mgmt

changes
This commit is contained in:
Robert Chacón authored and GitHub committed 2026-04-12 18:23:56 -06:00
commit 2a32abd52a
8 files changed
+540 -40

No files matched your search

+1
View File
@@ -46,6 +46,7 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta
- En Move Preview, la ascendencia profunda hacia la izquierda ahora se compacta en un stub después de los dos nodos upstream más cercanos, para que las cadenas largas no aplasten el lado izquierdo del mapa. El breadcrumb completo sigue visible en el resumen de jerarquía superior.
- Cuando UISP aporta adjuntos/radios explícitos, los destinos automáticos de sondeo de topología salen de las IPs de gestión que UISP reporta para ese par. Estas IPs de sondeo ya no están limitadas por `allow_subnets` de shaping; se tratan como datos del plano de gestión y no como direcciones de clientes para shaping.
- La sección `Attachment Health` también puede guardar overrides de tasa por adjunto cuando ese adjunto es editable. Estos overrides son direccionales (`download` / `upload`), viven en `topology_overrides.json` y solo afectan la ruta concreta `(nodo hijo, nodo padre, adjunto)`, no todo el nodo.
- En modo de adjunto `Auto`, Topology Manager usa los probes para suprimir enlaces conocidos como malos, pero no descalifica un enlace solo porque el probe esté deshabilitado o no disponible. Cuando quedan varios enlaces elegibles, `Auto` prioriza las tasas respaldadas por telemetría de integración antes que las tasas estáticas de respaldo y luego resuelve empates por capacidad.
- Las filas de adjuntos UISP también clasifican el rol de alimentación, como `PtP Backhaul`, `PtMP Uplink` o `Wired Uplink`, para que el operador distinga un backhaul real de una ruta de acceso/uplink sin deducirlo por el nombre del AP.
- El archivo único de depuración runtime para probes de topología es `topology_attachment_health_state.json`. Ahora incluye el `pair_id` del probe más el nombre/ID del adjunto, los nombres/IDs de nodo hijo y padre, las IPs local/remota configuradas para probe, el estado `enabled`/`probeable`, la alcanzabilidad reciente por endpoint y los contadores/razones actuales de health o suppress. El panel Details enlaza directamente a esta depuración desde `Attachment Health`.
- Los adjuntos UISP cuya capacidad viene de telemetría dinámica de radio siguen en solo lectura para este editor. Los adjuntos estáticos, los casos black-box/fallback y los grupos manuales sí pueden mostrar controles de `Attachment Rate`.
+1
View File
@@ -87,6 +87,7 @@ Practical meaning:
- Main operator-facing hierarchy chips now prefer display names only. If a label cannot be resolved, the page shows a neutral placeholder instead of a raw internal node ID.
- Attachment or radio preference edits for the current logical parent do not require move mode; when multiple explicit attachments are available, inspect mode exposes a lighter `Edit Attachment Preference` path in Details.
- Attachment rows now surface runtime health directly in the page, including `Healthy`, `Suppressed`, `Probe Unavailable`, or `Probe Disabled` state, plus reason text, suppression hold-down timestamps, and per-pair probe enable/disable actions.
- In `Auto` attachment mode, Topology Manager uses probe results to suppress known-bad links, but it does not disqualify a link only because probing is disabled or unavailable. When multiple eligible links remain, Auto prefers integration-telemetry-backed attachment rates ahead of static fallback rates and then breaks ties by capacity.
- UISP attachment rows now also classify the feed role, such as `PtP Backhaul`, `PtMP Uplink`, or `Wired Uplink`, so operators can tell a real backhaul path from an access/uplink path without inferring it from raw AP names.
- The single runtime debug file for topology probes is `topology_attachment_health_state.json`. It now carries the probe pair ID plus attachment name/ID, child and parent node names/IDs, configured local/remote probe IPs, enable/probeable state, recent endpoint reachability, and current suppression/health counters. The Details panel links directly to probe debugging from `Attachment Health`.
- Attachment Health now also exposes attachment-scoped rate overrides for editable attachments. These overrides are directional (`download` / `upload`), are stored in `topology_overrides.json`, and apply only to the selected `(child node, parent node, attachment)` path instead of behaving like a node-wide `AdjustSiteSpeed`.
+2 -1
View File
@@ -93,7 +93,8 @@ pub use topology_runtime_state::{
TopologyAttachmentHealthStateFile, TopologyEffectiveAttachmentState,
TopologyEffectiveNodeState, TopologyEffectiveStateFile, TopologyRuntimeStateError,
TopologyRuntimeStatusFile, TopologyShapingCircuitInput, TopologyShapingDeviceInput,
TopologyShapingInputsFile, TopologyShapingResolutionSource, compute_topology_source_generation,
TopologyShapingInputsFile, TopologyShapingResolutionSource,
compute_effective_network_generation, compute_topology_source_generation,
topology_attachment_health_state_path, topology_compiled_shaping_path,
topology_effective_network_path, topology_effective_state_path, topology_import_path,
topology_runtime_status_path, topology_shaping_inputs_path,
@@ -4,6 +4,7 @@ use crate::{
TopologyEditorStateFile,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::Write;
@@ -332,6 +333,9 @@ pub struct TopologyRuntimeStatusFile {
/// Stable generation hash of the current shaping-relevant runtime output, when ready.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub shaping_generation: String,
/// Stable generation hash of the effective network export, when ready.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub effective_generation: String,
/// Whether runtime outputs are ready for the source generation above.
#[serde(default)]
pub ready: bool,
@@ -667,6 +671,40 @@ impl TopologyShapingInputsFile {
}
}
fn normalized_json_value_for_generation(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut entries = map.iter().collect::<Vec<_>>();
entries.sort_unstable_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key));
let mut normalized = serde_json::Map::with_capacity(entries.len());
for (key, child) in entries {
normalized.insert(key.clone(), normalized_json_value_for_generation(child));
}
Value::Object(normalized)
}
Value::Array(items) => Value::Array(
items
.iter()
.map(normalized_json_value_for_generation)
.collect(),
),
_ => value.clone(),
}
}
/// Computes the stable effective-network generation for one runtime export payload.
pub fn compute_effective_network_generation(
effective_network: &Value,
) -> Result<String, TopologyRuntimeStateError> {
let normalized = normalized_json_value_for_generation(effective_network);
let payload = serde_json::to_vec(&normalized)?;
let mut hasher = Sha256::new();
hasher.update(b"topology-runtime-effective-generation");
hasher.update([0xff]);
hasher.update(payload);
Ok(format!("{:x}", hasher.finalize()))
}
impl TopologyRuntimeStatusFile {
/// Loads the topology runtime status file if it exists.
pub fn load(config: &Config) -> Result<Self, TopologyRuntimeStateError> {
@@ -693,7 +731,8 @@ mod tests {
CIRCUIT_ANCHORS_FILENAME, Config, TOPOLOGY_CANONICAL_STATE_FILENAME,
TOPOLOGY_COMPILED_SHAPING_FILENAME, TOPOLOGY_EDITOR_STATE_FILENAME,
TOPOLOGY_IMPORT_FILENAME, TOPOLOGY_RUNTIME_STATUS_FILENAME, TopologyRuntimeStatusFile,
TopologyShapingCircuitInput, TopologyShapingInputsFile, compute_topology_source_generation,
TopologyShapingCircuitInput, TopologyShapingInputsFile,
compute_effective_network_generation, compute_topology_source_generation,
topology_runtime_status_path,
};
use crate::{
@@ -1198,6 +1237,7 @@ mod tests {
schema_version: 1,
source_generation: "generation-1".to_string(),
shaping_generation: "shape-1".to_string(),
effective_generation: "effective-1".to_string(),
ready: true,
generated_unix: Some(123),
effective_state_path: "/tmp/effective.json".to_string(),
@@ -1215,7 +1255,10 @@ mod tests {
assert_eq!(loaded, status);
assert_eq!(
topology_runtime_status_path(&config),
lqos_directory.join(TOPOLOGY_RUNTIME_STATUS_FILENAME)
lqos_directory
.join("state")
.join("topology")
.join(TOPOLOGY_RUNTIME_STATUS_FILENAME)
);
}
@@ -1252,4 +1295,79 @@ mod tests {
.expect("generation should recompute")
);
}
#[test]
fn effective_generation_changes_when_effective_rates_change() {
let first = serde_json::json!({
"Root": {
"downloadBandwidthMbps": 1000,
"uploadBandwidthMbps": 1000,
"children": {
"AP A": {
"id": "ap-a",
"downloadBandwidthMbps": 204,
"uploadBandwidthMbps": 58,
"children": {}
}
}
}
});
let second = serde_json::json!({
"Root": {
"downloadBandwidthMbps": 1000,
"uploadBandwidthMbps": 1000,
"children": {
"AP A": {
"id": "ap-a",
"downloadBandwidthMbps": 218,
"uploadBandwidthMbps": 57,
"children": {}
}
}
}
});
let first_generation =
compute_effective_network_generation(&first).expect("generation should compute");
let second_generation =
compute_effective_network_generation(&second).expect("generation should compute");
assert_ne!(first_generation, second_generation);
}
#[test]
fn effective_generation_ignores_object_key_order_only_changes() {
let first = serde_json::json!({
"Root": {
"downloadBandwidthMbps": 1000,
"uploadBandwidthMbps": 1000,
"children": {
"AP A": {
"id": "ap-a",
"downloadBandwidthMbps": 204,
"uploadBandwidthMbps": 58,
"children": {}
}
}
}
});
let second = serde_json::json!({
"Root": {
"children": {
"AP A": {
"children": {},
"uploadBandwidthMbps": 58,
"downloadBandwidthMbps": 204,
"id": "ap-a"
}
},
"uploadBandwidthMbps": 1000,
"downloadBandwidthMbps": 1000
}
});
assert_eq!(
compute_effective_network_generation(&first).expect("generation should compute"),
compute_effective_network_generation(&second).expect("generation should compute"),
);
}
}
+203 -14
View File
@@ -3146,8 +3146,13 @@ fn first_existing_path<'a>(paths: &'a [&'a Path]) -> Option<&'a Path> {
paths.iter().copied().find(|path| path.exists())
}
fn state_or_legacy_path(base_path: &Path, category: &str, filename: &str) -> PathBuf {
let preferred = base_path.join("state").join(category).join(filename);
fn state_or_legacy_path(
base_path: &Path,
state_base_path: &Path,
category: &str,
filename: &str,
) -> PathBuf {
let preferred = state_base_path.join(category).join(filename);
if preferred.exists() {
preferred
} else {
@@ -3155,8 +3160,13 @@ fn state_or_legacy_path(base_path: &Path, category: &str, filename: &str) -> Pat
}
}
fn topology_runtime_shaping_generation(base_path: &Path) -> Option<String> {
let status_path = state_or_legacy_path(base_path, "topology", "topology_runtime_status.json");
fn topology_runtime_shaping_generation(base_path: &Path, state_base_path: &Path) -> Option<String> {
let status_path = state_or_legacy_path(
base_path,
state_base_path,
"topology",
"topology_runtime_status.json",
);
if let Ok(status_raw) = read_to_string(&status_path)
&& let Ok(status) =
serde_json::from_str::<lqos_config::TopologyRuntimeStatusFile>(&status_raw)
@@ -3166,7 +3176,8 @@ fn topology_runtime_shaping_generation(base_path: &Path) -> Option<String> {
return Some(status.shaping_generation);
}
let shaping_inputs_path = state_or_legacy_path(base_path, "shaping", "shaping_inputs.json");
let shaping_inputs_path =
state_or_legacy_path(base_path, state_base_path, "shaping", "shaping_inputs.json");
let shaping_raw = read_to_string(&shaping_inputs_path).ok()?;
let shaping_inputs =
serde_json::from_str::<lqos_config::TopologyShapingInputsFile>(&shaping_raw).ok()?;
@@ -3177,24 +3188,66 @@ fn topology_runtime_shaping_generation(base_path: &Path) -> Option<String> {
}
}
fn topology_runtime_effective_generation(
base_path: &Path,
state_base_path: &Path,
) -> Option<String> {
let status_path = state_or_legacy_path(
base_path,
state_base_path,
"topology",
"topology_runtime_status.json",
);
if let Ok(status_raw) = read_to_string(&status_path)
&& let Ok(status) =
serde_json::from_str::<lqos_config::TopologyRuntimeStatusFile>(&status_raw)
&& status.ready
&& !status.effective_generation.is_empty()
{
return Some(status.effective_generation);
}
let effective_path = state_or_legacy_path(
base_path,
state_base_path,
"topology",
"network.effective.json",
);
let effective_raw = read_to_string(&effective_path).ok()?;
let effective_network = serde_json::from_str::<serde_json::Value>(&effective_raw).ok()?;
lqos_config::compute_effective_network_generation(&effective_network).ok()
}
fn calculate_shaping_runtime_hash_for_base(
base_path: &Path,
state_base_path: &Path,
uses_topology_import_ingress: bool,
insight_topology_enabled: bool,
runtime_sqm_fingerprint: &str,
) -> i64 {
if uses_topology_import_ingress {
let Some(shaping_generation) = topology_runtime_shaping_generation(base_path) else {
let Some(shaping_generation) =
topology_runtime_shaping_generation(base_path, state_base_path)
else {
return 0;
};
let combined = format!("{shaping_generation}\n{runtime_sqm_fingerprint}");
let effective_generation =
topology_runtime_effective_generation(base_path, state_base_path).unwrap_or_default();
let combined =
format!("{shaping_generation}\n{effective_generation}\n{runtime_sqm_fingerprint}");
return lqos_utils::hash_to_i64(&combined);
}
let effective_path = state_or_legacy_path(base_path, "topology", "network.effective.json");
let effective_path = state_or_legacy_path(
base_path,
state_base_path,
"topology",
"network.effective.json",
);
let network_json_path = base_path.join("network.json");
let network_insight_path = base_path.join("network.insight.json");
let shaping_inputs_path = state_or_legacy_path(base_path, "shaping", "shaping_inputs.json");
let shaping_inputs_path =
state_or_legacy_path(base_path, state_base_path, "shaping", "shaping_inputs.json");
let shaped_devices_path = base_path.join("ShapedDevices.csv");
let shaped_devices_insight_path = base_path.join("ShapedDevices.insight.csv");
@@ -3243,11 +3296,13 @@ fn calculate_shaping_runtime_hash() -> PyResult<i64> {
return Ok(0);
};
let base_path = Path::new(&config.lqos_directory);
let state_base_path = config.resolved_state_directory();
let Ok(runtime_sqm_fingerprint) = shaping_runtime_sqm_fingerprint() else {
return Ok(0);
};
Ok(calculate_shaping_runtime_hash_for_base(
base_path,
state_base_path.as_path(),
config_uses_topology_import_ingress(config.as_ref()),
config
.long_term_stats
@@ -3960,7 +4015,7 @@ mod tests {
)
.expect("shaping inputs should write");
let first = calculate_shaping_runtime_hash_for_base(base, true, false, "");
let first = calculate_shaping_runtime_hash_for_base(base, base, true, false, "");
assert_ne!(first, 0);
fs::write(
@@ -3968,7 +4023,7 @@ mod tests {
r#"{"shaping_generation":"shape-b","circuits":[]}"#,
)
.expect("updated shaping inputs should write");
let second = calculate_shaping_runtime_hash_for_base(base, true, false, "");
let second = calculate_shaping_runtime_hash_for_base(base, base, true, false, "");
assert_ne!(second, 0);
assert_ne!(first, second);
}
@@ -3987,7 +4042,7 @@ mod tests {
)
.expect("shaping inputs should write");
let first = calculate_shaping_runtime_hash_for_base(base, true, false, "");
let first = calculate_shaping_runtime_hash_for_base(base, base, true, false, "");
assert_ne!(first, 0);
fs::write(
@@ -4000,7 +4055,108 @@ mod tests {
)
.expect("updated shaping inputs should write");
let second = calculate_shaping_runtime_hash_for_base(base, true, false, "");
let second = calculate_shaping_runtime_hash_for_base(base, base, true, false, "");
assert_eq!(first, second);
}
#[test]
fn shaping_runtime_hash_changes_when_effective_network_rates_change() {
let temp = tempdir().expect("tempdir should exist");
let base = temp.path();
fs::write(
base.join("shaping_inputs.json"),
r#"{"shaping_generation":"shape-1","circuits":[]}"#,
)
.expect("shaping inputs should write");
fs::write(
base.join("network.effective.json"),
r#"{
"Root": {
"children": {
"AP A": {
"id": "ap-a",
"downloadBandwidthMbps": 204,
"uploadBandwidthMbps": 58
}
}
}
}"#,
)
.expect("effective network should write");
let first = calculate_shaping_runtime_hash_for_base(base, base, true, false, "");
assert_ne!(first, 0);
fs::write(
base.join("network.effective.json"),
r#"{
"Root": {
"children": {
"AP A": {
"id": "ap-a",
"downloadBandwidthMbps": 218,
"uploadBandwidthMbps": 57
}
}
}
}"#,
)
.expect("updated effective network should write");
let second = calculate_shaping_runtime_hash_for_base(base, base, true, false, "");
assert_ne!(second, 0);
assert_ne!(first, second);
}
#[test]
fn shaping_runtime_hash_ignores_effective_network_key_order_only_changes() {
let temp = tempdir().expect("tempdir should exist");
let base = temp.path();
fs::write(
base.join("shaping_inputs.json"),
r#"{"shaping_generation":"shape-1","circuits":[]}"#,
)
.expect("shaping inputs should write");
fs::write(
base.join("network.effective.json"),
r#"{
"Root": {
"children": {
"AP A": {
"id": "ap-a",
"downloadBandwidthMbps": 204,
"uploadBandwidthMbps": 58
}
},
"downloadBandwidthMbps": 1000,
"uploadBandwidthMbps": 1000
}
}"#,
)
.expect("effective network should write");
let first = calculate_shaping_runtime_hash_for_base(base, base, true, false, "");
assert_ne!(first, 0);
fs::write(
base.join("network.effective.json"),
r#"{
"Root": {
"uploadBandwidthMbps": 1000,
"downloadBandwidthMbps": 1000,
"children": {
"AP A": {
"uploadBandwidthMbps": 58,
"downloadBandwidthMbps": 204,
"id": "ap-a"
}
}
}
}"#,
)
.expect("reordered effective network should write");
let second = calculate_shaping_runtime_hash_for_base(base, base, true, false, "");
assert_eq!(first, second);
}
@@ -4016,7 +4172,40 @@ mod tests {
)
.expect("shaped devices should write");
let hash = calculate_shaping_runtime_hash_for_base(base, false, false, "");
let hash = calculate_shaping_runtime_hash_for_base(base, base, false, false, "");
assert_ne!(hash, 0);
}
#[test]
fn shaping_runtime_hash_uses_resolved_state_directory_when_runtime_files_live_outside_src() {
let temp = tempdir().expect("tempdir should exist");
let base = temp.path().join("src");
let state = temp.path().join("state");
fs::create_dir_all(state.join("topology")).expect("topology state dir should exist");
fs::create_dir_all(state.join("shaping")).expect("shaping state dir should exist");
fs::write(
state.join("shaping").join("shaping_inputs.json"),
r#"{"shaping_generation":"shape-1","circuits":[]}"#,
)
.expect("shaping inputs should write");
fs::write(
state.join("topology").join("network.effective.json"),
r#"{
"Root": {
"children": {
"AP A": {
"id": "ap-a",
"downloadBandwidthMbps": 204,
"uploadBandwidthMbps": 58
}
}
}
}"#,
)
.expect("effective network should write");
let hash = calculate_shaping_runtime_hash_for_base(&base, &state, true, false, "");
assert_ne!(hash, 0);
}
}
+167 -23
View File
@@ -12,9 +12,9 @@ use lqos_config::{
TopologyEffectiveAttachmentState, TopologyEffectiveNodeState, TopologyEffectiveStateFile,
TopologyQueueVisibilityPolicy, TopologyRuntimeStatusFile, TopologyShapingCircuitInput,
TopologyShapingDeviceInput, TopologyShapingInputsFile, TopologyShapingResolutionSource,
circuit_anchors_path, detect_shaping_cpus, plan_top_level_assignments,
topology_effective_network_path, topology_effective_state_path, topology_runtime_status_path,
topology_shaping_inputs_path,
circuit_anchors_path, compute_effective_network_generation, detect_shaping_cpus,
plan_top_level_assignments, topology_effective_network_path, topology_effective_state_path,
topology_runtime_status_path, topology_shaping_inputs_path,
};
use lqos_overrides::{
CircuitAdjustment, OverrideStore, TopologyAttachmentMode, TopologyOverridesFile,
@@ -1223,6 +1223,11 @@ pub fn publish_effective_topology_artifacts(
}
let effective_network_path = topology_effective_network_path(config);
let effective_generation = artifacts
.effective_network
.as_ref()
.map(compute_effective_network_generation)
.transpose()?;
if let Some(effective_network) = artifacts.effective_network.as_ref() {
let current_effective_network = read_json_value(&effective_network_path);
if current_effective_network.as_ref() != Some(effective_network) {
@@ -1281,6 +1286,7 @@ pub fn publish_effective_topology_artifacts(
config,
source_generation,
Some(&shaping_inputs.shaping_generation),
effective_generation.as_deref(),
true,
None,
)?;
@@ -1294,7 +1300,14 @@ pub fn publish_effective_topology_artifacts(
)
})?;
}
publish_topology_runtime_status(config, source_generation, None, true, None)?;
publish_topology_runtime_status(
config,
source_generation,
None,
effective_generation.as_deref(),
true,
None,
)?;
}
}
@@ -1305,6 +1318,7 @@ fn topology_runtime_status_snapshot(
config: &Config,
source_generation: &str,
shaping_generation: Option<&str>,
effective_generation: Option<&str>,
ready: bool,
error: Option<String>,
) -> TopologyRuntimeStatusFile {
@@ -1312,6 +1326,7 @@ fn topology_runtime_status_snapshot(
schema_version: 1,
source_generation: source_generation.to_string(),
shaping_generation: shaping_generation.unwrap_or_default().to_string(),
effective_generation: effective_generation.unwrap_or_default().to_string(),
ready,
generated_unix: now_unix(),
effective_state_path: topology_effective_state_path(config)
@@ -1334,6 +1349,7 @@ pub fn publish_topology_runtime_status(
config: &Config,
source_generation: &str,
shaping_generation: Option<&str>,
effective_generation: Option<&str>,
ready: bool,
error: Option<String>,
) -> Result<()> {
@@ -1341,6 +1357,7 @@ pub fn publish_topology_runtime_status(
config,
source_generation,
shaping_generation,
effective_generation,
ready,
error,
);
@@ -1365,6 +1382,7 @@ pub fn publish_topology_runtime_error_status(
config,
source_generation,
None,
None,
false,
Some(error.to_string()),
)
@@ -1707,17 +1725,57 @@ fn parent_has_attachment(parent: &TopologyAllowedParent, attachment_id: &str) ->
.any(|option| option.attachment_id == attachment_id)
}
fn first_healthy_attachment_id(parent: &TopologyAllowedParent) -> Option<String> {
fn attachment_selectable_for_auto(option: &TopologyAttachmentOption) -> bool {
option.attachment_id != TOPOLOGY_ATTACHMENT_AUTO_ID
&& option.health_status != TopologyAttachmentHealthStatus::Suppressed
}
const fn attachment_rate_source_preference(source: TopologyAttachmentRateSource) -> u8 {
match source {
TopologyAttachmentRateSource::DynamicIntegration => 3,
TopologyAttachmentRateSource::Manual => 2,
TopologyAttachmentRateSource::Static => 1,
TopologyAttachmentRateSource::Unknown => 0,
}
}
const fn attachment_health_preference(status: TopologyAttachmentHealthStatus) -> u8 {
match status {
TopologyAttachmentHealthStatus::Healthy => 3,
TopologyAttachmentHealthStatus::Disabled => 2,
TopologyAttachmentHealthStatus::ProbeUnavailable => 1,
TopologyAttachmentHealthStatus::Suppressed => 0,
}
}
fn ranked_auto_attachment_id(
parent: &TopologyAllowedParent,
current_attachment_id: Option<&str>,
) -> Option<String> {
parent
.attachment_options
.iter()
.find(|option| {
option.attachment_id != TOPOLOGY_ATTACHMENT_AUTO_ID
&& option.health_status == TopologyAttachmentHealthStatus::Healthy
.filter(|option| attachment_selectable_for_auto(option))
.max_by_key(|option| {
(
attachment_rate_source_preference(option.rate_source),
attachment_capacity_mbps(option).unwrap_or(0),
attachment_health_preference(option.health_status),
option.probeable,
current_attachment_id == Some(option.attachment_id.as_str()),
)
})
.map(|option| option.attachment_id.clone())
}
fn first_selectable_attachment_id(parent: &TopologyAllowedParent) -> Option<String> {
parent
.attachment_options
.iter()
.find(|option| attachment_selectable_for_auto(option))
.map(|option| option.attachment_id.clone())
}
fn first_explicit_attachment_id(parent: &TopologyAllowedParent) -> Option<String> {
parent
.attachment_options
@@ -2048,22 +2106,24 @@ fn compute_effective_state_from_prepared(
})
})
}
_ => node
.current_attachment_id
.clone()
.filter(|attachment_id| parent_has_attachment(&enriched_parent, attachment_id)),
_ => ranked_auto_attachment_id(&enriched_parent, node.current_attachment_id.as_deref())
.or_else(|| {
node.current_attachment_id.clone().filter(|attachment_id| {
parent_has_attachment(&enriched_parent, attachment_id)
})
}),
};
let healthy_ids = explicit_options
let selectable_ids = explicit_options
.iter()
.filter(|option| option.health_status == TopologyAttachmentHealthStatus::Healthy)
.filter(|option| attachment_selectable_for_auto(option))
.map(|option| option.attachment_id.clone())
.collect::<HashSet<_>>();
let mut fallback_reason = None;
let effective_attachment_id = if explicit_options.is_empty() {
None
} else if !healthy_ids.is_empty() {
} else if !selectable_ids.is_empty() {
match override_entry {
Some(saved)
if saved.parent_node_id == selected_parent_id
@@ -2072,20 +2132,26 @@ fn compute_effective_state_from_prepared(
saved
.attachment_preference_ids
.iter()
.find(|attachment_id| healthy_ids.contains(*attachment_id))
.find(|attachment_id| selectable_ids.contains(*attachment_id))
.cloned()
.or_else(|| {
node.current_attachment_id
.clone()
.filter(|attachment_id| healthy_ids.contains(attachment_id))
.filter(|attachment_id| selectable_ids.contains(attachment_id))
})
.or_else(|| first_healthy_attachment_id(&enriched_parent))
.or_else(|| {
ranked_auto_attachment_id(
&enriched_parent,
node.current_attachment_id.as_deref(),
)
})
.or_else(|| first_selectable_attachment_id(&enriched_parent))
}
_ => node
.current_attachment_id
.clone()
.filter(|attachment_id| healthy_ids.contains(attachment_id))
.or_else(|| first_healthy_attachment_id(&enriched_parent)),
_ => ranked_auto_attachment_id(
&enriched_parent,
node.current_attachment_id.as_deref(),
)
.or_else(|| first_selectable_attachment_id(&enriched_parent)),
}
} else {
fallback_reason = Some(if enriched_parent.all_attachments_suppressed {
@@ -7052,6 +7118,84 @@ mod tests {
assert!(child.attachments.is_empty());
}
#[test]
fn compute_effective_state_auto_prefers_dynamic_attachment_when_probes_disabled() {
let config = Config::default();
let mut dynamic_attachment =
sample_attachment_option("dynamic-link", "WavePro-MREToRochester");
dynamic_attachment.rate_source = TopologyAttachmentRateSource::DynamicIntegration;
dynamic_attachment.capacity_mbps = Some(2700);
dynamic_attachment.download_bandwidth_mbps = Some(2700);
dynamic_attachment.upload_bandwidth_mbps = Some(2700);
dynamic_attachment.local_probe_ip = Some("100.126.0.226".to_string());
dynamic_attachment.probe_enabled = false;
let mut static_attachment = sample_attachment_option("static-link", "4600C_MRE_To_ROCH");
static_attachment.rate_source = TopologyAttachmentRateSource::Static;
static_attachment.capacity_mbps = Some(8000);
static_attachment.download_bandwidth_mbps = Some(8000);
static_attachment.upload_bandwidth_mbps = Some(8000);
static_attachment.local_probe_ip = Some("100.126.0.235".to_string());
static_attachment.remote_probe_ip = Some("100.126.0.234".to_string());
static_attachment.probe_enabled = false;
let canonical = TopologyEditorStateFile {
schema_version: 1,
source: "uisp/full2".to_string(),
generated_unix: None,
ingress_identity: None,
nodes: vec![TopologyEditorNode {
node_id: "site-mre".to_string(),
node_name: "MRE".to_string(),
latitude: None,
longitude: None,
current_parent_node_id: Some("site-rochester".to_string()),
current_parent_node_name: Some("7232 Rochester".to_string()),
current_attachment_id: Some("static-link".to_string()),
current_attachment_name: Some("4600C_MRE_To_ROCH".to_string()),
can_move: true,
allowed_parents: vec![TopologyAllowedParent {
parent_node_id: "site-rochester".to_string(),
parent_node_name: "7232 Rochester".to_string(),
attachment_options: vec![
auto_attachment_option(),
dynamic_attachment,
static_attachment,
],
all_attachments_suppressed: false,
has_probe_unavailable_attachments: false,
}],
queue_visibility_policy: TopologyQueueVisibilityPolicy::QueueAuto,
preferred_attachment_id: None,
preferred_attachment_name: None,
effective_attachment_id: None,
effective_attachment_name: None,
}],
};
let effective = compute_effective_state(
&config,
&canonical,
&TopologyOverridesFile::default(),
&TopologyAttachmentHealthStateFile::default(),
);
let node = effective
.nodes
.iter()
.find(|node| node.node_id == "site-mre")
.expect("MRE node should remain in effective state");
assert_eq!(
node.preferred_attachment_id.as_deref(),
Some("dynamic-link")
);
assert_eq!(
node.effective_attachment_id.as_deref(),
Some("dynamic-link")
);
assert!(node.fallback_reason.is_none());
}
#[test]
fn effective_state_fallback_does_not_keep_old_parent_attachment_after_reparent() {
use lqos_overrides::TopologyOverridesFile;
+20
View File
@@ -41,6 +41,25 @@ SCHEDULER_REFRESH_STEP_COUNT = 4
scheduler_status_bus_enabled = True
def configure_scheduler_stdio():
"""
Enable line-buffered scheduler logs when running under systemd.
Side effects: reconfigures `sys.stdout` and `sys.stderr` when the active
stream supports `reconfigure()`. This keeps periodic scheduler messages from
sitting in Python's block buffer for hours before journald sees them.
"""
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
reconfigure = getattr(stream, "reconfigure", None)
if not callable(reconfigure):
continue
try:
reconfigure(line_buffering=True, write_through=True)
except Exception:
continue
def get_state_directory():
if _get_state_dir_native is not None:
return _get_state_dir_native()
@@ -1269,6 +1288,7 @@ def run_scheduler_main():
if __name__ == '__main__':
try:
configure_scheduler_stdio()
run_scheduler_main()
except Exception as e:
print(f"Error starting scheduler: {e}")
+26
View File
@@ -287,6 +287,32 @@ class TestSchedulerErrorReporting(unittest.TestCase):
)
self.assertEqual(mock_scheduler_output.call_args_list, [(( "",),)])
class TestSchedulerLogging(unittest.TestCase):
def test_configure_scheduler_stdio_enables_line_buffering_when_supported(self):
class FakeStream:
def __init__(self):
self.calls = []
def reconfigure(self, **kwargs):
self.calls.append(kwargs)
fake_stdout = FakeStream()
fake_stderr = FakeStream()
with patch.object(scheduler.sys, "stdout", fake_stdout):
with patch.object(scheduler.sys, "stderr", fake_stderr):
scheduler.configure_scheduler_stdio()
expected = [{"line_buffering": True, "write_through": True}]
self.assertEqual(fake_stdout.calls, expected)
self.assertEqual(fake_stderr.calls, expected)
def test_configure_scheduler_stdio_ignores_streams_without_reconfigure(self):
with patch.object(scheduler.sys, "stdout", object()):
with patch.object(scheduler.sys, "stderr", object()):
scheduler.configure_scheduler_stdio()
def test_run_scheduler_main_stays_alive_on_startup_refresh_failure(self):
fake_ads = Mock()