mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 20:32:15 +00:00
Merge pull request #595 from jcastro/feature/monitor-ovs-topology
feat(monitor): show Open vSwitch network topology
This commit is contained in:
@@ -32,6 +32,11 @@ on:
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/ovs_topology.py'
|
||||
- 'AppImage/scripts/tests/test_ovs_topology.py'
|
||||
- 'AppImage/components/network-flow.tsx'
|
||||
- 'AppImage/components/network-metrics.tsx'
|
||||
- 'AppImage/tests/ovs-network-flow.cjs'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
@@ -93,6 +98,11 @@ on:
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/ovs_topology.py'
|
||||
- 'AppImage/scripts/tests/test_ovs_topology.py'
|
||||
- 'AppImage/components/network-flow.tsx'
|
||||
- 'AppImage/components/network-metrics.tsx'
|
||||
- 'AppImage/tests/ovs-network-flow.cjs'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
- 'AppImage/components/virtual-machines.tsx'
|
||||
@@ -435,3 +445,23 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Exercise private images with isolated mapped Linux readers
|
||||
run: sudo /usr/bin/python3 -B -m unittest discover -s oci/tests -p test_native_import_access.py -v
|
||||
|
||||
network-topology:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Test read-only OVS discovery and mixed network API
|
||||
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_ovs_topology.py -v
|
||||
- name: Test both Network Flow layouts
|
||||
run: |
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/ovs-network-flow.cjs
|
||||
|
||||
@@ -62,7 +62,7 @@ type Bridge = {
|
||||
type Guest = {
|
||||
id: string
|
||||
label: string
|
||||
kind: "lxc" | "vm" | "host"
|
||||
kind: "lxc" | "vm" | "host" | "interface"
|
||||
bridge: string
|
||||
rx: number
|
||||
tx: number
|
||||
@@ -87,6 +87,8 @@ const ICONS: Record<string, string> = {
|
||||
vm: `<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>`,
|
||||
}
|
||||
|
||||
ICONS.interface = ICONS.bridge
|
||||
|
||||
const COLORS = {
|
||||
host: "var(--amber-500, #f59e0b)",
|
||||
lxc: "var(--cyan-500, #06b6d4)",
|
||||
@@ -97,6 +99,7 @@ const COLORS = {
|
||||
// just another NIC.
|
||||
bond: "var(--orange-500, #f97316)",
|
||||
bridge: "var(--cyan-500, #06b6d4)",
|
||||
interface: "var(--cyan-500, #06b6d4)",
|
||||
gray: "#525252",
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ interface NetworkInterface {
|
||||
// Master device resolved from /sys/class/net/<iface>/master — the
|
||||
// bridge for a guest tap, the bond for a slave NIC.
|
||||
bridge_owner?: string
|
||||
network_backend?: "ovs"
|
||||
ovs_tag?: number | null
|
||||
ovs_trunks?: number[]
|
||||
bridge_members?: string[]
|
||||
bridge_physical_interface?: string
|
||||
bridge_bond_slaves?: string[]
|
||||
@@ -568,6 +571,7 @@ export function NetworkMetrics() {
|
||||
...(networkData.bond_interfaces || []),
|
||||
...(networkData.bridge_interfaces || []),
|
||||
...(networkData.vm_lxc_interfaces || []),
|
||||
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||
]
|
||||
|
||||
const vmLxcInterfaces = (networkData.vm_lxc_interfaces || []).sort((a, b) => {
|
||||
@@ -867,6 +871,7 @@ export function NetworkMetrics() {
|
||||
...(networkData.bond_interfaces || []),
|
||||
...(networkData.bridge_interfaces || []),
|
||||
...(networkData.vm_lxc_interfaces || []),
|
||||
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||
]
|
||||
const flowData: NetworkFlowData = {
|
||||
nics: (networkData.physical_interfaces || []).map((p) => ({
|
||||
@@ -916,7 +921,9 @@ export function NetworkMetrics() {
|
||||
tx: runningGuests.reduce((a, v) => a + toMBps(v.tx_Bps), 0),
|
||||
}
|
||||
})(),
|
||||
...(networkData.vm_lxc_interfaces || []).map((v) => {
|
||||
...[...(networkData.vm_lxc_interfaces || []),
|
||||
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||
].map((v) => {
|
||||
// Authoritative bridge from the kernel (read by the
|
||||
// backend from /sys/class/net/<iface>/master). Fallback
|
||||
// to bridge_members scan, then first bridge as last
|
||||
@@ -929,8 +936,8 @@ export function NetworkMetrics() {
|
||||
(networkData.bridge_interfaces?.[0]?.name || "")
|
||||
return {
|
||||
id: v.name,
|
||||
label: v.vm_name || v.name,
|
||||
kind: (v.vm_type === "vm" ? "vm" : "lxc") as "vm" | "lxc",
|
||||
label: v.vm_name || (v.ovs_tag != null ? `${v.name} · VLAN ${v.ovs_tag}` : v.name),
|
||||
kind: v.type === "vlan" ? "interface" as const : (v.vm_type === "vm" ? "vm" as const : "lxc" as const),
|
||||
bridge: ownerName,
|
||||
rx: toMBps(v.rx_Bps),
|
||||
tx: toMBps(v.tx_Bps),
|
||||
@@ -1213,6 +1220,7 @@ export function NetworkMetrics() {
|
||||
...(modalNetworkData.bond_interfaces || []),
|
||||
...(modalNetworkData.bridge_interfaces || []),
|
||||
...(modalNetworkData.vm_lxc_interfaces || []),
|
||||
...(modalNetworkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||
].find((iface) => iface.name === selectedInterface.name)
|
||||
: selectedInterface
|
||||
|
||||
@@ -1298,7 +1306,7 @@ export function NetworkMetrics() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">MTU</div>
|
||||
<div className="font-medium">{displayInterface.mtu}</div>
|
||||
<div className="font-medium">{displayInterface.mtu || t("common.notAvailable")}</div>
|
||||
</div>
|
||||
{displayInterface.mac_address && (
|
||||
<div className="col-span-2">
|
||||
|
||||
@@ -140,6 +140,7 @@ cd "$SCRIPT_DIR"
|
||||
# Copy Flask server
|
||||
echo "📋 Copying Flask server..."
|
||||
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/ovs_topology.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/zram_metrics.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
|
||||
cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
|
||||
|
||||
@@ -53,6 +53,7 @@ import threading
|
||||
import urllib.parse
|
||||
import uuid
|
||||
import disk_identity
|
||||
import ovs_topology
|
||||
import hardware_monitor
|
||||
from health_persistence import health_persistence
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -6258,17 +6259,24 @@ def _iface_master(iface_name):
|
||||
# Chain: veth/tap → fwbr (intermediate) → fwpr (peer) → vmbr (real).
|
||||
def _resolve_bridge_owner(iface_name):
|
||||
import re
|
||||
ovs_owners = ovs_topology.get_topology().get('owners', {})
|
||||
if iface_name in ovs_owners:
|
||||
return ovs_owners[iface_name]
|
||||
master = _iface_master(iface_name)
|
||||
if not master:
|
||||
return None
|
||||
if master.startswith('fwbr'):
|
||||
fwpr = re.sub(r'^fwbr', 'fwpr', master)
|
||||
fwpr = re.sub(r'i(\d+)$', r'p\1', fwpr)
|
||||
# A Linux bridge reaches the firewall bridge through fwprNpM; an OVS
|
||||
# bridge through its own internal port fwlnNoM.
|
||||
suffix = master[len('fwbr'):]
|
||||
links = (re.sub(r'i(\d+)$', r'p\1', 'fwpr' + suffix),
|
||||
re.sub(r'i(\d+)$', r'o\1', 'fwln' + suffix))
|
||||
try:
|
||||
if os.path.exists(f'/sys/class/net/{fwpr}'):
|
||||
real = _iface_master(fwpr)
|
||||
if real:
|
||||
return real
|
||||
for link in links:
|
||||
if os.path.exists(f'/sys/class/net/{link}'):
|
||||
real = ovs_owners.get(link) or _iface_master(link)
|
||||
if real:
|
||||
return real
|
||||
except Exception:
|
||||
pass
|
||||
return master
|
||||
@@ -6351,6 +6359,10 @@ def get_interface_type(interface_name):
|
||||
if interface_name.startswith(('tun', 'vnet', 'docker', 'virbr')):
|
||||
return 'skip'
|
||||
|
||||
ovs_type = ovs_topology.get_topology().get('types', {}).get(interface_name)
|
||||
if ovs_type:
|
||||
return ovs_type
|
||||
|
||||
# Check if it's a bond — sysfs first (name-agnostic), then the
|
||||
# "bondN" convention as fallback when /sys isn't readable.
|
||||
if _is_bond_master(interface_name) or interface_name.startswith('bond'):
|
||||
@@ -6590,6 +6602,7 @@ def get_network_info():
|
||||
pass
|
||||
|
||||
vm_lxc_map = get_vm_lxc_names()
|
||||
ovs = ovs_topology.get_topology()
|
||||
|
||||
# Get network interfaces
|
||||
net_if_addrs = psutil.net_if_addrs()
|
||||
@@ -6724,8 +6737,10 @@ def get_network_info():
|
||||
# vmbr so a guest behind the Proxmox firewall still lands
|
||||
# under its true bridge in the Network Flow diagram.
|
||||
master = _resolve_bridge_owner(interface_name)
|
||||
master = ovs.get('owners', {}).get(interface_name) or master
|
||||
if master:
|
||||
interface_info['bridge_owner'] = master
|
||||
interface_info.update(ovs.get('ports', {}).get(interface_name, {}))
|
||||
|
||||
if interface_type == 'bond':
|
||||
bond_info = get_bond_info(interface_name)
|
||||
@@ -6738,6 +6753,12 @@ def get_network_info():
|
||||
|
||||
if interface_type == 'bridge':
|
||||
bridge_info = get_bridge_info(interface_name)
|
||||
if interface_name in ovs.get('bridges', {}):
|
||||
bridge_info['members'] = ovs['bridges'][interface_name]
|
||||
bridge_info['physical_interface'] = next((member for member in bridge_info['members']
|
||||
if member in ovs.get('bonds', {}) or _is_physical_dev(member)), None)
|
||||
bridge_info['bond_slaves'] = ovs.get('bonds', {}).get(bridge_info['physical_interface'], {}).get('bond_slaves', [])
|
||||
interface_info['network_backend'] = 'ovs'
|
||||
interface_info['bridge_members'] = bridge_info['members']
|
||||
interface_info['bridge_physical_interface'] = bridge_info['physical_interface']
|
||||
interface_info['bridge_physical_duplex'] = bridge_info['physical_duplex']
|
||||
@@ -6758,6 +6779,22 @@ def get_network_info():
|
||||
if interface_type == 'bond':
|
||||
network_data['bond_interfaces'].append(interface_info)
|
||||
|
||||
# An OVS bond is an OVSDB port, not a kernel network interface.
|
||||
for name, detail in ovs.get('bonds', {}).items():
|
||||
slaves = [item for item in network_data['physical_interfaces']
|
||||
if item['name'] in detail['bond_slaves']]
|
||||
network_data['bond_interfaces'] = [item for item in network_data['bond_interfaces'] if item['name'] != name]
|
||||
bond_interface = {
|
||||
'name': name, 'type': 'bond', 'addresses': [],
|
||||
'status': 'up' if any(item['status'] == 'up' for item in slaves) else 'down',
|
||||
# OVSDB membership does not reveal active-backup capacity.
|
||||
'speed': 0, 'duplex': 'unknown', 'mtu': 0, 'mac_address': None,
|
||||
'rx_Bps': sum(item.get('rx_Bps', 0) for item in slaves),
|
||||
'tx_Bps': sum(item.get('tx_Bps', 0) for item in slaves), **detail}
|
||||
network_data['bond_interfaces'].append(bond_interface)
|
||||
network_data['interfaces'] = [item for item in network_data['interfaces'] if item['name'] != name]
|
||||
network_data['interfaces'].append(bond_interface)
|
||||
|
||||
network_data['physical_active_count'] = physical_active_count
|
||||
network_data['physical_total_count'] = physical_total_count
|
||||
network_data['bridge_active_count'] = bridge_active_count
|
||||
@@ -6808,6 +6845,11 @@ def get_network_info():
|
||||
bridges_per_nic.setdefault(slave, []).append(bridge['name'])
|
||||
else:
|
||||
bridges_per_nic.setdefault(phys, []).append(bridge['name'])
|
||||
for name, members in ovs.get('bridges', {}).items():
|
||||
for member in members:
|
||||
for nic in ovs.get('bonds', {}).get(member, {}).get('bond_slaves', [member]):
|
||||
if name not in bridges_per_nic.setdefault(nic, []):
|
||||
bridges_per_nic[nic].append(name)
|
||||
for phy in network_data['physical_interfaces']:
|
||||
if phy['name'] in bridges_per_nic:
|
||||
phy['used_by_bridges'] = bridges_per_nic[phy['name']]
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Read-only OVSDB topology; Linux bridges continue to use their sysfs path.
|
||||
|
||||
OVS does not publish Linux bridge brif/master links, and an OVSBond has
|
||||
no kernel netdev. Read the three OVSDB tables once per short cache window
|
||||
instead of guessing from names or changing the host's network config.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
_CACHE_SECONDS = 15
|
||||
_lock = threading.Lock()
|
||||
_cached_at = 0
|
||||
_cached = {}
|
||||
|
||||
|
||||
def _value(value):
|
||||
if isinstance(value, list) and len(value) == 2:
|
||||
if value[0] in ('uuid', 'named-uuid'):
|
||||
return value[1]
|
||||
if value[0] == 'set':
|
||||
return [_value(item) for item in value[1]]
|
||||
return value
|
||||
|
||||
|
||||
def _rows(table):
|
||||
return [{key: _value(value) for key, value in zip(table['headings'], row)}
|
||||
for row in table['data']]
|
||||
|
||||
|
||||
def _list(value):
|
||||
return value if isinstance(value, list) else [] if value is None or value == '' else [value]
|
||||
|
||||
|
||||
def parse_topology(bridges, ports, interfaces):
|
||||
"""Normalize OVSDB UUID references, including singleton sets and bonds."""
|
||||
iface_by_id = {row['_uuid']: row for row in _rows(interfaces)}
|
||||
port_by_id = {row['_uuid']: row for row in _rows(ports)}
|
||||
topology = {'types': {}, 'owners': {}, 'bridges': {}, 'bonds': {}, 'ports': {}}
|
||||
for bridge in _rows(bridges):
|
||||
name = bridge['name']
|
||||
topology['types'][name] = 'bridge'
|
||||
members = []
|
||||
for port_id in _list(bridge.get('ports')):
|
||||
port = port_by_id.get(port_id)
|
||||
if not port:
|
||||
continue
|
||||
port_name = port['name']
|
||||
ifaces = [iface_by_id[i] for i in _list(port.get('interfaces')) if i in iface_by_id]
|
||||
# The bridge's own internal port is not a downstream member.
|
||||
if port_name == name:
|
||||
continue
|
||||
members.append(port_name)
|
||||
detail = {'bridge_owner': name, 'network_backend': 'ovs',
|
||||
'ovs_tag': next(iter(_list(port.get('tag'))), None),
|
||||
'ovs_trunks': _list(port.get('trunks'))}
|
||||
topology['ports'][port_name] = detail
|
||||
topology['owners'][port_name] = name
|
||||
is_bond = len(ifaces) > 1
|
||||
if is_bond:
|
||||
topology['types'][port_name] = 'bond'
|
||||
mode = port.get('bond_mode') or 'balance-slb'
|
||||
topology['bonds'][port_name] = {
|
||||
'bond_mode': mode, 'bond_slaves': [i['name'] for i in ifaces],
|
||||
# OVSDB alone does not tell us which slave is active.
|
||||
'bond_active_slave': None, 'bond_supports_failover': False,
|
||||
'bond_slave_status': {}, **detail}
|
||||
for iface in ifaces:
|
||||
iface_name = iface['name']
|
||||
topology['owners'][iface_name] = name
|
||||
topology['ports'][iface_name] = detail
|
||||
if is_bond:
|
||||
topology['ports'][iface_name] = {**detail, 'bond_master': port_name,
|
||||
'bond_role': 'member'}
|
||||
elif iface.get('type') == 'internal' and not iface_name.startswith(('fwpr', 'fwln')):
|
||||
topology['types'][iface_name] = 'vlan'
|
||||
topology['bridges'][name] = members
|
||||
return topology
|
||||
|
||||
|
||||
def get_topology():
|
||||
"""Bounded, cached reads; missing OVS or an unavailable daemon is harmless."""
|
||||
global _cached_at, _cached
|
||||
with _lock:
|
||||
now = time.monotonic()
|
||||
if now - _cached_at < _CACHE_SECONDS:
|
||||
return _cached
|
||||
binary = shutil.which('ovs-vsctl')
|
||||
result = {}
|
||||
if binary:
|
||||
try:
|
||||
tables = []
|
||||
for table, columns in (
|
||||
('Bridge', '_uuid,name,ports'),
|
||||
('Port', '_uuid,name,interfaces,bond_mode,tag,trunks'),
|
||||
('Interface', '_uuid,name,type'),
|
||||
):
|
||||
proc = subprocess.run(
|
||||
[binary, '--timeout=1', '--format=json', f'--columns={columns}', 'list', table],
|
||||
capture_output=True, text=True, timeout=2, check=True)
|
||||
tables.append(json.loads(proc.stdout))
|
||||
result = parse_topology(*tables)
|
||||
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError):
|
||||
# Do not keep stale ownership when OVS disappears or is reconfigured.
|
||||
result = {}
|
||||
_cached, _cached_at = result, now
|
||||
return _cached
|
||||
@@ -0,0 +1,148 @@
|
||||
"""OVSDB fixtures exercise live ownership, UUID sets, bonds and API output."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('ovs_topology_test', SCRIPTS / 'ovs_topology.py')
|
||||
ovs = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(ovs)
|
||||
|
||||
|
||||
def uuid(name):
|
||||
return ['uuid', name]
|
||||
|
||||
|
||||
def refs(*names):
|
||||
return ['set', [uuid(name) for name in names]]
|
||||
|
||||
|
||||
def table(headings, *rows):
|
||||
return {'headings': headings.split(','), 'data': list(rows)}
|
||||
|
||||
|
||||
def fixture():
|
||||
# Mixed Linux/OVS host, named bond without a kernel netdev, tagged
|
||||
# internal port, direct guest and guest behind a Proxmox firewall,
|
||||
# which on OVS links its fwbr bridge through the internal port fwlnNoM.
|
||||
return (
|
||||
table('_uuid,name,ports', ['b1', 'switch0', refs('p0', 'p1', 'p2', 'p3', 'p4', 'p5')]),
|
||||
table('_uuid,name,interfaces,bond_mode,tag,trunks',
|
||||
['p0', 'switch0', uuid('i0'), '', ['set', []], ['set', []]],
|
||||
['p1', 'eno1', uuid('i1'), '', ['set', []], ['set', [10, 20]]],
|
||||
['p2', 'uplink', refs('i2', 'i3'), 'balance-tcp', ['set', []], ['set', []]],
|
||||
['p3', 'vlan100', uuid('i4'), '', 100, ['set', []]],
|
||||
['p4', 'tap103i0', uuid('i5'), '', 100, ['set', []]],
|
||||
['p5', 'fwln115o0', uuid('i6'), '', 10, ['set', []]]),
|
||||
table('_uuid,name,type', ['i0', 'switch0', 'internal'], ['i1', 'eno1', ''],
|
||||
['i2', 'eno2', ''], ['i3', 'eno3', ''], ['i4', 'vlan100', 'internal'],
|
||||
['i5', 'tap103i0', ''], ['i6', 'fwln115o0', 'internal']))
|
||||
|
||||
|
||||
class OvsTopology(unittest.TestCase):
|
||||
def setUp(self):
|
||||
ovs._cached_at = 0
|
||||
ovs._cached = {}
|
||||
self.addCleanup(setattr, ovs, '_cached_at', 0)
|
||||
self.addCleanup(setattr, ovs, '_cached', {})
|
||||
|
||||
def test_ovsdb_singletons_bonds_tags_and_internal_ports(self):
|
||||
data = ovs.parse_topology(*fixture())
|
||||
self.assertEqual(data['types']['switch0'], 'bridge')
|
||||
self.assertEqual(data['types']['vlan100'], 'vlan')
|
||||
self.assertNotIn('switch0', data['bridges']['switch0'])
|
||||
self.assertEqual(data['owners']['tap103i0'], 'switch0')
|
||||
self.assertEqual(data['ports']['vlan100']['ovs_tag'], 100)
|
||||
self.assertEqual(data['ports']['eno1']['ovs_trunks'], [10, 20])
|
||||
self.assertEqual(data['bonds']['uplink']['bond_slaves'], ['eno2', 'eno3'])
|
||||
self.assertIsNone(data['bonds']['uplink']['bond_active_slave'])
|
||||
self.assertEqual(data['ports']['eno2']['bond_master'], 'uplink')
|
||||
|
||||
def test_zero_vlan_tag_is_preserved(self):
|
||||
tables = fixture()
|
||||
tables[1]['data'][3][4] = 0
|
||||
self.assertEqual(ovs.parse_topology(*tables)['ports']['vlan100']['ovs_tag'], 0)
|
||||
|
||||
def test_no_ovs_does_not_spawn_any_process(self):
|
||||
with patch.object(ovs.shutil, 'which', return_value=None), patch.object(ovs.subprocess, 'run') as run:
|
||||
self.assertEqual(ovs.get_topology(), {})
|
||||
run.assert_not_called()
|
||||
|
||||
def test_read_only_queries_cached_and_failure_drops_stale_topology(self):
|
||||
replies = [types.SimpleNamespace(stdout=json.dumps(t)) for t in fixture()]
|
||||
with patch.object(ovs.shutil, 'which', return_value='/usr/bin/ovs-vsctl'), \
|
||||
patch.object(ovs.subprocess, 'run', side_effect=replies) as run:
|
||||
first = ovs.get_topology()
|
||||
self.assertEqual(ovs.get_topology(), first)
|
||||
self.assertEqual(run.call_count, 3)
|
||||
for call in run.call_args_list:
|
||||
self.assertEqual(call.args[0][-2], 'list')
|
||||
self.assertIn('--timeout=1', call.args[0])
|
||||
ovs._cached_at = 0
|
||||
with patch.object(ovs.shutil, 'which', return_value='/usr/bin/ovs-vsctl'), \
|
||||
patch.object(ovs.subprocess, 'run', side_effect=subprocess.TimeoutExpired('ovs-vsctl', 2)):
|
||||
self.assertEqual(ovs.get_topology(), {})
|
||||
|
||||
def test_malformed_or_reconfigured_database_is_harmless(self):
|
||||
for payload in ('not-json', '{}', '{"headings": [], "data": [[]]}'):
|
||||
ovs._cached_at = 0
|
||||
with patch.object(ovs.shutil, 'which', return_value='/usr/bin/ovs-vsctl'), \
|
||||
patch.object(ovs.subprocess, 'run', return_value=types.SimpleNamespace(stdout=payload)):
|
||||
self.assertEqual(ovs.get_topology(), {})
|
||||
|
||||
def api_namespace(self):
|
||||
names = {'get_network_info', 'get_interface_type', '_resolve_bridge_owner'}
|
||||
tree = ast.parse((SCRIPTS / 'flask_server.py').read_text())
|
||||
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in names]
|
||||
data = ovs.parse_topology(*fixture())
|
||||
names = ['eno1', 'eno2', 'eno3', 'switch0', 'vlan100', 'tap103i0', 'tap115i0', 'vmbr1']
|
||||
stats = {name: types.SimpleNamespace(isup=True, speed=1000, duplex=2, mtu=1500) for name in names}
|
||||
counter = types.SimpleNamespace(bytes_sent=1000, bytes_recv=2000, packets_sent=10, packets_recv=20,
|
||||
errin=0, errout=0, dropin=0, dropout=0)
|
||||
ns = {'os': os, 'socket': socket,
|
||||
'ovs_topology': types.SimpleNamespace(get_topology=lambda: data),
|
||||
'get_proxmox_node_name': lambda: 'pve', 'get_vm_lxc_names': lambda: {},
|
||||
'psutil': types.SimpleNamespace(net_if_addrs=lambda: dict.fromkeys(names, []),
|
||||
net_if_stats=lambda: stats,
|
||||
net_io_counters=lambda **kw: dict.fromkeys(names, counter) if kw else counter),
|
||||
'_ensure_net_rate_sampler': lambda: None, '_compute_per_nic_rates_live': lambda _: {},
|
||||
'_is_bond_master': lambda _: False, '_is_physical_dev': lambda name: name.startswith('eno'),
|
||||
'_ethtool_max_speed': lambda _: 1000,
|
||||
'_iface_master': lambda name: {'tap115i0': 'fwbr115i0', 'fwln115o0': 'ovs-system'}.get(name),
|
||||
'extract_vmid_from_interface': lambda name: (103 if '103' in name else 115, 'vm'),
|
||||
'get_bridge_info': lambda _: {'members': [], 'physical_interface': None,
|
||||
'physical_duplex': 'unknown', 'bond_slaves': []}}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'flask_server.py', 'exec'), ns)
|
||||
return ns
|
||||
|
||||
def test_api_preserves_linux_bridge_and_assigns_ovs_guests_and_bond(self):
|
||||
ns = self.api_namespace()
|
||||
# OVS creates no fwpr peer for the firewall bridge.
|
||||
with patch('os.path.exists', side_effect=lambda path: 'fwpr' not in str(path)):
|
||||
result = ns['get_network_info']()
|
||||
self.assertNotIn('error', result)
|
||||
self.assertEqual({i['name'] for i in result['bridge_interfaces']}, {'switch0', 'vmbr1'})
|
||||
self.assertEqual(result['bond_interfaces'][0]['name'], 'uplink')
|
||||
self.assertEqual(result['bond_interfaces'][0]['speed'], 0)
|
||||
self.assertIsNone(result['bond_interfaces'][0]['mac_address'])
|
||||
self.assertIn('uplink', {i['name'] for i in result['interfaces']})
|
||||
for guest in result['vm_lxc_interfaces']:
|
||||
self.assertEqual(guest['bridge_owner'], 'switch0')
|
||||
self.assertEqual(next(i for i in result['interfaces'] if i['name'] == 'vlan100')['ovs_tag'], 100)
|
||||
for nic in result['physical_interfaces']:
|
||||
self.assertIn('switch0', nic['used_by_bridges'])
|
||||
|
||||
def test_packaged_module_is_shipped_with_backend(self):
|
||||
self.assertIn('cp "$SCRIPT_DIR/ovs_topology.py" "$APP_DIR/usr/bin/"',
|
||||
(SCRIPTS / 'build_appimage.sh').read_text())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,39 @@
|
||||
// Exercise both shipped SVG layouts with OVS internal ports and bond topology.
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../components/network-flow.tsx'), 'utf8')
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText
|
||||
const sandbox = { exports: {}, require: () => ({}), console }
|
||||
vm.runInNewContext(code, sandbox)
|
||||
const data = {
|
||||
nics: [
|
||||
{ id: 'eno1', link: '1 Gb/s', rx: 1, tx: 2, status: 'up' },
|
||||
{ id: 'eno2', link: '1 Gb/s', rx: 1, tx: 2, status: 'up', bond: 'uplink', role: 'member' },
|
||||
{ id: 'eno3', link: '1 Gb/s', rx: 1, tx: 2, status: 'up', bond: 'uplink', role: 'member' },
|
||||
],
|
||||
bonds: [{ id: 'uplink', mode: 'balance-tcp', rx: 2, tx: 4, status: 'up' }],
|
||||
bridges: [{ id: 'switch0', parent: 'eno1' }, { id: 'vmbr1' }],
|
||||
consumers: [
|
||||
{ id: 'host', kind: 'host', label: 'host', bridge: 'switch0', rx: 1, tx: 2 },
|
||||
{ id: 'vlan100', kind: 'interface', label: 'vlan100 · VLAN 100', bridge: 'switch0', rx: 0, tx: 0 },
|
||||
{ id: 'tap103i0', kind: 'vm', label: 'VM 103', bridge: 'switch0', rx: 1, tx: 2 },
|
||||
{ id: 'veth115i0', kind: 'lxc', label: 'LXC 115', bridge: 'switch0', rx: 1, tx: 2 },
|
||||
{ id: 'tap104i0', kind: 'vm', label: 'VM 104', bridge: 'vmbr1', rx: 1, tx: 2 },
|
||||
],
|
||||
}
|
||||
const labels = { down: 'Down', active: 'Active', standby: 'Standby' }
|
||||
for (const render of [sandbox.renderHorizontal, sandbox.renderVertical]) {
|
||||
const result = render(data, 1500, labels, 'Bytes')
|
||||
for (const id of ['switch0', 'vmbr1', 'vlan100', 'tap103i0', 'veth115i0', 'tap104i0', 'uplink']) {
|
||||
assert.ok(result.svg.includes(`data-node-id="${id}"`), `layout must draw ${id}`)
|
||||
}
|
||||
assert.ok(result.svg.includes('vlan100 · VLAN 100'))
|
||||
assert.ok(!result.svg.includes('NaN'))
|
||||
assert.ok(!result.svg.includes('undefined'))
|
||||
}
|
||||
console.log('PASS: both Network Flow layouts render OVS bonds, internal VLAN ports and mixed Linux/OVS guests')
|
||||
Reference in New Issue
Block a user