diff --git a/src/LibreQoS.py b/src/LibreQoS.py
index e6969053..381f6bc6 100755
--- a/src/LibreQoS.py
+++ b/src/LibreQoS.py
@@ -74,6 +74,10 @@ class RefreshFailure(Exception):
pass
+class ValidationFailure(RefreshFailure):
+ pass
+
+
def report_refresh_failure(code, message, context=None, dedupe_key=None):
logging.error(message)
print("ERROR: " + message)
@@ -134,11 +138,8 @@ def get_state_path(category, filename):
return os.path.join(get_state_directory(), category, filename)
-def get_existing_state_path(category, filename):
- preferred = get_state_path(category, filename)
- if os.path.exists(preferred):
- return preferred
- return os.path.join(get_libreqos_directory(), filename)
+def get_runtime_state_path(category, filename):
+ return get_state_path(category, filename)
def ensure_parent_dir(path):
@@ -149,11 +150,7 @@ def ensure_parent_dir(path):
def get_network_json_path():
base_dir = get_libreqos_directory()
- effective_path = get_existing_state_path("topology", "network.effective.json")
-
- if os.path.exists(effective_path):
- return effective_path
-
+ effective_path = get_runtime_state_path("topology", "network.effective.json")
if topology_import_ingress_enabled():
return effective_path
@@ -167,47 +164,47 @@ def get_network_json_path():
def get_shaping_inputs_path():
- return get_existing_state_path("shaping", "shaping_inputs.json")
+ return get_runtime_state_path("shaping", "shaping_inputs.json")
def get_circuit_anchors_path():
- return get_existing_state_path("topology", "circuit_anchors.json")
+ return get_runtime_state_path("topology", "circuit_anchors.json")
def get_planner_state_path():
- return get_existing_state_path("shaping", "planner_state.json")
+ return get_runtime_state_path("shaping", "planner_state.json")
def get_topology_runtime_status_path():
- return get_existing_state_path("topology", "topology_runtime_status.json")
+ return get_runtime_state_path("topology", "topology_runtime_status.json")
def get_queuing_structure_path():
- return get_existing_state_path("shaping", "queuingStructure.json")
+ return get_runtime_state_path("shaping", "queuingStructure.json")
def get_last_run_path():
- return get_existing_state_path("stats", "lastRun.txt")
+ return get_runtime_state_path("stats", "lastRun.txt")
def get_last_good_config_json_path():
- return get_existing_state_path("shaping", "lastGoodConfig.json")
+ return get_runtime_state_path("shaping", "lastGoodConfig.json")
def get_last_good_config_csv_path():
- return get_existing_state_path("shaping", "lastGoodConfig.csv")
+ return get_runtime_state_path("shaping", "lastGoodConfig.csv")
def get_last_loaded_shaped_devices_path():
- return get_existing_state_path("shaping", "ShapedDevices.lastLoaded.csv")
+ return get_runtime_state_path("shaping", "ShapedDevices.lastLoaded.csv")
def get_stats_by_circuit_path():
- return get_existing_state_path("stats", "statsByCircuit.json")
+ return get_runtime_state_path("stats", "statsByCircuit.json")
def get_stats_by_parent_node_path():
- return get_existing_state_path("stats", "statsByParentNode.json")
+ return get_runtime_state_path("stats", "statsByParentNode.json")
def get_linux_tc_path():
@@ -831,34 +828,45 @@ def validateNetworkAndDevices():
warnings.warn("Rust failed to validate ShapedDevices.csv", stacklevel=2)
warnings.warn(rustValid, stacklevel=2)
devicesValidatedOrNot = False
- with open(get_network_json_path()) as file:
- try:
- data = json.load(file) # put JSON-data to a variable
- if data != {}:
- #Traverse
- observedNodes = set()
- duplicateNodes = set()
- def traverseToVerifyValidity(data):
- for elem in data:
- if isinstance(elem, str):
- if (isinstance(data[elem], dict)) and (elem != 'children'):
- if elem not in observedNodes:
- observedNodes.add(elem)
- if 'children' in data[elem]:
- traverseToVerifyValidity(data[elem]['children'])
- else:
- duplicateNodes.add(elem)
- traverseToVerifyValidity(data)
- if len(duplicateNodes) > 0:
- for elem in sorted(duplicateNodes):
- warnings.warn("Non-unique Node name in network.json: " + elem, stacklevel=2)
- networkValidatedOrNot = False
- if len(observedNodes) < 1:
- warnings.warn("network.json had 0 valid nodes. Only {} is accepted for that scenario.", stacklevel=2)
- networkValidatedOrNot = False
- except json.decoder.JSONDecodeError:
- warnings.warn("network.json is an invalid JSON file", stacklevel=2) # in case json is invalid
- networkValidatedOrNot = False
+ network_json_path = get_network_json_path()
+ if not os.path.isfile(network_json_path):
+ if integration_ingress:
+ warnings.warn(
+ f"Integration topology has not published an effective network tree yet: {network_json_path}",
+ stacklevel=2,
+ )
+ else:
+ warnings.warn(f"network.json is missing: {network_json_path}", stacklevel=2)
+ networkValidatedOrNot = False
+ else:
+ with open(network_json_path) as file:
+ try:
+ data = json.load(file) # put JSON-data to a variable
+ if data != {}:
+ #Traverse
+ observedNodes = set()
+ duplicateNodes = set()
+ def traverseToVerifyValidity(data):
+ for elem in data:
+ if isinstance(elem, str):
+ if (isinstance(data[elem], dict)) and (elem != 'children'):
+ if elem not in observedNodes:
+ observedNodes.add(elem)
+ if 'children' in data[elem]:
+ traverseToVerifyValidity(data[elem]['children'])
+ else:
+ duplicateNodes.add(elem)
+ traverseToVerifyValidity(data)
+ if len(duplicateNodes) > 0:
+ for elem in sorted(duplicateNodes):
+ warnings.warn("Non-unique Node name in network.json: " + elem, stacklevel=2)
+ networkValidatedOrNot = False
+ if len(observedNodes) < 1:
+ warnings.warn("network.json had 0 valid nodes. Only {} is accepted for that scenario.", stacklevel=2)
+ networkValidatedOrNot = False
+ except json.decoder.JSONDecodeError:
+ warnings.warn("network.json is an invalid JSON file", stacklevel=2) # in case json is invalid
+ networkValidatedOrNot = False
if integration_ingress:
if devicesValidatedOrNot == True:
print("integration shaping ingress passed validation")
@@ -1298,11 +1306,13 @@ def refreshShapers():
safeToRunRefresh = True
else:
if topology_import_ingress_enabled():
- warnings.warn("Validation failed for integration ingress/runtime artifacts - will now exit.", stacklevel=2)
- safeToRunRefresh = False
+ message = "Validation failed for integration ingress/runtime artifacts - will now exit."
+ warnings.warn(message, stacklevel=2)
+ raise ValidationFailure(message)
elif (isThisFirstRunSinceBoot == False):
- warnings.warn("Validation failed. Because this is not the first run since boot (queues already set up) - will now exit.", stacklevel=2)
- safeToRunRefresh = False
+ message = "Validation failed. Because this is not the first run since boot (queues already set up) - will now exit."
+ warnings.warn(message, stacklevel=2)
+ raise ValidationFailure(message)
else:
warnings.warn("Validation failed. However - because this is the first run since boot - will load queues from last good config", stacklevel=2)
shapedDevicesFile = get_last_good_config_csv_path()
@@ -2853,7 +2863,9 @@ def refreshShapersUpdateOnly():
if (validateNetworkAndDevices() == True):
safeToRunRefresh = True
else:
- warnings.warn("Validation failed. Will now exit.", stacklevel=2)
+ message = "Validation failed. Will now exit."
+ warnings.warn(message, stacklevel=2)
+ raise ValidationFailure(message)
if safeToRunRefresh == True:
networkChanged = False
diff --git a/src/build_dpkg.sh b/src/build_dpkg.sh
index 39c04614..c7c007c6 100755
--- a/src/build_dpkg.sh
+++ b/src/build_dpkg.sh
@@ -124,6 +124,22 @@ cat <<'EOF' > postinst
#!/bin/bash
set -euo pipefail
+set_libreqos_operator_permissions() {
+local runtime_paths=()
+[ -e /opt/libreqos/src ] && runtime_paths+=("/opt/libreqos/src")
+[ -e /opt/libreqos/state ] && runtime_paths+=("/opt/libreqos/state")
+[ ${#runtime_paths[@]} -eq 0 ] && return
+
+if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && id "$SUDO_USER" >/dev/null 2>&1; then
+ chown -R "$SUDO_USER:$SUDO_USER" "${runtime_paths[@]}"
+ chmod -R u+rwX "${runtime_paths[@]}" || true
+ echo "Granted $SUDO_USER ownership of /opt/libreqos/src and /opt/libreqos/state for SFTP editing."
+else
+ echo "Unable to determine the installing operator account automatically."
+ echo "If you plan to edit LibreQoS files over SFTP, run: sudo chown -R : /opt/libreqos/src /opt/libreqos/state"
+fi
+}
+
# Install Python Dependencies
pushd /opt/libreqos > /dev/null
# - Setup Python dependencies as a post-install task. Use --ignore-installed so
@@ -136,7 +152,7 @@ PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install --ignore-installed -r src/req
fi
# Ensure folder permissions are correct post-install
-sudo chown -R root:root /opt/libreqos
+set_libreqos_operator_permissions
# - Setup the services
install -m 0644 /opt/libreqos/src/bin/lqosd.service.example /etc/systemd/system/lqosd.service
diff --git a/src/build_rust.sh b/src/build_rust.sh
index 097167cc..35415a99 100755
--- a/src/build_rust.sh
+++ b/src/build_rust.sh
@@ -140,6 +140,24 @@ mv liblqos_python.so.new liblqos_python.so
echo "Updating lqos_api binary..."
bash ./update_api.sh || echo "Warning: Failed to update lqos_api (continuing)."
+set_libreqos_operator_permissions() {
+ local runtime_paths=()
+ [ -e /opt/libreqos/src ] && runtime_paths+=("/opt/libreqos/src")
+ [ -e /opt/libreqos/state ] && runtime_paths+=("/opt/libreqos/state")
+ [ ${#runtime_paths[@]} -eq 0 ] && return
+
+ if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && id "$SUDO_USER" >/dev/null 2>&1; then
+ sudo chown -R "$SUDO_USER:$SUDO_USER" "${runtime_paths[@]}"
+ sudo chmod -R u+rwX "${runtime_paths[@]}" || true
+ echo "Granted $SUDO_USER ownership of /opt/libreqos/src and /opt/libreqos/state for SFTP editing."
+ else
+ echo "Unable to determine the installing operator account automatically."
+ echo "If you plan to edit LibreQoS files over SFTP, run: sudo chown -R : /opt/libreqos/src /opt/libreqos/state"
+ fi
+}
+
+set_libreqos_operator_permissions
+
# If we're running systemd, we need to restart processes
service_exists() {
local n=$1
diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs
index a2488ff7..59a522c8 100644
--- a/src/rust/lqos_bakery/src/lib.rs
+++ b/src/rust/lqos_bakery/src/lib.rs
@@ -3170,38 +3170,7 @@ fn migration_target_label(migration: &Migration) -> String {
}
fn runtime_network_json_path(config: &Config) -> std::path::PathBuf {
- let base_path = Path::new(&config.lqos_directory);
- let effective_path = config.topology_state_read_path("network.effective.json");
- let integration_ingress_enabled = config.uisp_integration.enable_uisp
- || config.splynx_integration.enable_splynx
- || config
- .netzur_integration
- .as_ref()
- .is_some_and(|integration| integration.enable_netzur)
- || config
- .visp_integration
- .as_ref()
- .is_some_and(|integration| integration.enable_visp)
- || config.powercode_integration.enable_powercode
- || config.sonar_integration.enable_sonar
- || config
- .wispgate_integration
- .as_ref()
- .is_some_and(|integration| integration.enable_wispgate);
- if effective_path.exists() || integration_ingress_enabled {
- return effective_path;
- }
- if config
- .long_term_stats
- .enable_insight_topology
- .unwrap_or(false)
- {
- let tmp_path = base_path.join("network.insight.json");
- if tmp_path.exists() {
- return tmp_path;
- }
- }
- base_path.join("network.json")
+ lqos_config::NetworkJson::path_for_config(config)
}
fn runtime_hash_to_i64(text: &str) -> i64 {
diff --git a/src/rust/lqos_config/src/etc/etclqos_migration.rs b/src/rust/lqos_config/src/etc/etclqos_migration.rs
index ac3b7725..aa757f2a 100644
--- a/src/rust/lqos_config/src/etc/etclqos_migration.rs
+++ b/src/rust/lqos_config/src/etc/etclqos_migration.rs
@@ -308,8 +308,8 @@ mod test {
.filter(|line| !line.trim().starts_with("set_cpu_governor_performance"))
.collect::>()
.join("\n");
- let config = super::EtcLqos::load_from_string(&raw)
- .expect("Unable to read legacy config fixture");
+ let config =
+ super::EtcLqos::load_from_string(&raw).expect("Unable to read legacy config fixture");
assert!(
config
.tuning
diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs
index 4a0995c8..a796e2c8 100644
--- a/src/rust/lqos_config/src/etc/mod.rs
+++ b/src/rust/lqos_config/src/etc/mod.rs
@@ -21,9 +21,8 @@ mod v15;
pub use v15::{
BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode,
MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig,
- StormguardStrategy, TopologyConfig,
- TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode,
- TreeguardLinksConfig, TreeguardQooConfig, Tunables,
+ StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig,
+ TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables,
};
static CONFIG: Lazy>>> = Lazy::new(|| ArcSwap::from_pointee(None));
diff --git a/src/rust/lqos_config/src/etc/v15/top_config.rs b/src/rust/lqos_config/src/etc/v15/top_config.rs
index 6d127808..cc6f7d70 100644
--- a/src/rust/lqos_config/src/etc/v15/top_config.rs
+++ b/src/rust/lqos_config/src/etc/v15/top_config.rs
@@ -461,44 +461,24 @@ impl Config {
Path::new(&self.lqos_directory).join(filename)
}
- /// Returns the preferred read path for a topology runtime file, with legacy fallback.
+ /// Returns the canonical read path for a topology runtime file.
pub fn topology_state_read_path(&self, filename: &str) -> PathBuf {
- let preferred = self.topology_state_file_path(filename);
- if preferred.exists() {
- preferred
- } else {
- self.legacy_runtime_file_path(filename)
- }
+ self.topology_state_file_path(filename)
}
- /// Returns the preferred read path for a shaping runtime file, with legacy fallback.
+ /// Returns the canonical read path for a shaping runtime file.
pub fn shaping_state_read_path(&self, filename: &str) -> PathBuf {
- let preferred = self.shaping_state_file_path(filename);
- if preferred.exists() {
- preferred
- } else {
- self.legacy_runtime_file_path(filename)
- }
+ self.shaping_state_file_path(filename)
}
- /// Returns the preferred read path for a stats runtime file, with legacy fallback.
+ /// Returns the canonical read path for a stats runtime file.
pub fn stats_state_read_path(&self, filename: &str) -> PathBuf {
- let preferred = self.stats_state_file_path(filename);
- if preferred.exists() {
- preferred
- } else {
- self.legacy_runtime_file_path(filename)
- }
+ self.stats_state_file_path(filename)
}
- /// Returns the preferred read path for a cache runtime file, with legacy fallback.
+ /// Returns the canonical read path for a cache runtime file.
pub fn cache_state_read_path(&self, filename: &str) -> PathBuf {
- let preferred = self.cache_state_file_path(filename);
- if preferred.exists() {
- preferred
- } else {
- self.legacy_runtime_file_path(filename)
- }
+ self.cache_state_file_path(filename)
}
/// Returns the explicitly configured shared topology compile mode, when set.
diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs
index 7be006d7..2e54fd6b 100644
--- a/src/rust/lqos_config/src/lib.rs
+++ b/src/rust/lqos_config/src/lib.rs
@@ -41,12 +41,12 @@ pub use cpu_topology::{
CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus,
};
pub use etc::{
- BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode, QueueMode,
- MikrotikIpv6Config, RttThresholds, SingleInterfaceConfig, StormguardConfig,
- StormguardStrategy, TopologyConfig,
- TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode,
- TreeguardLinksConfig, TreeguardQooConfig, Tunables, clear_cached_config, disable_xdp_bridge,
- enable_long_term_stats, load_config, treeguard_cpu_mode_migration_notice, update_config,
+ BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode,
+ MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig,
+ StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig,
+ TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables,
+ clear_cached_config, disable_xdp_bridge, enable_long_term_stats, load_config,
+ treeguard_cpu_mode_migration_notice, update_config,
};
pub use ethernet_port_limits::{
DEFAULT_ETHERNET_PORT_LIMIT_MULTIPLIER, EthernetPortLimitPolicy, EthernetPortObservation,
@@ -95,12 +95,57 @@ pub use topology_runtime_state::{
TopologyAttachmentHealthStateFile, TopologyEffectiveAttachmentState,
TopologyEffectiveNodeState, TopologyEffectiveStateFile, TopologyRuntimeStateError,
TopologyRuntimeStatusFile, TopologyShapingCircuitInput, TopologyShapingDeviceInput,
- TopologyShapingInputsFile, TopologyShapingResolutionSource,
+ TopologyShapingInputsFile, TopologyShapingResolutionSource, active_runtime_shaping_inputs_path,
compute_effective_network_generation, compute_topology_source_generation,
- topology_attachment_health_state_path, topology_compiled_shaping_path,
- topology_effective_network_path, topology_effective_state_path, topology_import_path,
- topology_runtime_status_path, topology_shaping_inputs_path,
+ load_active_runtime_shaping_inputs, topology_attachment_health_state_path,
+ topology_compiled_shaping_path, topology_effective_network_path, topology_effective_state_path,
+ topology_import_path, topology_runtime_status_path, topology_shaping_inputs_path,
};
+/// Returns whether an integration-backed topology ingress is enabled in the
+/// current config.
+///
+/// This function is pure: it has no side effects.
+pub fn integration_ingress_enabled(config: &Config) -> bool {
+ config.uisp_integration.enable_uisp
+ || config.splynx_integration.enable_splynx
+ || config
+ .netzur_integration
+ .as_ref()
+ .is_some_and(|integration| integration.enable_netzur)
+ || config
+ .visp_integration
+ .as_ref()
+ .is_some_and(|integration| integration.enable_visp)
+ || config.powercode_integration.enable_powercode
+ || config.sonar_integration.enable_sonar
+ || config
+ .wispgate_integration
+ .as_ref()
+ .is_some_and(|integration| integration.enable_wispgate)
+}
+
+/// Returns whether the current integration import artifact advertises any shaped devices.
+///
+/// This is a lightweight readiness predicate used by migration and runtime callers that need to
+/// know whether integration publication has taken over from legacy manual files.
+///
+/// This function is pure: it has no side effects.
+pub fn topology_import_has_shaped_devices(config: &Config) -> bool {
+ let import_path = config.topology_state_file_path(TOPOLOGY_IMPORT_FILENAME);
+ let Ok(raw) = std::fs::read_to_string(&import_path) else {
+ return false;
+ };
+ let Ok(decoded) = serde_json::from_str::(&raw) else {
+ return false;
+ };
+
+ decoded
+ .get("imported")
+ .and_then(|imported| imported.get("shaped_devices"))
+ .and_then(serde_json::Value::as_array)
+ .is_some_and(|devices| !devices.is_empty())
+}
+
/// Used as a constant in determining buffer preallocation
pub const SUPPORTED_CUSTOMERS: usize = 100_000;
diff --git a/src/rust/lqos_config/src/network_json.rs b/src/rust/lqos_config/src/network_json.rs
index a25b198a..500584a3 100644
--- a/src/rust/lqos_config/src/network_json.rs
+++ b/src/rust/lqos_config/src/network_json.rs
@@ -47,28 +47,17 @@ impl NetworkJson {
(self.nodes.len(), self.nodes.capacity())
}
- fn path_for_config(cfg: &crate::Config) -> PathBuf {
+ /// Resolves the active `network.json` path for one concrete config snapshot.
+ ///
+ /// When an integration-backed topology ingress is enabled, this prefers the
+ /// runtime-generated effective tree under `state/topology/`. When integration
+ /// ingress is disabled, this uses the operator-owned manual tree and ignores
+ /// any stale runtime effective export.
+ ///
+ /// This function is pure: it has no side effects.
+ pub fn path_for_config(cfg: &crate::Config) -> PathBuf {
let base_path = Path::new(&cfg.lqos_directory);
- let effective_path = cfg.topology_state_read_path("network.effective.json");
- let integration_ingress_enabled = cfg.uisp_integration.enable_uisp
- || cfg.splynx_integration.enable_splynx
- || cfg
- .netzur_integration
- .as_ref()
- .is_some_and(|integration| integration.enable_netzur)
- || cfg
- .visp_integration
- .as_ref()
- .is_some_and(|integration| integration.enable_visp)
- || cfg.powercode_integration.enable_powercode
- || cfg.sonar_integration.enable_sonar
- || cfg
- .wispgate_integration
- .as_ref()
- .is_some_and(|integration| integration.enable_wispgate);
- if effective_path.exists() {
- effective_path
- } else if integration_ingress_enabled {
+ if crate::integration_ingress_enabled(cfg) {
cfg.topology_state_file_path("network.effective.json")
} else if cfg.long_term_stats.enable_insight_topology.unwrap_or(false) {
let tmp_path = base_path.join("network.insight.json");
@@ -936,7 +925,9 @@ mod test {
#[test]
fn path_for_config_prefers_runtime_effective_tree_over_other_tree_inputs() {
let temp = unique_temp_dir("network-json-effective-priority");
- std::fs::write(temp.join("network.effective.json"), "{}")
+ let state_topology = temp.join("state/topology");
+ std::fs::create_dir_all(&state_topology).expect("state topology dir should exist");
+ std::fs::write(state_topology.join("network.effective.json"), "{}")
.expect("effective tree should write");
std::fs::write(temp.join("network.insight.json"), "{}").expect("insight tree should write");
std::fs::write(temp.join("network.json"), "{}").expect("legacy tree should write");
@@ -945,10 +936,30 @@ mod test {
state_directory: None,
..crate::Config::default()
};
+ config.uisp_integration.enable_uisp = true;
config.long_term_stats.enable_insight_topology = Some(true);
let selected = NetworkJson::path_for_config(&config);
- assert_eq!(selected, temp.join("network.effective.json"));
+ assert_eq!(selected, state_topology.join("network.effective.json"));
+ }
+
+ #[test]
+ fn path_for_config_ignores_stale_runtime_effective_tree_in_manual_mode() {
+ let temp = unique_temp_dir("network-json-manual-ignores-stale-effective");
+ let state_topology = temp.join("state/topology");
+ std::fs::create_dir_all(&state_topology).expect("state topology dir should exist");
+ std::fs::write(state_topology.join("network.effective.json"), "{}")
+ .expect("effective tree should write");
+ std::fs::write(temp.join("network.json"), "{}").expect("manual tree should write");
+
+ let config = crate::Config {
+ lqos_directory: temp.display().to_string(),
+ state_directory: None,
+ ..crate::Config::default()
+ };
+
+ let selected = NetworkJson::path_for_config(&config);
+ assert_eq!(selected, temp.join("network.json"));
}
#[test]
diff --git a/src/rust/lqos_config/src/runtime_state_migration.rs b/src/rust/lqos_config/src/runtime_state_migration.rs
index a08f6f0d..e23730cf 100644
--- a/src/rust/lqos_config/src/runtime_state_migration.rs
+++ b/src/rust/lqos_config/src/runtime_state_migration.rs
@@ -79,6 +79,7 @@ pub(crate) fn migrate_legacy_runtime_state(
migrate_legacy_token_caches(config, &mut migrated, &mut removed_duplicate_legacy)?;
quarantine_legacy_backups(config, &mut quarantined)?;
+ quarantine_stale_manual_source_files(config, &mut quarantined)?;
if !migrated.is_empty() {
info!(
@@ -360,6 +361,35 @@ fn quarantine_legacy_backups(
Ok(())
}
+fn quarantine_stale_manual_source_files(
+ config: &Config,
+ quarantined: &mut Vec,
+) -> Result<(), RuntimeStateMigrationError> {
+ if !integration_topology_import_is_ready(config) {
+ return Ok(());
+ }
+
+ for name in ["network.json", "ShapedDevices.csv"] {
+ let source = config.legacy_runtime_file_path(name);
+ if !source.is_file() {
+ continue;
+ }
+ let destination = unique_quarantine_destination(config, name);
+ move_path(&source, &destination)?;
+ quarantined.push(format!(
+ "{} -> {} (integration ingress active)",
+ source.display(),
+ destination.display()
+ ));
+ }
+
+ Ok(())
+}
+
+fn integration_topology_import_is_ready(config: &Config) -> bool {
+ crate::integration_ingress_enabled(config) && crate::topology_import_has_shaped_devices(config)
+}
+
fn is_obsolete_legacy_backup(name: &str) -> bool {
(name.starts_with("network.json.treeguard-polluted.") && name.ends_with(".bak"))
|| name.starts_with("network.json.pre_")
@@ -487,7 +517,7 @@ fn copy_dir_all(source: &Path, destination: &Path) -> Result<(), RuntimeStateMig
#[cfg(test)]
mod tests {
use super::migrate_legacy_runtime_state;
- use crate::Config;
+ use crate::{Config, TOPOLOGY_IMPORT_FILENAME};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -637,4 +667,81 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
+
+ #[test]
+ fn quarantines_stale_manual_source_files_when_topology_import_is_ready() {
+ let root = temp_path("integration-source-quarantine");
+ let src = root.join("src");
+ fs::create_dir_all(&src).expect("test should create src dir");
+ fs::write(src.join("network.json"), "{\"Root\":{}}\n")
+ .expect("test should write legacy network source file");
+ fs::write(src.join("ShapedDevices.csv"), "Circuit ID,Device ID\n")
+ .expect("test should write legacy shaped devices source file");
+
+ let mut config = test_config(&root);
+ config.uisp_integration.enable_uisp = true;
+ let topology_state = config.resolved_state_directory().join("topology");
+ fs::create_dir_all(&topology_state).expect("test should create topology state dir");
+ fs::write(
+ topology_state.join(TOPOLOGY_IMPORT_FILENAME),
+ serde_json::json!({
+ "schema_version": 1,
+ "source": "test/import",
+ "compile_mode": "full",
+ "imported": {
+ "source": "test/import",
+ "compatibility_network_json": {},
+ "shaped_devices": [
+ {
+ "circuit_id": "circuit-1",
+ "device_id": "device-1"
+ }
+ ],
+ "circuit_anchors": { "anchors": [] },
+ "ethernet_advisories": []
+ }
+ })
+ .to_string(),
+ )
+ .expect("test should write topology import artifact");
+
+ migrate_legacy_runtime_state(&config).expect("stale manual source files should quarantine");
+
+ assert!(!src.join("network.json").exists());
+ assert!(!src.join("ShapedDevices.csv").exists());
+ assert!(root.join("state/quarantine/legacy/network.json").exists());
+ assert!(
+ root.join("state/quarantine/legacy/ShapedDevices.csv")
+ .exists()
+ );
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn keeps_manual_source_files_in_place_when_topology_import_is_missing() {
+ let root = temp_path("manual-source-preserved");
+ let src = root.join("src");
+ fs::create_dir_all(&src).expect("test should create src dir");
+ fs::write(src.join("network.json"), "{\"Root\":{}}\n")
+ .expect("test should write manual network source file");
+ fs::write(src.join("ShapedDevices.csv"), "Circuit ID,Device ID\n")
+ .expect("test should write manual shaped devices source file");
+
+ let mut config = test_config(&root);
+ config.uisp_integration.enable_uisp = true;
+ migrate_legacy_runtime_state(&config)
+ .expect("manual source files should remain until integration publishes data");
+
+ assert!(src.join("network.json").exists());
+ assert!(src.join("ShapedDevices.csv").exists());
+ assert!(!root.join("state/quarantine/legacy/network.json").exists());
+ assert!(
+ !root
+ .join("state/quarantine/legacy/ShapedDevices.csv")
+ .exists()
+ );
+
+ let _ = fs::remove_dir_all(root);
+ }
}
diff --git a/src/rust/lqos_config/src/topology_canonical_state.rs b/src/rust/lqos_config/src/topology_canonical_state.rs
index 2e253342..7fff434b 100644
--- a/src/rust/lqos_config/src/topology_canonical_state.rs
+++ b/src/rust/lqos_config/src/topology_canonical_state.rs
@@ -280,7 +280,7 @@ pub(crate) fn current_topology_ingress_identity(
) -> Result