mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
fix(monitor): disk observations follow the disk, and OCI containers keep their address
- The observations of a disk belong to its serial number, not to its connector: a replaced disk starts clean, and the badge of the list counts what the detail of the disk lists. - An OCI container is recognised while it is still being installed, so an image that carries Docker inside is not offered Docker. - Links use the IPv4 address of a container and write IPv6 in brackets; an answer without an address of its own is read again. - The Monitor restores, when it starts, the DHCP clients of containers that were started from it.
This commit is contained in:
@@ -422,7 +422,8 @@ function buildWebUrl(ip: string | undefined | null, port: number | "", scheme?:
|
||||
const custom = (customUrl || "").trim()
|
||||
if (custom) return custom
|
||||
if (!ip || ip === "DHCP" || !port) return null
|
||||
return `${scheme || defaultSchemeFor(port)}://${ip}:${port}`
|
||||
const host = ip.includes(":") && !ip.startsWith("[") ? `[${ip}]` : ip
|
||||
return `${scheme || defaultSchemeFor(port)}://${host}:${port}`
|
||||
}
|
||||
|
||||
// Suggest a dpkg/apk package name from a friendly app name — lowercase,
|
||||
|
||||
@@ -629,10 +629,22 @@ def _get_lxc_ip_info_cached(vmid):
|
||||
if vmid_int in _lxc_ip_cache:
|
||||
return _lxc_ip_cache[vmid_int]
|
||||
info = get_lxc_ip_from_lxc_info(vmid_int)
|
||||
_lxc_ip_cache[vmid_int] = info
|
||||
_remember_lxc_ip(vmid_int, info)
|
||||
return info
|
||||
|
||||
|
||||
def _remember_lxc_ip(vmid, info):
|
||||
"""Keep the addresses of a container until its next lifecycle event.
|
||||
|
||||
A container that shows addresses but no IPv4 of its own is still waiting
|
||||
for its lease, or lost it when it rebooted from inside: that answer is not
|
||||
kept, so the next read looks again instead of serving an address of its
|
||||
inner bridges until the container is restarted.
|
||||
"""
|
||||
if info is None or any(':' not in ip for ip in info.get('real_ips') or []):
|
||||
_lxc_ip_cache[vmid] = info
|
||||
|
||||
|
||||
def _get_lxc_primary_ip_cached(vmid):
|
||||
"""Return the primary IP for legacy callers of the LXC IP cache."""
|
||||
info = _get_lxc_ip_info_cached(vmid)
|
||||
@@ -673,6 +685,27 @@ def _invalidate_lxc_ip(vmid):
|
||||
pass
|
||||
|
||||
|
||||
def _restore_lxc_dhcp_clients():
|
||||
"""Start the DHCP clients that ended with the previous Monitor.
|
||||
|
||||
Proxmox runs the DHCP client of a host-managed interface as a process of
|
||||
whatever started the container. One started from the Monitor, or from its
|
||||
terminal, ends when the Monitor is restarted or updated, and the container
|
||||
would lose its address when the lease runs out.
|
||||
"""
|
||||
script = '/usr/local/share/proxmenux/oci/engine/remote/oci_dhcp_lease.py'
|
||||
if not os.path.isfile(script):
|
||||
return
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, script, '--all'], capture_output=True, text=True, timeout=900,
|
||||
env={k: v for k, v in os.environ.items() if k not in ('PYTHONPATH', 'LD_LIBRARY_PATH')})
|
||||
for line in result.stdout.splitlines():
|
||||
print(f"[ProxMenux] {line}", flush=True)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
print(f"[ProxMenux] DHCP client check failed: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _warmup_lxc_ip_cache() -> int:
|
||||
"""Populate the LXC IP cache for every running CT at Monitor
|
||||
startup. After this runs, /api/vms serves the IPs from memory
|
||||
@@ -700,7 +733,7 @@ def _warmup_lxc_ip_cache() -> int:
|
||||
continue
|
||||
if parts[1].lower() != 'running':
|
||||
continue
|
||||
_lxc_ip_cache[vmid_int] = get_lxc_ip_from_lxc_info(vmid_int)
|
||||
_remember_lxc_ip(vmid_int, get_lxc_ip_from_lxc_info(vmid_int))
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -793,6 +826,8 @@ def get_lxc_ip_from_lxc_info(vmid):
|
||||
else:
|
||||
# Real network IPs (192.168.x.x, 10.x.x.x, etc.)
|
||||
real_ips.append(ip)
|
||||
# A link is built on an IPv4 address when the container has one.
|
||||
real_ips.sort(key=lambda ip: ':' in ip)
|
||||
isolated = _lxc_isolated_ips(vmid)
|
||||
if isolated:
|
||||
real_ips.sort(key=lambda ip: ip in isolated)
|
||||
@@ -4573,20 +4608,18 @@ def get_storage_info():
|
||||
size_bytes=disk_info.get('size_bytes'),
|
||||
)
|
||||
|
||||
# Fetch observation counts AFTER registration so consolidated
|
||||
# entries are already merged (ata8 -> sdh).
|
||||
obs_counts = health_persistence.get_disks_observation_counts()
|
||||
|
||||
# Mark disks no longer present as removed, including the one whose
|
||||
# connector now holds a different disk.
|
||||
health_persistence.mark_removed_disks(
|
||||
active_dev_names,
|
||||
{name: info.get('serial', '') for name, info in physical_disks.items()})
|
||||
|
||||
# Counted AFTER registration so consolidated entries are already
|
||||
# merged (ata8 -> sdh), and per disk: the badge shows what the
|
||||
# detail of that disk lists.
|
||||
for disk_name, disk_info in physical_disks.items():
|
||||
# Attach observation count: try serial match first, then device name
|
||||
serial = disk_info.get('serial', '')
|
||||
count = obs_counts.get(f'serial:{serial}', 0) if serial else 0
|
||||
if count == 0:
|
||||
count = obs_counts.get(disk_name, 0)
|
||||
disk_info['observations_count'] = count
|
||||
|
||||
# Mark disks no longer present as removed
|
||||
health_persistence.mark_removed_disks(active_dev_names)
|
||||
disk_info['observations_count'] = health_persistence.count_disk_observations(
|
||||
disk_name, disk_info.get('serial', ''))
|
||||
# Auto-dismiss stale observations (> 30 days old)
|
||||
health_persistence.cleanup_stale_observations()
|
||||
except Exception:
|
||||
@@ -22844,6 +22877,7 @@ if __name__ == '__main__':
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] LXC IP warmup failed: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
threading.Thread(target=_restore_lxc_dhcp_clients, daemon=True).start()
|
||||
# Preload custom weblinks so the first Apps dashboard fetch
|
||||
# is served straight from memory (0 disk I/O).
|
||||
try:
|
||||
|
||||
@@ -2560,7 +2560,7 @@ class HealthPersistence:
|
||||
cursor.execute('''
|
||||
SELECT id FROM disk_registry
|
||||
WHERE device_name = ? AND serial != ''
|
||||
ORDER BY last_seen DESC LIMIT 1
|
||||
ORDER BY removed ASC, last_seen DESC LIMIT 1
|
||||
''', (clean_dev,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
@@ -2691,14 +2691,86 @@ class HealthPersistence:
|
||||
except Exception as e:
|
||||
print(f"[HealthPersistence] Error recording disk observation: {e}")
|
||||
|
||||
# A disk whose serial could not be read is recorded with none, or with the
|
||||
# word the interface shows for it: neither identifies a disk.
|
||||
_NO_SERIAL = "(serial IS NULL OR serial = '' OR serial = 'Unknown')"
|
||||
|
||||
@staticmethod
|
||||
def _known_serial(serial: Optional[str]) -> str:
|
||||
serial = (serial or '').strip()
|
||||
return '' if serial.lower() in ('', 'unknown', 'n/a') else serial
|
||||
|
||||
def _disk_observation_scope(self, cursor, device_name: Optional[str], serial: Optional[str]):
|
||||
"""Which observations belong to the disk now known as `device_name`.
|
||||
|
||||
A disk is its serial number: its history follows it to whatever name
|
||||
the kernel gives it. The name alone is a connector, and the disk that
|
||||
was plugged there before keeps its own history. Observations recorded
|
||||
under the name without a serial count for the current disk only from
|
||||
the moment the previous disk with a serial was last seen there.
|
||||
|
||||
Returns (WHERE clause, parameters), or None when nothing is known.
|
||||
"""
|
||||
clean_dev = (device_name or '').replace('/dev/', '')
|
||||
serial = self._known_serial(serial)
|
||||
if not serial and clean_dev:
|
||||
cursor.execute(
|
||||
f'SELECT serial FROM disk_registry WHERE device_name = ? AND NOT {self._NO_SERIAL} '
|
||||
'ORDER BY removed ASC, last_seen DESC LIMIT 1', (clean_dev,))
|
||||
row = cursor.fetchone()
|
||||
serial = row[0] if row else ''
|
||||
own = []
|
||||
if serial:
|
||||
cursor.execute('SELECT id FROM disk_registry WHERE serial = ?', (serial,))
|
||||
own = [row[0] for row in cursor.fetchall()]
|
||||
unidentified, since = [], None
|
||||
if clean_dev:
|
||||
cursor.execute(
|
||||
f'SELECT id FROM disk_registry WHERE device_name = ? AND {self._NO_SERIAL}', (clean_dev,))
|
||||
unidentified = [row[0] for row in cursor.fetchall()]
|
||||
cursor.execute(
|
||||
f'SELECT MAX(last_seen) FROM disk_registry '
|
||||
f'WHERE device_name = ? AND NOT {self._NO_SERIAL} AND serial != ?',
|
||||
(clean_dev, serial))
|
||||
since = cursor.fetchone()[0]
|
||||
if not own and not unidentified:
|
||||
return None
|
||||
cursor.execute('PRAGMA table_info(disk_observations)')
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
last_col = 'last_occurrence' if 'last_occurrence' in columns else 'last_seen'
|
||||
clauses, params = [], []
|
||||
if own:
|
||||
clauses.append(f"o.disk_registry_id IN ({','.join('?' * len(own))})")
|
||||
params += own
|
||||
if unidentified:
|
||||
clause = f"o.disk_registry_id IN ({','.join('?' * len(unidentified))})"
|
||||
params += unidentified
|
||||
if since:
|
||||
clause = f'({clause} AND o.{last_col} >= ?)'
|
||||
params.append(since)
|
||||
clauses.append(clause)
|
||||
return '(' + ' OR '.join(clauses) + ')', params
|
||||
|
||||
def count_disk_observations(self, device_name: str, serial: Optional[str] = None) -> int:
|
||||
"""Active observations of one disk: the number its badge shows, counted
|
||||
the way `get_disk_observations` lists them."""
|
||||
try:
|
||||
with self._db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
scope = self._disk_observation_scope(cursor, device_name, serial)
|
||||
if scope is None:
|
||||
return 0
|
||||
where, params = scope
|
||||
cursor.execute(
|
||||
f'SELECT COUNT(*) FROM disk_observations o WHERE {where} AND o.dismissed = 0', params)
|
||||
return int(cursor.fetchone()[0])
|
||||
except Exception as e:
|
||||
print(f"[HealthPersistence] Error counting observations: {e}")
|
||||
return 0
|
||||
|
||||
def get_disk_observations(self, device_name: Optional[str] = None,
|
||||
serial: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get active (non-dismissed) observations for one disk or all disks.
|
||||
|
||||
For USB disks that may have multiple registry entries (one with serial,
|
||||
one without), this searches ALL registry entries matching the device_name
|
||||
to ensure observations are found regardless of which entry recorded them.
|
||||
"""
|
||||
"""Get active (non-dismissed) observations for one disk or all disks."""
|
||||
try:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.cursor()
|
||||
@@ -2712,29 +2784,11 @@ class HealthPersistence:
|
||||
last_col = 'last_occurrence' if 'last_occurrence' in columns else 'last_seen'
|
||||
|
||||
if device_name or serial:
|
||||
clean_dev = (device_name or '').replace('/dev/', '')
|
||||
|
||||
# Get ALL disk_registry IDs that match this device_name
|
||||
# This handles USB disks with multiple registry entries
|
||||
cursor.execute(
|
||||
'SELECT id FROM disk_registry WHERE device_name = ?',
|
||||
(clean_dev,))
|
||||
all_ids = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
# Also try to find by serial if provided
|
||||
if serial:
|
||||
cursor.execute(
|
||||
'SELECT id FROM disk_registry WHERE serial = ? AND serial != ""',
|
||||
(serial,))
|
||||
serial_ids = [row[0] for row in cursor.fetchall()]
|
||||
all_ids = list(set(all_ids + serial_ids))
|
||||
|
||||
if not all_ids:
|
||||
scope = self._disk_observation_scope(cursor, device_name, serial)
|
||||
if scope is None:
|
||||
conn.close()
|
||||
return []
|
||||
|
||||
# Query observations for ALL matching registry entries
|
||||
placeholders = ','.join('?' * len(all_ids))
|
||||
where, params = scope
|
||||
cursor.execute(f'''
|
||||
SELECT o.id, o.{type_col}, o.error_signature,
|
||||
o.{first_col}, o.{last_col},
|
||||
@@ -2742,9 +2796,9 @@ class HealthPersistence:
|
||||
d.device_name, d.serial, d.model
|
||||
FROM disk_observations o
|
||||
JOIN disk_registry d ON o.disk_registry_id = d.id
|
||||
WHERE o.disk_registry_id IN ({placeholders}) AND o.dismissed = 0
|
||||
WHERE {where} AND o.dismissed = 0
|
||||
ORDER BY o.{last_col} DESC
|
||||
''', all_ids)
|
||||
''', params)
|
||||
else:
|
||||
cursor.execute(f'''
|
||||
SELECT o.id, o.{type_col}, o.error_signature,
|
||||
@@ -2802,86 +2856,6 @@ class HealthPersistence:
|
||||
print(f"[HealthPersistence] get_all_observed_devices failed: {e}")
|
||||
return []
|
||||
|
||||
def get_disks_observation_counts(self) -> Dict[str, int]:
|
||||
"""Return {device_name: count} of active observations per disk.
|
||||
|
||||
Groups by serial when available to consolidate counts across device name changes
|
||||
(e.g., ata8 -> sdh). Also includes serial-keyed entries for cross-device matching.
|
||||
"""
|
||||
try:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# For disks WITH serial: group by serial to consolidate across device renames
|
||||
cursor.execute('''
|
||||
SELECT d.serial, COUNT(o.id) as cnt
|
||||
FROM disk_observations o
|
||||
JOIN disk_registry d ON o.disk_registry_id = d.id
|
||||
WHERE o.dismissed = 0 AND d.serial IS NOT NULL AND d.serial != ''
|
||||
GROUP BY d.serial
|
||||
''')
|
||||
serial_counts = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
# Get current device_name for each serial (prefer non-ata names)
|
||||
cursor.execute('''
|
||||
SELECT serial, device_name FROM disk_registry
|
||||
WHERE serial IS NOT NULL AND serial != ''
|
||||
ORDER BY
|
||||
CASE WHEN device_name LIKE 'ata%' THEN 1 ELSE 0 END,
|
||||
last_seen DESC
|
||||
''')
|
||||
serial_to_device = {}
|
||||
for serial, device_name in cursor.fetchall():
|
||||
if serial not in serial_to_device:
|
||||
serial_to_device[serial] = device_name
|
||||
|
||||
# Resolve which serial currently OWNS each device_name. The
|
||||
# kernel reuses NVMe / SD device names across reboots
|
||||
# (e.g. the disk that was nvme4n1 with 5 NVMes plugged in
|
||||
# comes back as nvme0n1 once 4 are removed), and
|
||||
# disk_registry keeps a row for every (device_name, serial)
|
||||
# combination it has ever seen. Without this check we would
|
||||
# mirror an observation's count onto its serial's
|
||||
# "most-recent" device_name even when that name is now in
|
||||
# use by a DIFFERENT serial — surfacing a "1 obs." badge on
|
||||
# a disk that has no observations of its own and a clean
|
||||
# modal, since the modal correctly scopes by current
|
||||
# (device_name, serial) pair.
|
||||
cursor.execute('''
|
||||
SELECT device_name, serial FROM disk_registry
|
||||
WHERE device_name IS NOT NULL AND device_name != ''
|
||||
ORDER BY last_seen DESC
|
||||
''')
|
||||
current_owner = {}
|
||||
for device_name, dev_serial in cursor.fetchall():
|
||||
if device_name not in current_owner:
|
||||
current_owner[device_name] = dev_serial
|
||||
|
||||
# Build result
|
||||
result = {}
|
||||
for serial, cnt in serial_counts.items():
|
||||
result[f'serial:{serial}'] = cnt
|
||||
device_name = serial_to_device.get(serial)
|
||||
if device_name and current_owner.get(device_name) == serial:
|
||||
result[device_name] = max(result.get(device_name, 0), cnt)
|
||||
|
||||
# For disks WITHOUT serial: group by device_name
|
||||
cursor.execute('''
|
||||
SELECT d.device_name, COUNT(o.id) as cnt
|
||||
FROM disk_observations o
|
||||
JOIN disk_registry d ON o.disk_registry_id = d.id
|
||||
WHERE o.dismissed = 0 AND (d.serial IS NULL OR d.serial = '')
|
||||
GROUP BY d.device_name
|
||||
''')
|
||||
for device_name, cnt in cursor.fetchall():
|
||||
result[device_name] = max(result.get(device_name, 0), cnt)
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[HealthPersistence] Error getting observation counts: {e}")
|
||||
return {}
|
||||
|
||||
def dismiss_disk_observation(self, observation_id: int):
|
||||
"""Mark a single observation as dismissed."""
|
||||
try:
|
||||
@@ -2918,8 +2892,11 @@ class HealthPersistence:
|
||||
except Exception as e:
|
||||
print(f"[HealthPersistence] Error cleaning stale observations: {e}")
|
||||
|
||||
def mark_removed_disks(self, active_device_names: List[str]):
|
||||
"""Mark disks not in active_device_names as removed."""
|
||||
def mark_removed_disks(self, active_device_names: List[str],
|
||||
active_serials: Optional[Dict[str, str]] = None):
|
||||
"""Mark disks not in active_device_names as removed. With
|
||||
`active_serials` ({device name: serial of the disk there now}), a disk
|
||||
whose name was taken by a different one is marked removed as well."""
|
||||
try:
|
||||
now = datetime.now().isoformat()
|
||||
conn = self._get_conn()
|
||||
@@ -2930,6 +2907,14 @@ class HealthPersistence:
|
||||
UPDATE disk_registry SET removed = 1
|
||||
WHERE device_name NOT IN ({placeholders}) AND removed = 0
|
||||
''', active_device_names)
|
||||
for device_name, serial in (active_serials or {}).items():
|
||||
serial = self._known_serial(serial)
|
||||
if serial:
|
||||
cursor.execute(f'''
|
||||
UPDATE disk_registry SET removed = 1
|
||||
WHERE device_name = ? AND NOT {self._NO_SERIAL}
|
||||
AND serial != ? AND removed = 0
|
||||
''', (device_name, serial))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
|
||||
@@ -5596,7 +5596,11 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
|
||||
return None
|
||||
# While an update or recreation runs, the record is the copy taken before
|
||||
# it: still the right identity for the container, not yet its new image.
|
||||
if not isinstance(record, dict) or record.get("status") not in ("installed", "assembling", "updating"):
|
||||
# While the installation runs it already names what the container holds:
|
||||
# the first start of an image that carries Docker inside is long enough
|
||||
# for a probe to find that binary and offer it as an application.
|
||||
if not isinstance(record, dict) or record.get("status") not in (
|
||||
"installed", "installing", "assembling", "updating", "recovering"):
|
||||
return None
|
||||
template = record.get("template") or {}
|
||||
contract = template.get("container_contract") or {}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""The observations of a disk belong to its serial number, not to the
|
||||
connector it is plugged in."""
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
# The module opens the database of the host when it is imported.
|
||||
_connect = sqlite3.connect
|
||||
with patch.object(Path, 'mkdir'), patch('builtins.print'), \
|
||||
patch('sqlite3.connect', side_effect=lambda *args, **kwargs: _connect(':memory:')):
|
||||
import health_persistence as hp_module
|
||||
|
||||
|
||||
class DiskObservationIdentityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
store = hp_module.HealthPersistence.__new__(hp_module.HealthPersistence)
|
||||
for name, value in vars(hp_module.health_persistence).items():
|
||||
setattr(store, name, value)
|
||||
store.data_dir = Path(tmp.name)
|
||||
store.db_path = store.data_dir / 'health_monitor.db'
|
||||
with patch('builtins.print'):
|
||||
store._init_database()
|
||||
self.store = store
|
||||
|
||||
def record(self, device, serial, signature):
|
||||
self.store.register_disk(device, serial)
|
||||
self.store.record_disk_observation(device, serial, 'io_error', signature, f'{signature} on {device}')
|
||||
|
||||
def seen(self, device, serial=None):
|
||||
return sorted(o['error_signature'] for o in self.store.get_disk_observations(device, serial))
|
||||
|
||||
def set_time(self, table, column, value, where, params):
|
||||
conn = self.store._get_conn()
|
||||
conn.execute(f'UPDATE {table} SET {column} = ? WHERE {where}', (value, *params))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def test_a_new_disk_in_the_same_connector_starts_clean(self):
|
||||
self.record('sdb', 'OLD-BAD', 'pending_sectors')
|
||||
self.record('sdb', 'OLD-BAD', 'read_error')
|
||||
# The disk is replaced: the same name, another serial.
|
||||
self.store.register_disk('sdb', 'NEW-GOOD', 'WDC', 0)
|
||||
self.store.mark_removed_disks(['sdb'], {'sdb': 'NEW-GOOD'})
|
||||
self.assertEqual(self.seen('sdb', 'NEW-GOOD'), [])
|
||||
self.assertEqual(self.store.count_disk_observations('sdb', 'NEW-GOOD'), 0)
|
||||
# Its history is kept, and is back if the disk is.
|
||||
self.assertEqual(self.seen('sdb', 'OLD-BAD'), ['pending_sectors', 'read_error'])
|
||||
|
||||
def test_the_interface_asking_without_a_serial_gets_the_disk_that_is_there_now(self):
|
||||
self.record('sdb', 'OLD-BAD', 'pending_sectors')
|
||||
self.set_time('disk_registry', 'last_seen', '2026-01-01T00:00:00', 'serial = ?', ('OLD-BAD',))
|
||||
self.store.register_disk('sdb', 'NEW-GOOD')
|
||||
self.store.mark_removed_disks(['sdb'], {'sdb': 'NEW-GOOD'})
|
||||
self.assertEqual(self.seen('sdb'), [])
|
||||
self.record('sdb', 'NEW-GOOD', 'crc_error')
|
||||
self.assertEqual(self.seen('sdb'), ['crc_error'])
|
||||
|
||||
def test_an_error_logged_without_a_serial_goes_to_the_disk_that_is_there_now(self):
|
||||
self.record('sdb', 'OLD-BAD', 'pending_sectors')
|
||||
self.set_time('disk_registry', 'last_seen', '2026-01-01T00:00:00', 'serial = ?', ('OLD-BAD',))
|
||||
self.store.register_disk('sdb', 'NEW-GOOD')
|
||||
self.store.mark_removed_disks(['sdb'], {'sdb': 'NEW-GOOD'})
|
||||
# The kernel log names the device only.
|
||||
self.store.record_disk_observation('sdb', None, 'io_error', 'crc_error', 'crc on sdb')
|
||||
self.assertEqual(self.seen('sdb', 'NEW-GOOD'), ['crc_error'])
|
||||
self.assertEqual(self.seen('sdb', 'OLD-BAD'), ['pending_sectors'])
|
||||
|
||||
def test_the_history_follows_a_disk_to_another_name(self):
|
||||
self.record('nvme4n1', 'SN-A', 'media_error')
|
||||
self.store.register_disk('nvme0n1', 'SN-A')
|
||||
self.store.register_disk('nvme4n1', 'SN-B')
|
||||
self.assertEqual(self.seen('nvme0n1', 'SN-A'), ['media_error'])
|
||||
self.assertEqual(self.store.count_disk_observations('nvme0n1', 'SN-A'), 1)
|
||||
self.assertEqual(self.seen('nvme4n1', 'SN-B'), [])
|
||||
|
||||
def test_observations_without_a_serial_stay_with_the_only_disk_ever_seen_there(self):
|
||||
# Recorded before the serial of the disk was known.
|
||||
self.store.record_disk_observation('sdc', None, 'io_error', 'early_error', 'early')
|
||||
self.store.register_disk('sdc', 'ONLY-ONE')
|
||||
self.assertEqual(self.seen('sdc', 'ONLY-ONE'), ['early_error'])
|
||||
|
||||
def test_observations_without_a_serial_older_than_the_previous_disk_are_not_the_new_one(self):
|
||||
self.store.record_disk_observation('sdc', None, 'io_error', 'old_unnamed', 'old')
|
||||
self.set_time('disk_observations', 'last_occurrence', '2026-01-01T00:00:00', '1 = 1', ())
|
||||
self.store.register_disk('sdc', 'PREVIOUS')
|
||||
self.set_time('disk_registry', 'last_seen', '2026-02-01T00:00:00', 'serial = ?', ('PREVIOUS',))
|
||||
self.store.register_disk('sdc', 'CURRENT')
|
||||
self.assertEqual(self.seen('sdc', 'CURRENT'), [])
|
||||
self.store.record_disk_observation('sdc', None, 'io_error', 'new_error', 'new')
|
||||
self.assertEqual(self.seen('sdc', 'CURRENT'), ['new_error'])
|
||||
|
||||
def test_an_unread_serial_identifies_no_disk(self):
|
||||
self.record('sda', 'Unknown', 'error_on_sda')
|
||||
self.record('sdd', 'Unknown', 'error_on_sdd')
|
||||
self.assertEqual(self.seen('sda', 'Unknown'), ['error_on_sda'])
|
||||
self.assertEqual(self.seen('sdd'), ['error_on_sdd'])
|
||||
self.assertEqual(self.store.count_disk_observations('sda', 'Unknown'), 1)
|
||||
|
||||
def test_a_dismissed_observation_is_not_counted(self):
|
||||
self.record('sdb', 'SN-1', 'read_error')
|
||||
self.record('sdb', 'SN-1', 'crc_error')
|
||||
first = self.store.get_disk_observations('sdb', 'SN-1')[0]['id']
|
||||
self.store.dismiss_disk_observation(first)
|
||||
self.assertEqual(self.store.count_disk_observations('sdb', 'SN-1'), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -89,5 +89,47 @@ class LxcVmIpInventoryTests(unittest.TestCase):
|
||||
self.assertNotIn("ip", rows[0])
|
||||
|
||||
|
||||
class LxcPrimaryAddressTests(unittest.TestCase):
|
||||
"""A container that rebooted from inside shows the addresses of its inner
|
||||
bridges until it has its own again."""
|
||||
|
||||
def setUp(self):
|
||||
self.server = load_server_functions(
|
||||
"_get_lxc_ip_info_cached", "_remember_lxc_ip", "get_lxc_ip_from_lxc_info")
|
||||
self.listed = ""
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
|
||||
def run(_command, **_options):
|
||||
result = Result()
|
||||
result.stdout = self.listed
|
||||
return result
|
||||
|
||||
import types
|
||||
self.server.update({
|
||||
"_lxc_ip_cache": {},
|
||||
"subprocess": types.SimpleNamespace(run=run),
|
||||
"_lxc_isolated_ips": lambda _vmid: set(),
|
||||
"_lxc_shares_host_network": lambda _vmid: False,
|
||||
})
|
||||
|
||||
def read(self, listed):
|
||||
self.listed = listed
|
||||
return self.server["_get_lxc_ip_info_cached"](121)
|
||||
|
||||
def test_ipv4_is_the_primary_address_wherever_it_is_listed(self):
|
||||
info = self.read("fd0c:ac1e:2100::1\n172.30.32.1\n192.168.0.100\n")
|
||||
self.assertEqual(info["primary_ip"], "192.168.0.100")
|
||||
self.assertEqual(info["real_ips"], ["192.168.0.100", "fd0c:ac1e:2100::1"])
|
||||
self.assertIn(121, self.server["_lxc_ip_cache"])
|
||||
|
||||
def test_an_answer_without_ipv4_of_its_own_is_read_again(self):
|
||||
self.read("172.30.232.1\n172.30.32.1\nfd0c:ac1e:2100::1\n")
|
||||
self.assertNotIn(121, self.server["_lxc_ip_cache"])
|
||||
self.assertEqual(self.read("192.168.0.100\n172.30.32.1\n")["primary_ip"], "192.168.0.100")
|
||||
self.assertIn(121, self.server["_lxc_ip_cache"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -36,10 +36,10 @@ class CatalogFixture(unittest.TestCase):
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
def install(self, endpoints, template_id='image-frigate'):
|
||||
def install(self, endpoints, template_id='image-frigate', status='installed'):
|
||||
(self.root / 'instances/190').mkdir(parents=True)
|
||||
(self.root / 'instances/190/oci-compose.json').write_text(json.dumps({
|
||||
'vmid': 190, 'status': 'installed', 'installation_id': 'install-190',
|
||||
'vmid': 190, 'status': status, 'installation_id': 'install-190',
|
||||
'observed': {'image': {'manifest_digest': 'sha256:' + 'b1' * 32, 'architecture': 'amd64'}},
|
||||
'template': {'id': template_id, 'catalog_ui': {'title': 'Frigate'},
|
||||
'first_run': {'endpoints': endpoints},
|
||||
@@ -58,6 +58,12 @@ class OciEndpointTests(CatalogFixture):
|
||||
])
|
||||
self.assertEqual(meta['endpoint_port'], 5000)
|
||||
|
||||
def test_the_record_names_the_container_while_it_is_being_installed(self):
|
||||
self.assertEqual(self.install(ENDPOINTS, status='installing')['template_id'], 'image-frigate')
|
||||
|
||||
def test_a_failed_installation_names_nothing(self):
|
||||
self.assertIsNone(self.install(ENDPOINTS, status='failed'))
|
||||
|
||||
def test_single_endpoint_and_unknown_template(self):
|
||||
meta = self.install([ENDPOINTS[0]], template_id='image-other')
|
||||
self.assertEqual(meta['endpoints'], [
|
||||
|
||||
Reference in New Issue
Block a user