From 053b6c81f83824954b0ae097c6080d0e6500c8da Mon Sep 17 00:00:00 2001 From: MacRimi Date: Thu, 8 Oct 2026 17:14:36 +0200 Subject: [PATCH] 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. --- AppImage/components/lxc-app-panel.tsx | 3 +- AppImage/scripts/flask_server.py | 64 ++++-- AppImage/scripts/health_persistence.py | 209 ++++++++---------- AppImage/scripts/lxc_apps.py | 6 +- .../test_disk_observations_follow_the_disk.py | 115 ++++++++++ .../scripts/tests/test_lxc_vm_ip_inventory.py | 42 ++++ AppImage/scripts/tests/test_oci_endpoints.py | 10 +- 7 files changed, 318 insertions(+), 131 deletions(-) create mode 100644 AppImage/scripts/tests/test_disk_observations_follow_the_disk.py diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 0670d697..83b8f956 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -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, diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 823a5e1a..13bcb05e 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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: diff --git a/AppImage/scripts/health_persistence.py b/AppImage/scripts/health_persistence.py index 4fb13fc7..c9a75c22 100644 --- a/AppImage/scripts/health_persistence.py +++ b/AppImage/scripts/health_persistence.py @@ -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: diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index 78d0ae6e..542d6bd2 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -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 {} diff --git a/AppImage/scripts/tests/test_disk_observations_follow_the_disk.py b/AppImage/scripts/tests/test_disk_observations_follow_the_disk.py new file mode 100644 index 00000000..285760c5 --- /dev/null +++ b/AppImage/scripts/tests/test_disk_observations_follow_the_disk.py @@ -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() diff --git a/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py b/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py index 877754d4..5975bf59 100644 --- a/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py +++ b/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py @@ -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() diff --git a/AppImage/scripts/tests/test_oci_endpoints.py b/AppImage/scripts/tests/test_oci_endpoints.py index 1e63c58d..cd3336f9 100644 --- a/AppImage/scripts/tests/test_oci_endpoints.py +++ b/AppImage/scripts/tests/test_oci_endpoints.py @@ -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'], [