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
+2 -1
View File
@@ -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,
+49 -15
View File
@@ -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:
+97 -112
View File
@@ -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:
+5 -1
View File
@@ -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()
+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'], [