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:
MacRimi
2026-10-08 17:14:36 +02:00
parent f5ad4072b5
commit 053b6c81f8
7 changed files with 318 additions and 131 deletions
@@ -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()
+8 -2
View File
@@ -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'], [