mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-09 06:56:37 +00:00
feat(oci): GPU selection per host and per image, one notification per update, and App tab for stack containers
- Immich asks what runs its video and its recognition in one menu, in both modes, and gives the GPU to the server and to Machine learning; AMD uses ROCm - Frigate, Ollama, llama.cpp, Faster Whisper and Piper take the image built for the chosen GPU - The acceleration menu offers only what the host can run - An update or a recreation sends one notification with its result instead of the stop, backup and start of each container - A private bridge with nothing connected is not reported as down - Secondary containers of a stack appear in the App tab with their version and logo; Secure Gateway shows the same update state in both views - A mistyped value in the wizard asks the same question again
This commit is contained in:
@@ -138,6 +138,7 @@ cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
|
||||
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
|
||||
cp "$SCRIPT_DIR/oci_console_logs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_console_logs.py not found"
|
||||
cp "$SCRIPT_DIR/oci_instance_info.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_instance_info.py not found"
|
||||
cp "$SCRIPT_DIR/oci_operations.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_operations.py not found"
|
||||
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
|
||||
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
|
||||
cp "$SCRIPT_DIR/disk_identity.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_identity.py not found"
|
||||
|
||||
@@ -1625,6 +1625,54 @@ def internal_shutdown_event():
|
||||
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
|
||||
|
||||
|
||||
# ─── Internal OCI Event Endpoint ─────────────────────────────────
|
||||
|
||||
_OCI_EVENTS = {
|
||||
'oci_update_completed': 'INFO', 'oci_update_failed': 'WARNING',
|
||||
'oci_recreate_completed': 'INFO', 'oci_recreate_failed': 'WARNING',
|
||||
}
|
||||
|
||||
|
||||
@notification_bp.route('/api/internal/oci-event', methods=['POST'])
|
||||
def internal_oci_event():
|
||||
"""Called by the OCI engine when an update or a recreation ends, with its
|
||||
result. Only accepts requests from this host."""
|
||||
remote_addr = request.remote_addr or ''
|
||||
try:
|
||||
import ipaddress
|
||||
addr = ipaddress.ip_address(remote_addr.split('%')[0])
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
is_loopback = addr.is_loopback
|
||||
except (ValueError, TypeError):
|
||||
is_loopback = remote_addr in ('127.0.0.1', '::1', 'localhost')
|
||||
if not is_loopback:
|
||||
return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
event_type = str(data.get('event') or '')
|
||||
if event_type not in _OCI_EVENTS:
|
||||
return jsonify({'error': 'invalid_event_type'}), 400
|
||||
vmid = str(data.get('vmid') or '')
|
||||
notification_manager.emit_event(
|
||||
event_type=event_type,
|
||||
severity=_OCI_EVENTS[event_type],
|
||||
data={
|
||||
'hostname': str(data.get('hostname') or 'unknown'),
|
||||
'app_name': str(data.get('app_name') or f'CT {vmid}')[:120],
|
||||
'vmid': vmid,
|
||||
'containers': str(data.get('containers') or '')[:400],
|
||||
'reason': str(data.get('reason') or '')[:600],
|
||||
},
|
||||
source='proxmenux',
|
||||
entity='ct',
|
||||
entity_id=vmid,
|
||||
)
|
||||
return jsonify({'success': True, 'event_type': event_type}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
|
||||
|
||||
|
||||
# ─── Internal Restore Event Endpoint ─────────────────────────────
|
||||
|
||||
@notification_bp.route('/api/internal/restore-event', methods=['POST'])
|
||||
|
||||
@@ -545,6 +545,15 @@ def update_auth_key(app_id: str):
|
||||
}), 500
|
||||
|
||||
|
||||
def _sync_managed_registry(app_id: str) -> None:
|
||||
"""Keep the Updates tab of the container in step with this page."""
|
||||
try:
|
||||
import managed_installs
|
||||
managed_installs.refresh_oci_app(app_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not refresh the managed registry for {app_id}: {e}")
|
||||
|
||||
|
||||
@oci_bp.route("/installed/<app_id>/update-check", methods=["GET"])
|
||||
@require_auth
|
||||
def installed_update_check(app_id: str):
|
||||
@@ -558,6 +567,8 @@ def installed_update_check(app_id: str):
|
||||
try:
|
||||
force = request.args.get("force", "").lower() in ("1", "true", "yes")
|
||||
result = oci_manager.check_app_update_available(app_id, force=force)
|
||||
if force:
|
||||
_sync_managed_registry(app_id)
|
||||
return jsonify({"success": True, **result})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check app update for {app_id}: {e}")
|
||||
@@ -572,6 +583,8 @@ def installed_update_apply(app_id: str):
|
||||
would cause an unnecessary brief disconnect."""
|
||||
try:
|
||||
result = oci_manager.update_app(app_id)
|
||||
if result.get("success"):
|
||||
_sync_managed_registry(app_id)
|
||||
status_code = 200 if result.get("success") else 500
|
||||
return jsonify(result), status_code
|
||||
except Exception as e:
|
||||
|
||||
@@ -3158,6 +3158,20 @@ class HealthMonitor:
|
||||
print(f"[HealthMonitor] Disk/IO check failed: {e}")
|
||||
return {'status': 'UNKNOWN', 'reason': f'Disk check unavailable: {str(e)}', 'checks': {}, 'dismissable': True}
|
||||
|
||||
@staticmethod
|
||||
def _bridge_is_idle(interface: str, root: str = '/sys/class/net') -> bool:
|
||||
"""Whether a bridge is administratively up with no port attached.
|
||||
|
||||
Such a bridge reports no carrier, which is its normal state and not a
|
||||
failure. A bridge that is set down, or one that has ports and still no
|
||||
carrier, is not idle."""
|
||||
try:
|
||||
with open(f'{root}/{interface}/flags', encoding='ascii') as handle:
|
||||
administratively_up = bool(int(handle.read().strip(), 16) & 0x1)
|
||||
return administratively_up and not os.listdir(f'{root}/{interface}/brif')
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
def _check_network_optimized(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Optimized network check - only alerts for interfaces that are actually in use.
|
||||
@@ -3206,6 +3220,18 @@ class HealthMonitor:
|
||||
|
||||
# Check if it's a bridge interface (always important for VMs/LXCs)
|
||||
if interface.startswith('vmbr'):
|
||||
if self._bridge_is_idle(interface):
|
||||
# A bridge with no port attached has no carrier: the
|
||||
# private network of an application whose containers
|
||||
# are stopped, during an update for example. Nothing
|
||||
# is down; nothing is connected to it.
|
||||
interface_details[interface] = {
|
||||
'status': 'OK',
|
||||
'reason': 'Bridge without attached ports',
|
||||
'is_up': False,
|
||||
}
|
||||
health_persistence.resolve_error(interface, 'Bridge without attached ports')
|
||||
continue
|
||||
should_alert = True
|
||||
alert_reason = 'Bridge interface DOWN (VMs/LXCs may be affected)'
|
||||
|
||||
|
||||
@@ -4332,7 +4332,10 @@ def check_app(
|
||||
pass
|
||||
|
||||
if app.get("installed_via") == "oci_image":
|
||||
result = _oci_image_versions(vmid, known=state)
|
||||
# A secondary container of a stack shows the version it runs; the
|
||||
# registry is not asked, because it is not updated on its own.
|
||||
result = _oci_image_versions(
|
||||
vmid, known=state, with_latest=not _oci_secondary_member(_read_oci_record(vmid)))
|
||||
if result.get("busy"):
|
||||
_recheck_after_oci_operation(vmid, app_id)
|
||||
return sidecar
|
||||
@@ -5458,30 +5461,50 @@ def _dismiss_oci_registration(vmid) -> None:
|
||||
print(f"[ProxMenux] lxc_apps: could not save the OCI dismissal: {exc}")
|
||||
|
||||
|
||||
def _oci_secondary_member(record) -> bool:
|
||||
"""Whether the record belongs to a container of a stack other than its main one."""
|
||||
if not isinstance(record, dict):
|
||||
return False
|
||||
member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
|
||||
return member.get("primary_vmid") not in (None, record.get("vmid"))
|
||||
|
||||
|
||||
def ensure_oci_registration(vmid) -> bool:
|
||||
"""Register the application ProxMenux installed from an OCI image the
|
||||
first time the Monitor sees its container, with version tracking by the
|
||||
image. The auxiliary members of a stack are not registered, nor is a
|
||||
container that already has applications, nor one whose registration the
|
||||
user removed."""
|
||||
image. A secondary container of a stack, its database or its cache, is
|
||||
registered with the version it runs and no tracking: the stack is updated
|
||||
as a whole from its main container. A container that already has
|
||||
applications is left alone, and so is one whose registration the user
|
||||
removed."""
|
||||
record = _read_oci_record(vmid)
|
||||
if not record or record.get("status") != "installed":
|
||||
return False
|
||||
member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
|
||||
if member.get("primary_vmid") not in (None, record.get("vmid")):
|
||||
return False
|
||||
secondary = _oci_secondary_member(record)
|
||||
if _oci_dismissed().get(str(int(vmid))) == record.get("installation_id"):
|
||||
return False
|
||||
with _cache_lock:
|
||||
sidecar = _read_sidecar(vmid)
|
||||
if sidecar and sidecar.get("apps"):
|
||||
# An application registered before its logo could be resolved
|
||||
# takes it now; nothing else of what is registered is touched.
|
||||
missing = [app for app in sidecar["apps"]
|
||||
if app.get("installed_via") == "oci_image" and not app.get("logo_url")]
|
||||
logo = (_oci_instance_meta(vmid) or {}).get("logo") if missing else ""
|
||||
if logo:
|
||||
for app in missing:
|
||||
app["logo_url"] = logo
|
||||
_write_sidecar(vmid, sidecar)
|
||||
return False
|
||||
meta = _oci_instance_meta(vmid)
|
||||
if not meta or not meta.get("name") or not _NAME_RE.match(str(meta["name"])):
|
||||
return False
|
||||
category = meta.get("category_label") or meta.get("category") or ""
|
||||
endpoints = meta.get("endpoints") or []
|
||||
if not endpoints:
|
||||
if secondary:
|
||||
# A database or a cache is reached by the application, not by the user.
|
||||
endpoints = []
|
||||
elif not endpoints:
|
||||
port = meta.get("endpoint_port") or next(iter(meta.get("ports") or []), None)
|
||||
endpoints = [{"port": port, "scheme": meta.get("endpoint_scheme"), "path": meta.get("endpoint_path"),
|
||||
"description": "", "logo_url": ""}] if isinstance(port, int) else []
|
||||
@@ -5543,6 +5566,15 @@ def _recheck_after_oci_operation(vmid, app_id: str) -> None:
|
||||
threading.Thread(target=wait_and_check, name=f"oci-recheck-{vmid}", daemon=True).start()
|
||||
|
||||
|
||||
_OCI_ICON_BASE = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp"
|
||||
# The services a stack runs beside its application, by the name of their image.
|
||||
_OCI_SERVICE_ICONS = {
|
||||
name: f"{_OCI_ICON_BASE}/{icon}.webp"
|
||||
for name, icon in (("postgres", "postgresql"), ("valkey", "valkey"), ("redis", "redis"),
|
||||
("mariadb", "mariadb"), ("mongo", "mongodb"), ("meilisearch", "meilisearch"))
|
||||
}
|
||||
|
||||
|
||||
def _oci_instance_meta(vmid) -> Optional[dict]:
|
||||
"""What ProxMenux itself recorded when it installed this container.
|
||||
|
||||
@@ -5623,12 +5655,27 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
|
||||
endpoints.append(detail)
|
||||
catalog_icons = _oci_catalog_icons()
|
||||
logo = catalog_icons.get(template_id) or ""
|
||||
repository = str(image.get("reference") or "").split("@", 1)[0]
|
||||
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
|
||||
# A container of a stack records its own template id, `image-immich-server`,
|
||||
# which the catalog does not list: the application it belongs to does.
|
||||
stack_id = str(stack_template.get("id") or "").strip()
|
||||
secondary = _oci_secondary_member(record)
|
||||
if not stack_id and secondary:
|
||||
primary = _read_oci_record(member.get("primary_vmid")) or {}
|
||||
stack_id = str(((primary.get("stack") or {}).get("template") or {}).get("id") or "").strip()
|
||||
application = stack_id.removeprefix("image-").removesuffix("-stack")
|
||||
if not logo and stack_id and (not secondary or (application and basename.startswith(application))):
|
||||
# The main container, or one that carries the application in its own
|
||||
# name, such as Immich's machine learning.
|
||||
logo = catalog_icons.get(stack_id) or ""
|
||||
if not logo:
|
||||
# A stack or a one-off image has no catalog entry of its own, but the
|
||||
# image it runs usually does: the Nextcloud stack wears Nextcloud's.
|
||||
repository = str(image.get("reference") or "").split("@", 1)[0]
|
||||
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
|
||||
logo = catalog_icons.get(basename) or ""
|
||||
if not logo:
|
||||
# The database or the cache beside an application.
|
||||
logo = _OCI_SERVICE_ICONS.get(basename, "")
|
||||
if not logo:
|
||||
logo = ui.get("icon") if isinstance(ui.get("icon"), str) else ""
|
||||
website = ui.get("website") if isinstance(ui.get("website"), str) else ""
|
||||
|
||||
@@ -1716,6 +1716,27 @@ def _store_update_result(item: dict, result: dict) -> None:
|
||||
item["update_check"][extra_key] = result[extra_key]
|
||||
|
||||
|
||||
def refresh_oci_app(app_id: str) -> Optional[dict]:
|
||||
"""Read one OCI-managed application again and store what it reports.
|
||||
|
||||
Its card on the Security page and the Updates tab of its container read
|
||||
different stores; after an update or a forced check from either one, the
|
||||
registry is brought to the same state the application reports now."""
|
||||
with _lock:
|
||||
reg = _read_registry()
|
||||
for it in reg.get("items", []):
|
||||
if (it.get("type") != "oci_app" or it.get("removed_at")
|
||||
or it.get("_oci_app_id") != app_id):
|
||||
continue
|
||||
result = _check_oci_app(it)
|
||||
_store_update_result(it, result)
|
||||
if result.get("current"):
|
||||
it["current_version"] = result["current"]
|
||||
_write_registry(reg)
|
||||
return it
|
||||
return None
|
||||
|
||||
|
||||
def check_for_updates(force: bool = False) -> list[dict]:
|
||||
"""Run every type-specific checker over active items, persist
|
||||
the updated state, return the list of items that have an update
|
||||
|
||||
@@ -1275,6 +1275,16 @@ class NotificationManager:
|
||||
if self._is_backup_running():
|
||||
return
|
||||
|
||||
# The stop, the backup and the start of a container the OCI manager is
|
||||
# updating or recreating are steps of that operation, which reports
|
||||
# its own result when it ends.
|
||||
try:
|
||||
import oci_operations
|
||||
if oci_operations.quiet(event):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check storage exclusions for storage-related events.
|
||||
# If the storage is excluded from notifications, suppress the event entirely.
|
||||
_STORAGE_EVENTS = {'storage_unavailable', 'storage_low_space', 'storage_warning', 'storage_error',
|
||||
|
||||
@@ -870,6 +870,44 @@ TEMPLATES = {
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'oci_update_completed': {
|
||||
'title': '{hostname}: {app_name} updated',
|
||||
'body': '{app_name} was updated to its new image and its data was kept.\nContainers: {containers}',
|
||||
'label': 'OCI application updated',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'oci_update_failed': {
|
||||
'title': '{hostname}: {app_name} update did not complete',
|
||||
'body': (
|
||||
'The update of {app_name} did not complete.\n'
|
||||
'Reason: {reason}\n'
|
||||
'Containers: {containers}\n'
|
||||
'Open Manage installed OCI applications to check its state.'
|
||||
),
|
||||
'label': 'OCI application update failed',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'oci_recreate_completed': {
|
||||
'title': '{hostname}: {app_name} recreated',
|
||||
'body': '{app_name} was recreated with its new options and its data was kept.\nContainers: {containers}',
|
||||
'label': 'OCI application recreated',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'oci_recreate_failed': {
|
||||
'title': '{hostname}: {app_name} recreation did not complete',
|
||||
'body': (
|
||||
'The recreation of {app_name} did not complete.\n'
|
||||
'Reason: {reason}\n'
|
||||
'Containers: {containers}\n'
|
||||
'Open Manage installed OCI applications to check its state.'
|
||||
),
|
||||
'label': 'OCI application recreation failed',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'app_update_available': {
|
||||
'title': '{hostname}: {app_name} update available on CT {vmid}',
|
||||
'body': (
|
||||
@@ -2314,6 +2352,10 @@ EVENT_EMOJI = {
|
||||
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
|
||||
'apt_listchanges': '\U0001F4E6', # package-maintainer NEWS via PVE mail
|
||||
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
|
||||
'oci_update_completed': '\u2705',
|
||||
'oci_update_failed': '\u26A0\uFE0F',
|
||||
'oci_recreate_completed': '\u2705',
|
||||
'oci_recreate_failed': '\u26A0\uFE0F',
|
||||
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
|
||||
'docker_stack_update_available': '\U0001F433',
|
||||
'vm_start': '\u25B6\uFE0F', # play button
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Containers an OCI manager operation is working on.
|
||||
|
||||
An update or a recreation stops, backs up and starts its containers. The OCI
|
||||
engine marks them while it works and reports the result itself, so their
|
||||
stop, start and backup notices are part of the operation, not news.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
MARKERS = '/run/proxmenux/oci-operations'
|
||||
# The last start of an operation is noticed a little after it returned.
|
||||
GRACE_SECONDS = 180
|
||||
# A mark left behind by an operation that died is not trusted for ever.
|
||||
STALE_SECONDS = 6 * 3600
|
||||
QUIET_EVENTS = frozenset({
|
||||
'vm_start', 'vm_stop', 'vm_shutdown', 'vm_restart',
|
||||
'ct_start', 'ct_stop', 'ct_shutdown', 'ct_restart',
|
||||
'backup_start', 'backup_complete',
|
||||
})
|
||||
_OCI_BACKUP_PATH = '/proxmenux/oci/instances/'
|
||||
|
||||
|
||||
def active(vmid, now=None, root=MARKERS) -> bool:
|
||||
try:
|
||||
with open(os.path.join(root, str(int(vmid))), encoding='utf-8') as handle:
|
||||
mark = json.load(handle)
|
||||
started = float(mark.get('started') or 0)
|
||||
ended = mark.get('ended')
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
now = time.time() if now is None else now
|
||||
if ended is None:
|
||||
return now - started < STALE_SECONDS
|
||||
return now - float(ended) < GRACE_SECONDS
|
||||
|
||||
|
||||
def _vmids(event) -> set:
|
||||
data = event.data or {}
|
||||
found = set()
|
||||
for value in (data.get('vmid'), getattr(event, 'entity_id', '')):
|
||||
if str(value or '').isdigit():
|
||||
found.add(int(value))
|
||||
if event.event_type.startswith('backup_'):
|
||||
text = ' '.join(str(data.get(key) or '') for key in ('reason', 'pve_message', 'vmname', 'guests'))
|
||||
found.update(int(value) for value in re.findall(r'\((\d{3,})\)|vzdump-(?:lxc|qemu)-(\d+)-', text)
|
||||
for value in value if value)
|
||||
return found
|
||||
|
||||
|
||||
def quiet(event, root=MARKERS) -> bool:
|
||||
"""Whether the event is a step of a running OCI operation."""
|
||||
if event.event_type not in QUIET_EVENTS or event.severity in ('CRITICAL', 'WARNING'):
|
||||
return False
|
||||
data = event.data or {}
|
||||
if event.event_type.startswith('backup_') and any(
|
||||
_OCI_BACKUP_PATH in str(data.get(key) or '') for key in ('reason', 'pve_message', 'filename')):
|
||||
# The working copy of an update lives in the OCI registry of the host.
|
||||
return True
|
||||
vmids = _vmids(event)
|
||||
return bool(vmids) and all(active(vmid, root=root) for vmid in vmids)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""A bridge with no port attached has no carrier and is not a failure: the
|
||||
private network of an application whose containers are stopped."""
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
from health_monitor import HealthMonitor
|
||||
|
||||
|
||||
class IdleBridgeTests(unittest.TestCase):
|
||||
def bridge(self, flags, ports=()):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
folder = Path(tmp.name) / 'vmbr10'
|
||||
(folder / 'brif').mkdir(parents=True)
|
||||
(folder / 'flags').write_text(flags + '\n')
|
||||
for port in ports:
|
||||
(folder / 'brif' / port).mkdir()
|
||||
return HealthMonitor._bridge_is_idle('vmbr10', tmp.name)
|
||||
|
||||
def test_an_up_bridge_with_no_port_is_idle(self):
|
||||
self.assertTrue(self.bridge('0x1003'))
|
||||
|
||||
def test_a_bridge_with_ports_and_no_carrier_is_not_idle(self):
|
||||
self.assertFalse(self.bridge('0x1003', ['enp3s0']))
|
||||
self.assertFalse(self.bridge('0x1003', ['veth115i1']))
|
||||
|
||||
def test_a_bridge_set_down_is_not_idle(self):
|
||||
self.assertFalse(self.bridge('0x1002'))
|
||||
|
||||
def test_an_interface_that_is_not_a_bridge_is_not_idle(self):
|
||||
self.assertFalse(HealthMonitor._bridge_is_idle('vmbr10', '/nonexistent'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""While the OCI manager updates or recreates an application, the stop, the
|
||||
backup and the start of its containers are steps of that operation."""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import oci_operations
|
||||
|
||||
|
||||
def event(kind, vmid=None, severity='INFO', **data):
|
||||
if vmid is not None:
|
||||
data['vmid'] = str(vmid)
|
||||
return SimpleNamespace(event_type=kind, severity=severity, data=data, entity_id=str(vmid or ''))
|
||||
|
||||
|
||||
class OperationQuietTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = tmp.name
|
||||
|
||||
def mark(self, vmid, started, ended=None):
|
||||
Path(self.root, str(vmid)).write_text(json.dumps({'started': started, 'ended': ended}))
|
||||
|
||||
def test_the_steps_of_a_running_operation_are_quiet(self):
|
||||
self.mark(115, time.time())
|
||||
for kind in ('ct_shutdown', 'ct_stop', 'ct_start', 'ct_restart', 'backup_start', 'backup_complete'):
|
||||
self.assertTrue(oci_operations.quiet(event(kind, 115), self.root), kind)
|
||||
|
||||
def test_another_container_is_still_reported(self):
|
||||
self.mark(115, time.time())
|
||||
self.assertFalse(oci_operations.quiet(event('ct_stop', 200), self.root))
|
||||
self.assertFalse(oci_operations.quiet(event('ct_stop'), self.root))
|
||||
|
||||
def test_a_problem_is_never_silenced(self):
|
||||
self.mark(115, time.time())
|
||||
self.assertFalse(oci_operations.quiet(event('ct_stop', 115, severity='WARNING'), self.root))
|
||||
self.assertFalse(oci_operations.quiet(event('ct_fail', 115), self.root))
|
||||
self.assertFalse(oci_operations.quiet(event('backup_fail', 115), self.root))
|
||||
|
||||
def test_the_last_start_is_still_quiet_just_after_the_operation(self):
|
||||
now = time.time()
|
||||
self.mark(115, now - 60, ended=now - 60)
|
||||
self.assertTrue(oci_operations.quiet(event('ct_start', 115), self.root))
|
||||
self.mark(115, now - 3600, ended=now - 3600)
|
||||
self.assertFalse(oci_operations.quiet(event('ct_start', 115), self.root))
|
||||
|
||||
def test_a_mark_left_by_a_dead_operation_expires(self):
|
||||
self.mark(115, time.time() - 7 * 3600)
|
||||
self.assertFalse(oci_operations.quiet(event('ct_stop', 115), self.root))
|
||||
|
||||
def test_the_working_copy_of_an_update_is_recognised_by_its_path(self):
|
||||
archive = ('/usr/local/share/proxmenux/oci/instances/115/stack-transactions/abc/backup-117/'
|
||||
'vzdump-lxc-117-2026_10_02-20_45_41.tar.zst')
|
||||
self.assertTrue(oci_operations.quiet(event('backup_complete', pve_message=archive), self.root))
|
||||
self.assertFalse(oci_operations.quiet(
|
||||
event('backup_complete', pve_message='/var/lib/vz/dump/vzdump-lxc-117-2026.tar.zst'), self.root))
|
||||
|
||||
def test_a_backup_of_several_guests_is_quiet_only_when_all_belong_to_the_operation(self):
|
||||
self.mark(115, time.time())
|
||||
self.mark(117, time.time())
|
||||
both = event('backup_start', reason='VM/CT:\n CT immich-server (115)\n CT immich-db (117)')
|
||||
mixed = event('backup_start', reason='VM/CT:\n CT immich-db (117)\n CT other (200)')
|
||||
self.assertTrue(oci_operations.quiet(both, self.root))
|
||||
self.assertFalse(oci_operations.quiet(mixed, self.root))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""A secondary container of an OCI stack is registered with the version it
|
||||
runs and no version tracking; its application is updated with the stack."""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import lxc_apps
|
||||
|
||||
DIGEST = 'sha256:' + 'ab' * 32
|
||||
|
||||
|
||||
class StackMemberRegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
(self.root / 'catalog').mkdir()
|
||||
(self.root / 'catalog/index.json').write_text(json.dumps({'applications': []}))
|
||||
patches = [patch.object(lxc_apps, '_OCI_INSTANCE_ROOT', str(self.root / 'instances')),
|
||||
patch.object(lxc_apps, '_OCI_CATALOG_INDEX', str(self.root / 'catalog/index.json')),
|
||||
patch.object(lxc_apps, '_oci_catalog_cache', None),
|
||||
patch.object(lxc_apps, '_APPS_DIR', str(self.root / 'apps')),
|
||||
patch.object(lxc_apps, '_OCI_DISMISSED_FILE', str(self.root / 'apps/.oci-dismissed.json'))]
|
||||
for item in patches:
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
def record(self, vmid, primary, reference):
|
||||
folder = self.root / f'instances/{vmid}'
|
||||
folder.mkdir(parents=True)
|
||||
(folder / 'oci-compose.json').write_text(json.dumps({
|
||||
'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
|
||||
'stack_member': {'name': 'database', 'primary_vmid': primary},
|
||||
'stack': {'template': {'id': 'stack-nextcloud', 'catalog_ui': {'title': 'Nextcloud'},
|
||||
'first_run': {'endpoints': [{'port': 80, 'scheme': 'http', 'path': '/'}]}}},
|
||||
'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
|
||||
'template': {'id': f'stack-nextcloud-{vmid}',
|
||||
'container_contract': {'image': {'reference': reference},
|
||||
'ports': [{'container_port': 5432}]}}}))
|
||||
|
||||
def test_the_database_of_a_stack_is_registered_without_a_web_port(self):
|
||||
self.record(131, 129, 'docker.io/library/postgres:16-alpine')
|
||||
with patch.object(lxc_apps, 'add_app', return_value=(True, {})) as add:
|
||||
self.assertTrue(lxc_apps.ensure_oci_registration(131))
|
||||
payload = add.call_args.args[1]
|
||||
self.assertEqual(payload['name'], 'Postgres')
|
||||
self.assertEqual(payload['installed_via'], 'oci_image')
|
||||
self.assertEqual(payload['ports'], [])
|
||||
|
||||
def test_a_secondary_container_is_told_apart_from_the_main_one(self):
|
||||
self.assertTrue(lxc_apps._oci_secondary_member({'vmid': 131, 'stack_member': {'primary_vmid': 129}}))
|
||||
self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 129, 'stack_member': {'primary_vmid': 129}}))
|
||||
self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 120}))
|
||||
self.assertFalse(lxc_apps._oci_secondary_member(None))
|
||||
|
||||
def test_the_registry_is_not_asked_for_a_secondary_container(self):
|
||||
self.record(131, 129, 'docker.io/library/postgres:16-alpine')
|
||||
|
||||
class Engine:
|
||||
@staticmethod
|
||||
def resolve_candidate(reference, architecture):
|
||||
assert '@' in reference, 'only the installed digest is read'
|
||||
return {'version': '16.15', 'created': '2026-09-01T00:00:00Z', 'manifest_digest': DIGEST}
|
||||
|
||||
with patch.object(lxc_apps, '_oci_state_module', return_value=Engine):
|
||||
result = lxc_apps._oci_image_versions(
|
||||
131, with_latest=not lxc_apps._oci_secondary_member(lxc_apps._read_oci_record(131)))
|
||||
self.assertEqual(result['installed_version'], '16.15')
|
||||
self.assertNotIn('update_available', result)
|
||||
self.assertNotIn('latest_version', result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class StackLogoTests(StackMemberRegistrationTests):
|
||||
ICON = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/immich.webp'
|
||||
|
||||
def stack(self):
|
||||
(self.root / 'catalog/index.json').write_text(json.dumps({'applications': [
|
||||
{'id': 'immich', 'template_id': 'image-immich', 'template': 'apps/immich.json', 'icon': self.ICON}]}))
|
||||
members = {115: ('server', 'ghcr.io/immich-app/immich-server:release'),
|
||||
116: ('machine-learning', 'ghcr.io/immich-app/immich-machine-learning:release'),
|
||||
117: ('database', 'ghcr.io/immich-app/postgres:14-vectorchord0.4.3'),
|
||||
118: ('valkey', 'docker.io/valkey/valkey:9')}
|
||||
for vmid, (role, reference) in members.items():
|
||||
folder = self.root / f'instances/{vmid}'
|
||||
folder.mkdir(parents=True)
|
||||
record = {'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
|
||||
'stack_member': {'name': role, 'primary_vmid': 115},
|
||||
'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
|
||||
'template': {'id': f'image-immich-{role}',
|
||||
'container_contract': {'image': {'reference': reference}, 'ports': []}}}
|
||||
if vmid == 115:
|
||||
record['stack'] = {'template': {'id': 'image-immich', 'catalog_ui': {'title': 'Immich', 'icon': None}}}
|
||||
(folder / 'oci-compose.json').write_text(json.dumps(record))
|
||||
|
||||
def test_the_main_container_and_machine_learning_wear_the_application_logo(self):
|
||||
self.stack()
|
||||
self.assertEqual(lxc_apps._oci_instance_meta(115)['logo'], self.ICON)
|
||||
self.assertEqual(lxc_apps._oci_instance_meta(116)['logo'], self.ICON)
|
||||
|
||||
def test_the_database_and_the_cache_wear_their_own(self):
|
||||
self.stack()
|
||||
self.assertTrue(lxc_apps._oci_instance_meta(117)['logo'].endswith('/postgresql.webp'))
|
||||
self.assertTrue(lxc_apps._oci_instance_meta(118)['logo'].endswith('/valkey.webp'))
|
||||
|
||||
def test_an_application_registered_without_logo_takes_it_later(self):
|
||||
self.stack()
|
||||
sidecar = {'vmid': 115, 'apps': [{'id': 'a', 'name': 'Immich', 'installed_via': 'oci_image', 'logo_url': ''},
|
||||
{'id': 'b', 'name': 'Other', 'installed_via': '', 'logo_url': ''}]}
|
||||
written = {}
|
||||
with patch.object(lxc_apps, '_read_sidecar', return_value=sidecar), \
|
||||
patch.object(lxc_apps, '_write_sidecar', side_effect=lambda vmid, data: written.update(data)):
|
||||
self.assertFalse(lxc_apps.ensure_oci_registration(115))
|
||||
self.assertEqual(written['apps'][0]['logo_url'], self.ICON)
|
||||
self.assertEqual(written['apps'][1]['logo_url'], '')
|
||||
Reference in New Issue
Block a user