mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +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:
@@ -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