Files
LibreQoS/src/scheduler.py
T
2026-04-08 17:47:56 +00:00

1164 lines
43 KiB
Python

import time
import datetime
import csv
import io
import json
import atexit
import chardet
from LibreQoS import refreshShapers, refreshShapersUpdateOnly
import subprocess
import sys
import tempfile
from io import StringIO
from liblqos_python import automatic_import_uisp, automatic_import_splynx, queue_refresh_interval_mins, \
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_shaping_runtime_hash, efficiency_core_ids, scheduler_alive, scheduler_error, \
scheduler_progress, overrides_persistent_devices_materialized, overrides_circuit_adjustments_materialized, \
overrides_network_adjustments_materialized, \
scheduler_output, wait_for_bus_ready
from apscheduler.schedulers.background import BlockingScheduler
from apscheduler.executors.pool import ThreadPoolExecutor
import os.path
import os
ads = BlockingScheduler(executors={'default': ThreadPoolExecutor(1)})
shaping_runtime_hash = 0
topology_runtime_process = None
topology_runtime_missing_reported = False
INTEGRATION_FAILURE_PREVIEW_LINES = 30
INTEGRATION_FAILURE_PREVIEW_CHARS = 4000
TOPOLOGY_RUNTIME_REFRESH_SECONDS = 3
SCHEDULER_STARTUP_STEP_COUNT = 5
SCHEDULER_REFRESH_STEP_COUNT = 4
def clear_scheduler_error():
"""Clear the scheduler error status shown in the Web UI."""
scheduler_error("")
def clear_scheduler_output():
"""Clear the scheduler output shown in the Web UI."""
scheduler_output("")
def _scheduler_progress_percent(step_index: int, step_count: int, *, active: bool) -> int:
if step_count <= 0:
return 0
bounded_step = max(1, min(int(step_index), int(step_count)))
completed_steps = bounded_step - 1 if active else bounded_step
return max(0, min(100, int(round((completed_steps / step_count) * 100))))
def publish_scheduler_progress(active: bool, phase: str, phase_label: str, step_index: int, step_count: int, *, percent=None):
try:
resolved_percent = _scheduler_progress_percent(step_index, step_count, active=active) if percent is None else int(percent)
resolved_percent = max(0, min(100, resolved_percent))
scheduler_progress(
bool(active),
str(phase),
str(phase_label),
int(step_index),
int(step_count),
resolved_percent,
)
except Exception as e:
print(f"Failed to publish scheduler progress: {e}")
def report_scheduler_runtime_failure(context: str, exc: Exception, *, startup: bool = False):
message = f"{context}: {exc}"
print(message)
scheduler_error(message)
if startup:
publish_scheduler_progress(
False,
"degraded",
"Scheduler running with topology/runtime error",
SCHEDULER_STARTUP_STEP_COUNT,
SCHEDULER_STARTUP_STEP_COUNT,
percent=100,
)
def _integration_output_lines(output):
normalized = (output or "").replace("\r\n", "\n").strip()
if not normalized:
return []
return normalized.split("\n")
def _summarize_output_preview(output, *, max_lines, max_chars):
lines = _integration_output_lines(output)
if not lines:
return ""
preview_lines = lines[:max_lines]
preview = "\n".join(preview_lines)
truncated = len(lines) > max_lines
if len(preview) > max_chars:
preview = preview[:max_chars].rstrip()
truncated = True
if truncated:
preview += "\n..."
return preview
def _sanitize_label_for_filename(label):
token = "".join(ch.lower() if ch.isalnum() else "_" for ch in (label or "integration"))
token = token.strip("_")
return token or "integration"
def _write_integration_output_artifact(label, output):
normalized = (output or "").replace("\r\n", "\n").strip()
if not normalized:
return None
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
artifact = os.path.join(
tempfile.gettempdir(),
f"lqos_scheduler_{_sanitize_label_for_filename(label)}_{timestamp}.log",
)
try:
with open(artifact, "w", encoding="utf-8") as handle:
handle.write(normalized)
handle.write("\n")
return artifact
except Exception as e:
print(f"Failed to write {label} output artifact: {e}")
return None
def _publish_integration_result(label, result):
output = ((result.stdout or "") + (result.stderr or "")).replace("\r\n", "\n").strip()
if result.returncode == 0:
line_count = len(_integration_output_lines(output))
summary = (
f"{label} completed successfully."
if line_count == 0
else f"{label} completed successfully. Captured {line_count} line(s) of output."
)
print(summary)
scheduler_output(summary)
return
preview = _summarize_output_preview(
output,
max_lines=INTEGRATION_FAILURE_PREVIEW_LINES,
max_chars=INTEGRATION_FAILURE_PREVIEW_CHARS,
)
artifact = _write_integration_output_artifact(label, output)
message = f"{label} exited with code {result.returncode}. Continuing."
if preview:
message += f"\nOutput preview:\n{preview}"
if artifact is not None:
message += f"\nFull output saved to {artifact}"
print(message)
scheduler_error(message)
def get_integration_affinity_cpus():
"""Return efficiency-core CPU IDs to prefer for integration subprocesses."""
try:
cpus = efficiency_core_ids()
except Exception as e:
msg = f"Failed to determine efficiency cores for integrations: {e}"
print(msg)
scheduler_error(msg)
return []
normalized = []
for cpu in cpus or []:
try:
normalized.append(int(cpu))
except (TypeError, ValueError):
continue
return sorted(set(cpu for cpu in normalized if cpu >= 0))
def _affinity_preexec(cpu_ids):
cpu_set = set(cpu_ids)
def apply_affinity():
os.sched_setaffinity(0, cpu_set)
return apply_affinity
def run_integration_subprocess(cmd, *, capture_output=True, text=True, cwd=None, label="integration"):
"""
Launch a scheduler-managed integration subprocess.
Prefer detected efficiency cores when available, but retry unpinned on failure.
"""
kwargs = {
"capture_output": capture_output,
"text": text,
}
if cwd is not None:
kwargs["cwd"] = cwd
cpu_ids = get_integration_affinity_cpus()
used_affinity = False
if cpu_ids and hasattr(os, "sched_setaffinity"):
kwargs["preexec_fn"] = _affinity_preexec(cpu_ids)
used_affinity = True
try:
return subprocess.run(cmd, **kwargs)
except Exception as e:
if not used_affinity:
raise
msg = (
f"Failed to pin {label} to efficiency cores {cpu_ids}: {e}. "
"Retrying without affinity."
)
print(msg)
scheduler_error(msg)
kwargs.pop("preexec_fn", None)
return subprocess.run(cmd, **kwargs)
def capture_output_and_run(func):
"""Capture stdout/stderr from a callable and ensure failures are non-fatal."""
old_stdout = sys.stdout
old_stderr = sys.stderr
captured_output = StringIO()
try:
sys.stdout = captured_output
sys.stderr = captured_output
func()
except BaseException as e:
# Catch BaseException to also handle SystemExit/KeyboardInterrupt from integrations
error_msg = f"Failed to execute function: {str(e)}"
try:
print(error_msg)
finally:
# Ensure scheduler gets error details even if printing fails
scheduler_error(error_msg)
finally:
# Always restore stdio and flush captured output
sys.stdout = old_stdout
sys.stderr = old_stderr
output = captured_output.getvalue()
if output:
preview = _summarize_output_preview(
output,
max_lines=INTEGRATION_FAILURE_PREVIEW_LINES,
max_chars=INTEGRATION_FAILURE_PREVIEW_CHARS,
)
artifact = _write_integration_output_artifact("captured_integration", output)
message = "Captured integration output."
if preview:
message += f"\nOutput preview:\n{preview}"
if artifact is not None:
message += f"\nFull output saved to {artifact}"
print(message)
scheduler_output(message)
def run_python_integration(module_name: str, func_name: str, label: str = ""):
"""
Run a Python integration in a subprocess so failures cannot terminate the scheduler.
Captures stdout/stderr, logs them, and continues regardless of exit code.
"""
try:
code = f"from {module_name} import {func_name} as f; f()"
cmd = [sys.executable, "-c", code]
friendly = label or f"{module_name}.{func_name}"
result = run_integration_subprocess(
cmd,
capture_output=True,
text=True,
label=friendly,
)
_publish_integration_result(friendly, result)
except Exception as e:
err = f"Failed to invoke integration {label or (module_name + '.' + func_name)}: {e}"
print(err)
scheduler_error(err)
def importFromCRM(
*,
integration_phase="running_integration",
integration_label="Running integration sync",
integration_step=2,
progress_step_count=SCHEDULER_STARTUP_STEP_COUNT,
overrides_phase="applying_overrides",
overrides_label="Applying overrides",
overrides_step=3,
):
clear_scheduler_error()
clear_scheduler_output()
publish_scheduler_progress(
True,
integration_phase,
integration_label,
integration_step,
progress_step_count,
)
# CRM Hooks
if automatic_import_uisp():
try:
# Execute UISP integration in a subprocess and keep going on failure
path = get_libreqos_directory() + "/bin/uisp_integration"
result = run_integration_subprocess(
[path],
capture_output=True,
text=True,
label="UISP integration",
)
_publish_integration_result("UISP integration", result)
blackboard_finish()
except Exception as e:
error_msg = f"Failed to run UISP integration: {str(e)}"
print(error_msg)
scheduler_error(error_msg)
elif automatic_import_splynx():
run_python_integration("integrationSplynx", "importFromSplynx", label="Splynx")
elif automatic_import_visp():
run_python_integration("integrationVISP", "importFromVISP", label="VISP")
elif automatic_import_netzur():
run_python_integration("integrationNetzur", "importFromNetzur", label="Netzur")
elif automatic_import_powercode():
run_python_integration("integrationPowercode", "importFromPowercode", label="Powercode")
elif automatic_import_sonar():
run_python_integration("integrationSonar", "importFromSonar", label="Sonar")
elif automatic_import_wispgate():
run_python_integration("integrationWISPGate", "importFromWISPGate", label="WISPGate")
# Post-CRM Hooks
path = get_libreqos_directory() + "/bin/post_integration_hook.sh"
binPath = get_libreqos_directory() + "/bin"
if os.path.isfile(path):
try:
subprocess.Popen(path, cwd=binPath)
except Exception as e:
msg = f"post_integration_hook.sh failed to launch: {e}"
print(msg)
scheduler_error(msg)
# Handle lqos_overrides
try:
publish_scheduler_progress(
True,
overrides_phase,
overrides_label,
overrides_step,
progress_step_count,
)
apply_lqos_overrides()
except Exception as e:
scheduler_error(f"Failed to apply lqos_overrides: {e}")
print(f"Failed to apply lqos_overrides: {e}")
# --------------- Overrides Handling ---------------
SHAPED_DEVICES_HEADER = [
"Circuit ID",
"Circuit Name",
"Device ID",
"Device Name",
"Parent Node",
"Parent Node ID",
"Anchor Node ID",
"MAC",
"IPv4",
"IPv6",
"Download Min Mbps",
"Upload Min Mbps",
"Download Max Mbps",
"Upload Max Mbps",
"Comment",
]
SHAPED_DEVICES_HEADER_WITH_SQM = [
*SHAPED_DEVICES_HEADER,
"sqm",
]
def normalize_shaped_devices_header(header_value):
return ''.join(ch for ch in str(header_value).lower() if ch.isalnum())
def shaped_devices_header_index_map(header):
legacy = {
'circuit_id': 0,
'circuit_name': 1,
'device_id': 2,
'device_name': 3,
'parent_node': 4,
'mac': 5,
'ipv4': 6,
'ipv6': 7,
'download_min': 8,
'upload_min': 9,
'download_max': 10,
'upload_max': 11,
'comment': 12,
'sqm': 13,
}
aliases = {
'circuit_id': {'circuitid', 'circuit_id'},
'circuit_name': {'circuitname', 'circuit_name'},
'device_id': {'deviceid', 'device_id'},
'device_name': {'devicename', 'device_name'},
'parent_node': {'parentnode', 'parent_node'},
'parent_node_id': {'parentnodeid', 'parent_node_id'},
'anchor_node_id': {'anchornodeid', 'anchor_node_id', 'id'},
'mac': {'mac'},
'ipv4': {'ipv4'},
'ipv6': {'ipv6'},
'download_min': {'downloadminmbps', 'downloadmin', 'download_min_mbps'},
'upload_min': {'uploadminmbps', 'uploadmin', 'upload_min_mbps'},
'download_max': {'downloadmaxmbps', 'downloadmax', 'download_max_mbps'},
'upload_max': {'uploadmaxmbps', 'uploadmax', 'upload_max_mbps'},
'comment': {'comment'},
'sqm': {'sqm'},
}
layout = dict(legacy)
layout['parent_node_id'] = None
layout['anchor_node_id'] = None
for idx, name in enumerate(header or []):
normalized = normalize_shaped_devices_header(name)
for field, names in aliases.items():
if normalized in names:
layout[field] = idx
break
return layout
def set_row_value_by_header(row, header_map, field, value):
index = header_map.get(field)
if index is None:
return
while len(row) <= index:
row.append('')
row[index] = value
def shaped_devices_csv_path() -> str:
base_dir = get_libreqos_directory()
return base_dir + "/ShapedDevices.csv"
def read_shaped_devices_csv(path: str):
"""Read CSV with comment stripping and header handling. Returns (header, rows)."""
if not os.path.isfile(path):
return SHAPED_DEVICES_HEADER, []
with open(path, 'rb') as f:
raw_bytes = f.read()
# Handle BOMs and encoding similar to LibreQoS.py
if raw_bytes.startswith(b'\xef\xbb\xbf'):
raw_bytes = raw_bytes[3:]
text_content = raw_bytes.decode('utf-8')
elif raw_bytes.startswith(b'\xff\xfe') or raw_bytes.startswith(b'\xfe\xff'):
text_content = raw_bytes.decode('utf-16')
else:
try:
text_content = raw_bytes.decode('utf-8')
except UnicodeDecodeError:
detected = chardet.detect(raw_bytes)
encoding = detected['encoding'] or 'utf-8'
text_content = raw_bytes.decode(encoding, errors='replace')
with io.StringIO(text_content) as csv_file:
reader = csv.reader(csv_file, delimiter=',')
rows = [row for row in reader if row and not row[0].startswith('#')]
if not rows:
return SHAPED_DEVICES_HEADER, []
header = rows[0]
data_rows = rows[1:]
if len(header) < len(SHAPED_DEVICES_HEADER):
header = list(header) + SHAPED_DEVICES_HEADER[len(header):]
target_len = len(header)
if target_len > 0:
for row in data_rows:
if len(row) < target_len:
row.extend([""] * (target_len - len(row)))
return header, data_rows
def override_devices_to_rows(devices, header, include_sqm=False):
"""Convert override device dicts to CSV rows, preserving the existing CSV shape by default."""
rows = []
header_map = shaped_devices_header_index_map(header)
row_len = max(len(header), len(SHAPED_DEVICES_HEADER_WITH_SQM if include_sqm else SHAPED_DEVICES_HEADER))
for d in devices:
ipv4s = d.get('ipv4s', [])
ipv6s = d.get('ipv6s', [])
sqm = d.get('sqm', '') or d.get('sqm_override', '')
sqm = sqm or ""
row = [''] * row_len
set_row_value_by_header(row, header_map, 'circuit_id', d.get('circuitID', ''))
set_row_value_by_header(row, header_map, 'circuit_name', d.get('circuitName', ''))
set_row_value_by_header(row, header_map, 'device_id', d.get('deviceID', ''))
set_row_value_by_header(row, header_map, 'device_name', d.get('deviceName', ''))
set_row_value_by_header(row, header_map, 'parent_node', d.get('ParentNode', ''))
set_row_value_by_header(row, header_map, 'parent_node_id', d.get('ParentNodeID', ''))
set_row_value_by_header(row, header_map, 'anchor_node_id', d.get('AnchorNodeID', ''))
set_row_value_by_header(row, header_map, 'mac', d.get('mac', ''))
set_row_value_by_header(row, header_map, 'ipv4', ','.join(ipv4s))
set_row_value_by_header(row, header_map, 'ipv6', ','.join(ipv6s))
set_row_value_by_header(row, header_map, 'download_min', str(d.get('minDownload', '')))
set_row_value_by_header(row, header_map, 'upload_min', str(d.get('minUpload', '')))
set_row_value_by_header(row, header_map, 'download_max', str(d.get('maxDownload', '')))
set_row_value_by_header(row, header_map, 'upload_max', str(d.get('maxUpload', '')))
set_row_value_by_header(row, header_map, 'comment', d.get('comment', ''))
if include_sqm:
set_row_value_by_header(row, header_map, 'sqm', str(sqm))
rows.append(row)
return rows
def merge_rows_replace_by_device_id(existing_rows, override_rows):
"""Replace existing rows by device_id if present, else append."""
index_by_device = {}
for idx, row in enumerate(existing_rows):
if len(row) >= 3:
index_by_device[row[2]] = idx
merged = list(existing_rows)
changed = False
for o in override_rows:
device_id = o[2] if len(o) >= 3 else ''
if device_id in index_by_device:
idx = index_by_device[device_id]
if merged[idx] != o:
merged[idx] = o
changed = True
else:
merged.append(o)
changed = True
return merged, changed
def write_shaped_devices_csv(path: str, header, rows):
with open(path, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
writer.writerow(header)
writer.writerows(rows)
def header_has_sqm(header):
return shaped_devices_header_index_map(header).get('sqm') is not None and any(
normalize_shaped_devices_header(value) == 'sqm' for value in (header or [])
)
def operator_requires_sqm_column(devices, adjustments):
if any(((d.get('sqm', '') or d.get('sqm_override', '') or '').strip()) for d in devices or []):
return True
return any(
adj.get('type') == 'device_adjust_sqm' and (adj.get('sqm_override') or '').strip()
for adj in adjustments or []
)
def apply_lqos_overrides():
"""Load ShapedDevices.csv, apply persistent devices and circuit adjustments, and save back."""
path = shaped_devices_csv_path()
header, rows = read_shaped_devices_csv(path)
# 1) Persistent devices: replace by device_id or append
try:
extra = overrides_persistent_devices_materialized()
except Exception as e:
# Persistent device overrides are optional. Keep the scheduler healthy
# and continue applying the rest of the override sources if this loader
# is unavailable or temporarily broken.
print(f"Skipping persistent device overrides: {e}")
extra = []
# 2) Circuit adjustments: speed changes, removals, reparenting
try:
adjustments = overrides_circuit_adjustments_materialized()
except Exception as e:
print(f"Failed to read circuit adjustments: {e}")
adjustments = []
need_sqm_column = header_has_sqm(header) or operator_requires_sqm_column(extra, adjustments)
if need_sqm_column and not header_has_sqm(header):
header = list(header) + ["sqm"]
for row in rows:
if len(row) < len(header):
row.extend([""] * (len(header) - len(row)))
override_rows = override_devices_to_rows(extra or [], header, include_sqm=need_sqm_column)
merged_rows, changed = merge_rows_replace_by_device_id(rows, override_rows)
header_map = shaped_devices_header_index_map(header)
def set_if_some(value_opt, current_str):
if value_opt is None:
return current_str
try:
return str(float(value_opt))
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')
if t == 'circuit_adjust_speed':
cid = adj.get('circuit_id', '')
for r in merged_rows:
if len(r) >= 12 and r[0] == cid:
r[8] = set_if_some(adj.get('min_download_bandwidth'), r[8] if len(r) > 8 else '')
r[10] = set_if_some(adj.get('max_download_bandwidth'), r[10] if len(r) > 10 else '')
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_speed':
did = adj.get('device_id', '')
for r in merged_rows:
if len(r) >= 12 and r[2] == did:
r[8] = set_if_some(adj.get('min_download_bandwidth'), r[8] if len(r) > 8 else '')
r[10] = set_if_some(adj.get('max_download_bandwidth'), r[10] if len(r) > 10 else '')
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':
if not need_sqm_column:
continue
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)
merged_rows = [r for r in merged_rows if len(r) < 1 or r[0] != cid]
if len(merged_rows) != before:
changed = True
elif t == 'remove_device':
did = adj.get('device_id', '')
before = len(merged_rows)
merged_rows = [r for r in merged_rows if len(r) < 3 or r[2] != did]
if len(merged_rows) != before:
changed = True
elif t == 'reparent_circuit':
cid = adj.get('circuit_id', '')
parent_node = adj.get('parent_node', '')
for r in merged_rows:
circuit_id_idx = header_map.get('circuit_id', 0)
if len(r) > circuit_id_idx and r[circuit_id_idx] == cid:
set_row_value_by_header(r, header_map, 'parent_node', parent_node)
set_row_value_by_header(r, header_map, 'parent_node_id', '')
changed = True
if changed:
final_header = header if header else (SHAPED_DEVICES_HEADER_WITH_SQM if need_sqm_column else SHAPED_DEVICES_HEADER)
write_shaped_devices_csv(path, final_header, merged_rows)
print("Updated ShapedDevices.csv with overrides")
# 3) Load, adjust, and optionally save network.json
nj_path = network_json_path()
network = load_network_json(nj_path)
try:
adjustments = overrides_network_adjustments_materialized()
except Exception as e:
print(f"Failed to read network adjustments: {e}")
adjustments = []
net_changed = apply_network_adjustments(network, adjustments)
if net_changed:
write_network_json(nj_path, network)
print("Updated network.json with overrides")
canonical_path = topology_canonical_state_path()
canonical_state = load_topology_canonical_state(canonical_path)
if canonical_state and apply_network_adjustments_to_canonical_state(canonical_state, adjustments):
write_topology_canonical_state(canonical_path, canonical_state)
print("Updated topology_canonical_state.json with overrides")
# --------------- Network JSON handling ---------------
def network_json_path() -> str:
base_dir = get_libreqos_directory()
if enable_insight_topology():
insight_path = os.path.join(base_dir, "network.insight.json")
if os.path.exists(insight_path):
return insight_path
return os.path.join(base_dir, "network.json")
def load_network_json(path: str):
if not os.path.isfile(path):
return {}
with open(path, 'r', encoding='utf-8') as f:
try:
return json.loads(f.read())
except Exception:
return {}
def topology_canonical_state_path() -> str:
return os.path.join(get_libreqos_directory(), "topology_canonical_state.json")
def load_topology_canonical_state(path: str):
if not os.path.isfile(path):
return None
with open(path, 'r', encoding='utf-8') as f:
try:
return json.loads(f.read())
except Exception:
return None
def write_topology_canonical_state(path: str, canonical_state: dict):
with open(path, 'w', encoding='utf-8') as f:
f.write(json.dumps(canonical_state, indent=4))
def apply_network_adjustments(network: dict, adjustments=None) -> bool:
"""Apply network adjustments from overrides to the network JSON structure.
Currently supports: adjust_site_speed (preferring node_id, with legacy
site_name fallback) updating downloadBandwidthMbps and
uploadBandwidthMbps at the matching node, and set_node_virtual (by
node_name) updating the boolean 'virtual' flag for operator-authored
topology overrides.
This path intentionally excludes runtime automation changes such as
StormGuard adaptive site-speed overrides and TreeGuard virtual-node
decisions so they do not overwrite the operator-authored `network.json`
source of truth.
Returns True if any changes were applied.
"""
if adjustments is None:
try:
adjustments = overrides_network_adjustments_materialized()
except Exception as e:
print(f"Failed to read network adjustments: {e}")
return False
if not adjustments:
return False
def normalize_bandwidth_value(value):
numeric = float(value)
if numeric.is_integer():
return int(numeric)
return numeric
def adjust_node(tree: dict, site: str, node_id, dl_opt, ul_opt) -> bool:
changed_local = False
for key in list(tree.keys()):
if key == 'children':
child = tree.get('children')
if isinstance(child, dict):
if adjust_node(child, site, node_id, dl_opt, ul_opt):
changed_local = True
continue
node = tree.get(key)
if isinstance(node, dict):
current_node_id = node.get('id')
matches_target = False
if node_id:
matches_target = current_node_id == node_id
elif key == site:
matches_target = True
if matches_target:
if dl_opt is not None:
node['downloadBandwidthMbps'] = normalize_bandwidth_value(dl_opt)
changed_local = True
if ul_opt is not None:
node['uploadBandwidthMbps'] = normalize_bandwidth_value(ul_opt)
changed_local = True
# Recurse into children
if 'children' in node and isinstance(node['children'], dict):
if adjust_node(node['children'], site, node_id, dl_opt, ul_opt):
changed_local = True
return changed_local
def set_virtual_flag(tree: dict, node_name: str, virtual_val) -> bool:
changed_local = False
v = bool(virtual_val)
for key in list(tree.keys()):
if key == 'children':
child = tree.get('children')
if isinstance(child, dict):
if set_virtual_flag(child, node_name, v):
changed_local = True
continue
node = tree.get(key)
if isinstance(node, dict):
if key == node_name:
prev = bool(node.get('virtual', False))
if prev != v:
node['virtual'] = v
changed_local = True
if 'children' in node and isinstance(node['children'], dict):
if set_virtual_flag(node['children'], node_name, v):
changed_local = True
return changed_local
net_changed = False
for adj in adjustments:
if adj.get('type') == 'adjust_site_speed':
site = adj.get('site_name', '')
node_id = adj.get('node_id', None)
dl = adj.get('download_bandwidth_mbps', None)
ul = adj.get('upload_bandwidth_mbps', None)
if site or node_id:
if adjust_node(network, site, node_id, dl, ul):
net_changed = True
elif adj.get('type') == 'set_node_virtual':
node_name = adj.get('node_name', '')
v = adj.get('virtual', None)
if node_name and v is not None:
if set_virtual_flag(network, node_name, v):
net_changed = True
return net_changed
def apply_network_adjustments_to_canonical_state(canonical_state: dict, adjustments) -> bool:
if not isinstance(canonical_state, dict):
return False
compatibility_network = canonical_state.get('compatibility_network_json')
nodes = canonical_state.get('nodes')
if not isinstance(compatibility_network, dict) or not isinstance(nodes, list):
return False
compatibility_changed = apply_network_adjustments(compatibility_network, adjustments)
def normalize_bandwidth_value(value):
numeric = float(value)
if numeric.is_integer():
return int(numeric)
return numeric
nodes_changed = False
for adj in adjustments:
adj_type = adj.get('type')
if adj_type == 'adjust_site_speed':
target_node_id = adj.get('node_id', None)
target_name = adj.get('site_name', '')
download = adj.get('download_bandwidth_mbps', None)
upload = adj.get('upload_bandwidth_mbps', None)
for node in nodes:
if not isinstance(node, dict):
continue
matches_target = False
if target_node_id:
matches_target = node.get('node_id') == target_node_id
elif target_name:
matches_target = node.get('node_name') == target_name
if not matches_target:
continue
rate_input = node.get('rate_input')
if not isinstance(rate_input, dict):
rate_input = {}
node['rate_input'] = rate_input
if download is not None:
normalized_download = normalize_bandwidth_value(download)
rate_input['intrinsic_download_mbps'] = normalized_download
rate_input['legacy_imported_download_mbps'] = normalized_download
nodes_changed = True
if upload is not None:
normalized_upload = normalize_bandwidth_value(upload)
rate_input['intrinsic_upload_mbps'] = normalized_upload
rate_input['legacy_imported_upload_mbps'] = normalized_upload
nodes_changed = True
if download is not None or upload is not None:
rate_input['source'] = 'compatibility_export'
elif adj_type == 'set_node_virtual':
target_name = adj.get('node_name', '')
virtual_val = adj.get('virtual', None)
if not target_name or virtual_val is None:
continue
normalized_virtual = bool(virtual_val)
for node in nodes:
if not isinstance(node, dict) or node.get('node_name') != target_name:
continue
if bool(node.get('is_virtual', False)) != normalized_virtual:
node['is_virtual'] = normalized_virtual
nodes_changed = True
return compatibility_changed or nodes_changed
def write_network_json(path: str, network: dict):
with open(path, 'w', encoding='utf-8') as f:
f.write(json.dumps(network, indent=4))
def importAndShapeFullReload():
global shaping_runtime_hash
importFromCRM()
publish_scheduler_progress(True, "starting_topology_runtime", "Starting topology runtime", 4, SCHEDULER_STARTUP_STEP_COUNT)
ensure_topology_runtime_process(wait_for_outputs=True)
publish_scheduler_progress(True, "initial_shaping_reload", "Refreshing shaper state", 5, SCHEDULER_STARTUP_STEP_COUNT)
if not enable_insight_topology():
refreshShapers()
shaping_runtime_hash = calculate_shaping_runtime_hash()
else:
shaping_runtime_hash = calculate_shaping_runtime_hash()
def importAndShapePartialReload():
global shaping_runtime_hash
importFromCRM(
integration_phase="partial_integration",
integration_label="Running scheduled integration refresh",
integration_step=1,
progress_step_count=SCHEDULER_REFRESH_STEP_COUNT,
overrides_phase="partial_overrides",
overrides_label="Applying scheduled overrides",
overrides_step=2,
)
publish_scheduler_progress(True, "partial_topology_runtime", "Refreshing topology runtime", 3, SCHEDULER_REFRESH_STEP_COUNT)
ensure_topology_runtime_process(wait_for_outputs=True)
# Rebuild when runtime shaping inputs change, including effective adaptive
# circuit overrides that do not belong in source-of-truth files.
publish_scheduler_progress(True, "partial_runtime_hash", "Checking shaping inputs", 4, SCHEDULER_REFRESH_STEP_COUNT)
new_hash = calculate_shaping_runtime_hash()
if new_hash != shaping_runtime_hash:
publish_scheduler_progress(True, "partial_reload", "Applying incremental shaper refresh", 4, SCHEDULER_REFRESH_STEP_COUNT)
try:
refreshShapers()
except Exception as e:
report_scheduler_runtime_failure("Scheduled shaping refresh failed", e)
return
shaping_runtime_hash = calculate_shaping_runtime_hash()
publish_scheduler_progress(False, "ready", "Scheduler ready", SCHEDULER_REFRESH_STEP_COUNT, SCHEDULER_REFRESH_STEP_COUNT, percent=100)
def topology_runtime_binary_path():
return os.path.join(get_libreqos_directory(), "bin", "lqos_topology")
def topology_runtime_output_paths():
base_dir = get_libreqos_directory()
return [
os.path.join(base_dir, "topology_attachment_health_state.json"),
os.path.join(base_dir, "topology_effective_state.json"),
os.path.join(base_dir, "network.effective.json"),
os.path.join(base_dir, "shaping_inputs.json"),
]
def _load_json_field(path, field):
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except Exception:
return None
value = data.get(field)
try:
return int(value) if value is not None else None
except Exception:
return None
def _topology_runtime_freshness_target():
base_dir = get_libreqos_directory()
canonical_path = os.path.join(base_dir, "topology_canonical_state.json")
canonical_generated = _load_json_field(canonical_path, "generated_unix")
if canonical_generated is not None:
return ("generated_unix", canonical_generated)
editor_path = os.path.join(base_dir, "topology_editor_state.json")
editor_generated = _load_json_field(editor_path, "generated_unix")
if editor_generated is not None:
return ("generated_unix", editor_generated)
for path in (canonical_path, editor_path, os.path.join(base_dir, "network.json")):
if os.path.isfile(path):
return ("mtime", os.path.getmtime(path))
return (None, None)
def _topology_runtime_outputs_are_fresh():
_, effective_state_path, effective_network_path, shaping_inputs_path = topology_runtime_output_paths()
if not (
os.path.isfile(effective_state_path)
and os.path.isfile(effective_network_path)
and os.path.isfile(shaping_inputs_path)
):
return False
target_kind, target_value = _topology_runtime_freshness_target()
if target_kind is None:
return True
if target_kind == "generated_unix":
effective_canonical_generated = _load_json_field(effective_state_path, "canonical_generated_unix")
if effective_canonical_generated is not None:
return effective_canonical_generated >= target_value
try:
effective_state_mtime = os.path.getmtime(effective_state_path)
effective_network_mtime = os.path.getmtime(effective_network_path)
shaping_inputs_mtime = os.path.getmtime(shaping_inputs_path)
except OSError:
return False
shaped_devices_path = os.path.join(get_libreqos_directory(), "ShapedDevices.csv")
circuit_anchors_path = os.path.join(get_libreqos_directory(), "circuit_anchors.json")
if os.path.isfile(shaped_devices_path) and shaping_inputs_mtime < os.path.getmtime(shaped_devices_path):
return False
if os.path.isfile(circuit_anchors_path) and shaping_inputs_mtime < os.path.getmtime(circuit_anchors_path):
return False
return min(effective_state_mtime, effective_network_mtime, shaping_inputs_mtime) >= target_value
def clear_topology_runtime_outputs():
for path in topology_runtime_output_paths():
try:
os.remove(path)
except FileNotFoundError:
continue
except Exception as e:
print(f"Failed to remove topology runtime artifact {path}: {e}")
def stop_topology_runtime_process():
global topology_runtime_process
process = topology_runtime_process
topology_runtime_process = None
if process is None:
return
if process.poll() is not None:
return
try:
process.terminate()
process.wait(timeout=5)
except Exception:
try:
process.kill()
except Exception:
pass
def wait_for_topology_runtime_outputs(timeout_seconds=8.0):
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
if _topology_runtime_outputs_are_fresh():
return True
process = topology_runtime_process
if process is not None and process.poll() is not None:
return False
time.sleep(0.1)
return False
def ensure_topology_runtime_process(wait_for_outputs=False):
global topology_runtime_process
global topology_runtime_missing_reported
binary = topology_runtime_binary_path()
if not os.path.isfile(binary):
if not topology_runtime_missing_reported:
print(f"Topology runtime helper is unavailable at {binary}. Rain suppression is disabled.")
topology_runtime_missing_reported = True
clear_topology_runtime_outputs()
topology_runtime_process = None
return False
topology_runtime_missing_reported = False
if topology_runtime_process is not None:
code = topology_runtime_process.poll()
if code is None:
if wait_for_outputs:
wait_for_topology_runtime_outputs()
return True
print(f"Topology runtime helper exited with code {code}. Restarting it.")
clear_topology_runtime_outputs()
topology_runtime_process = None
try:
topology_runtime_process = subprocess.Popen(
[binary],
cwd=get_libreqos_directory(),
)
print("Started topology runtime helper.")
if wait_for_outputs:
wait_for_topology_runtime_outputs()
return True
except Exception as e:
print(f"Failed to start topology runtime helper: {e}")
clear_topology_runtime_outputs()
topology_runtime_process = None
return False
def topology_runtime_refresh_tick():
global shaping_runtime_hash
ensure_topology_runtime_process()
new_hash = calculate_shaping_runtime_hash()
if new_hash == 0 or new_hash == shaping_runtime_hash:
return
try:
refreshShapers()
except Exception as e:
report_scheduler_runtime_failure("Topology runtime refresh failed", e)
return
shaping_runtime_hash = calculate_shaping_runtime_hash()
def not_dead_yet():
#print(f"Scheduler alive at {datetime.datetime.now()}")
scheduler_alive()
def ensure_bus_ready():
"""Wait briefly for lqosd to finish binding the local bus socket."""
wait_for_bus_ready(5000)
def run_scheduler_main():
global shaping_runtime_hash
atexit.register(stop_topology_runtime_process)
publish_scheduler_progress(True, "waiting_for_bus", "Waiting for lqosd bus", 1, SCHEDULER_STARTUP_STEP_COUNT)
ensure_bus_ready()
try:
importAndShapeFullReload()
shaping_runtime_hash = calculate_shaping_runtime_hash()
publish_scheduler_progress(False, "ready", "Scheduler ready", SCHEDULER_STARTUP_STEP_COUNT, SCHEDULER_STARTUP_STEP_COUNT, percent=100)
except Exception as e:
report_scheduler_runtime_failure("Scheduler startup shaping refresh failed", e, startup=True)
import traceback
traceback.print_exc()
shaping_runtime_hash = 0
print("Starting scheduler with jobs:")
print(f"- not_dead_yet every 1 minute")
refresh_interval = queue_refresh_interval_mins()
print(f"- topology_runtime_refresh_tick every {TOPOLOGY_RUNTIME_REFRESH_SECONDS} seconds")
print(f"- importAndShapePartialReload every {refresh_interval} minutes")
not_dead_yet()
ads.add_job(not_dead_yet, 'interval', minutes=1, max_instances=1)
ads.add_job(topology_runtime_refresh_tick, 'interval', seconds=TOPOLOGY_RUNTIME_REFRESH_SECONDS, max_instances=1)
ads.add_job(importAndShapePartialReload, 'interval', minutes=refresh_interval, max_instances=1)
print("Scheduler starting...")
ads.start()
if __name__ == '__main__':
try:
run_scheduler_main()
except Exception as e:
print(f"Error starting scheduler: {e}")
import traceback
traceback.print_exc()