diff --git a/.github/scripts/tests/test_oci_recovery_inventory_wording.py b/.github/scripts/tests/test_oci_recovery_inventory_wording.py index fd825a97..d63dd51d 100644 --- a/.github/scripts/tests/test_oci_recovery_inventory_wording.py +++ b/.github/scripts/tests/test_oci_recovery_inventory_wording.py @@ -80,6 +80,7 @@ class InventoryMessages(unittest.TestCase): 'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None), 'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None), 'image_cache': SimpleNamespace(prune=lambda root, lock: []), + 'work_backup': SimpleNamespace(forget=lambda root, vmid: None), 'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))), 're': re, 'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: events.append(('host', vmid)), diff --git a/.github/scripts/tests/test_oci_removal_firewall_wording.py b/.github/scripts/tests/test_oci_removal_firewall_wording.py index 328e760e..427ac9e0 100644 --- a/.github/scripts/tests/test_oci_removal_firewall_wording.py +++ b/.github/scripts/tests/test_oci_removal_firewall_wording.py @@ -117,6 +117,7 @@ class RemovalWordings(unittest.TestCase): 'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value), 'shutil': SimpleNamespace(rmtree=lambda path: None), 'image_cache': SimpleNamespace(prune=lambda root, lock: []), + 'work_backup': SimpleNamespace(forget=lambda root, vmid: None), 'oci_console': SimpleNamespace(remove_log=lambda vmid: None), 'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re, 'translate': lambda text: text, @@ -186,6 +187,7 @@ class RemovalWordings(unittest.TestCase): 'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value), 'shutil': SimpleNamespace(rmtree=lambda path: None), 'image_cache': SimpleNamespace(prune=lambda root, lock: []), + 'work_backup': SimpleNamespace(forget=lambda root, vmid: None), 'oci_console': SimpleNamespace(remove_log=lambda vmid: None), 'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 'translate': lambda text: text, @@ -264,6 +266,7 @@ class RemovalWordings(unittest.TestCase): 'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value), 'shutil': SimpleNamespace(rmtree=lambda path: None), 'image_cache': SimpleNamespace(prune=lambda root, lock: []), + 'work_backup': SimpleNamespace(forget=lambda root, vmid: None), 'oci_console': SimpleNamespace(remove_log=lambda vmid: None), 'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re, 'translate': lambda text: text, 'msg_info': lambda text: None, diff --git a/.github/scripts/tests/test_oci_update_preservation_wording.py b/.github/scripts/tests/test_oci_update_preservation_wording.py index b99c311f..44146970 100644 --- a/.github/scripts/tests/test_oci_update_preservation_wording.py +++ b/.github/scripts/tests/test_oci_update_preservation_wording.py @@ -132,6 +132,7 @@ class UpdateWording(unittest.TestCase): 'translate': lambda text: text, 'check_selected': lambda project, row: row, 'images': SimpleNamespace(offer_removal=lambda *args: None), 'carry_records': lambda project, **options: {}, + '_work_backup_ready': lambda project, ui, vmid: True, '_run_lifecycle': lambda command, title: events.append(('run', command, title)) or True} row = {'vmid': 101, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'} decision = False 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/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 2d687e97..7525b7e8 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -116,7 +116,6 @@ interface DiskObservation { occurrence_count: number raw_message: string severity: string - dismissed: boolean device_name: string serial: string model: string @@ -2887,7 +2886,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri const severityLabel = severityText(obs.severity) const firstDate = obs.first_occurrence ? new Date(obs.first_occurrence).toLocaleString() : na const lastDate = obs.last_occurrence ? new Date(obs.last_occurrence).toLocaleString() : na - const dismissedBadge = obs.dismissed ? `${t("storage.smartReport.dismissed")}` : '' const errorTypeLabel = observationTypeText(type) obsItemsHtml += ` @@ -2897,7 +2895,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri ${severityLabel} ID: #${obs.id} ${t("storage.smartReport.occurrences")}: ${obs.occurrence_count} - ${dismissedBadge}
diff --git a/AppImage/scripts/audit_checks_pve.py b/AppImage/scripts/audit_checks_pve.py index d04b95ad..f5f4d73e 100644 --- a/AppImage/scripts/audit_checks_pve.py +++ b/AppImage/scripts/audit_checks_pve.py @@ -3268,15 +3268,12 @@ def _disk_errors(ctx): """ records = _recorded_disk_events() if not records: - # An empty store and a store whose entries were all dismissed - # look the same from here, and neither supports the claim that - # no disk reported an error. Saying which of the two it is is - # not possible; saying there is nothing to grade is. + # An empty store does not support the claim that no disk ever + # reported an error: the monitor may not have been running when + # one did. Saying there is nothing to grade is what can be said. return {"classification": CLASS_NOT_APPLICABLE, "summary_key": "noEvents", - "evidence": "The health monitor holds no undismissed disk " - "event. A dismissed observation is dropped by " - "the monitor and is not read back here."} + "evidence": "The health monitor holds no disk event."} now = time.time() recent_days = ctx.policy.threshold("disk_error_recent_days") @@ -3316,9 +3313,8 @@ def _disk_errors(ctx): evidence += ("\nRecorded because Linux reported them; neither SMART nor " "Proxmox surfaces these, and both may report the device as " "healthy. Stated as separate warnings without overriding " - "that current health result. Events the reader dismissed are not " - "listed: the " - "monitor drops them and this reads what the monitor keeps. " + "that current health result. Every recorded event is " + "listed: the history of a disk is permanent. " "Recorded by the health monitor as events occurred. SMART " "reports the device's present opinion of itself and is read " "by a separate check; a device can report healthy while its " diff --git a/AppImage/scripts/audit_inventory.py b/AppImage/scripts/audit_inventory.py index eb0ca324..79074d85 100644 --- a/AppImage/scripts/audit_inventory.py +++ b/AppImage/scripts/audit_inventory.py @@ -587,6 +587,33 @@ def _disk_observations() -> dict[str, list[dict[str, Any]]]: return grouped +def _observations_of(name: str, serial: str, by_name: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]: + """The events of the disk that is plugged there now. + + A device name is a connector: the disk that was there before keeps its + own history. The Monitor tells them apart by serial number; the events + grouped by name are only used when it cannot be asked. + """ + server = sys.modules.get("flask_server") or sys.modules.get("__main__") + getter = getattr(getattr(server, "health_persistence", None), "get_disk_observations", None) + if getter is None: + return by_name.get(name, []) + try: + records = getter(name, serial or None) or [] + except Exception: + return by_name.get(name, []) + entries = [{ + "type": record.get("error_type", ""), + "severity": record.get("severity", ""), + "count": record.get("occurrence_count", 0), + "first_seen": record.get("first_occurrence"), + "last_seen": record.get("last_occurrence"), + "message": (record.get("raw_message") or "")[:400], + } for record in records] + entries.sort(key=lambda e: e.get("last_seen") or 0, reverse=True) + return entries + + def _physical_disks(ctx) -> list[dict[str, Any]]: observations = _disk_observations() # The SMART cache is keyed by device, each entry a (collected_at, data) @@ -613,7 +640,7 @@ def _physical_disks(ctx) -> list[dict[str, Any]]: "health": health.get("smart_status"), "temperature": health.get("temperature"), "power_on_hours": health.get("power_on_hours"), - "observations": observations.get(name, []), + "observations": _observations_of(name, (row.get("SERIAL") or "").strip(), observations), }) return sorted(disks, key=lambda d: d["name"]) diff --git a/AppImage/scripts/flask_health_routes.py b/AppImage/scripts/flask_health_routes.py index e04cd5b8..7cad09f7 100644 --- a/AppImage/scripts/flask_health_routes.py +++ b/AppImage/scripts/flask_health_routes.py @@ -325,12 +325,6 @@ def cleanup_orphan_errors(): health_persistence.resolve_error(err_key, 'Device no longer present (manual cleanup)') cleaned.append({'error_key': err_key, 'device': dev_path}) - # Also cleanup disk_observations for non-existent devices - try: - health_persistence.cleanup_orphan_observations() - except Exception: - pass - return jsonify({ 'success': True, 'cleaned_count': len(cleaned), diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 0e2c19cd..2354141d 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -895,6 +895,20 @@ def _pve_webhook_url() -> str: _PVE_WEBHOOK_URL = _pve_webhook_url() +_PVE_NOT_MOUNTED = 'The Proxmox cluster filesystem (/etc/pve) is not mounted' + + +def _pve_cluster_fs_mounted() -> bool: + """Whether /etc/pve is the Proxmox cluster filesystem. + + While its service is stopped or restarting, /etc/pve is an empty + directory of the root disk. A file written there is not a configuration + file: it keeps the service from mounting the filesystem again. + """ + import os + return os.path.ismount(os.path.dirname(_PVE_NOTIFICATIONS_CFG)) + + def _pve_read_file(path): """Read file, return (content, error). Content is '' if missing.""" try: @@ -909,26 +923,47 @@ def _pve_read_file(path): _PVE_BACKUPS_KEPT = 3 +# Copies of Proxmox's notification settings, taken before changing them. They +# are kept with ProxMenux, not next to the files: /etc/pve belongs to Proxmox +# and is replicated to every node of a cluster. +_PVE_BACKUP_DIR = '/usr/local/share/proxmenux/backups/pve-notifications' +_PVE_BACKUP_MARK = '.proxmenux_backup_' + + +def _pve_backup_prefix(path): + """Name the copies of a file are kept under. The private file carries + its own name, as both are called notifications.cfg.""" + import os + name = os.path.basename(path) + return ('priv-' if path == _PVE_PRIV_CFG else '') + name + _PVE_BACKUP_MARK + + +def _pve_backups(path): + """The copies kept of a file, newest first.""" + import os + prefix = _pve_backup_prefix(path) + try: + names = os.listdir(_PVE_BACKUP_DIR) + except OSError: + return [] + found = [os.path.join(_PVE_BACKUP_DIR, name) for name in names if name.startswith(prefix)] + return sorted(found, key=os.path.getmtime, reverse=True) def _pve_backup_file(path): - """Create timestamped backup if file exists. Never fails fatally. + """Keep a timestamped copy of the file if it exists. Never fails fatally. - The backups live next to the file, inside the cluster filesystem, whose - size is limited: a copy identical to the newest one is not written again - and only the newest few are kept. Restore uses the newest. + A copy identical to the newest one is not written again and only the + newest few are kept. Restore uses the newest. """ import os, shutil from datetime import datetime try: if not os.path.exists(path): return - directory, name = os.path.split(path) - prefix = f"{name}.proxmenux_backup_" - existing = sorted( - (os.path.join(directory, f) for f in os.listdir(directory) if f.startswith(prefix)), - key=os.path.getmtime, reverse=True, - ) + os.makedirs(_PVE_BACKUP_DIR, mode=0o700, exist_ok=True) + os.chmod(_PVE_BACKUP_DIR, 0o700) + existing = _pve_backups(path) with open(path, 'rb') as f: current = f.read() newest_matches = False @@ -940,16 +975,48 @@ def _pve_backup_file(path): pass if not newest_matches: ts = datetime.now().strftime('%Y%m%d_%H%M%S') - shutil.copy2(path, os.path.join(directory, prefix + ts)) - existing = sorted( - (os.path.join(directory, f) for f in os.listdir(directory) if f.startswith(prefix)), - key=os.path.getmtime, reverse=True, - ) - for stale in existing[_PVE_BACKUPS_KEPT:]: - try: - os.remove(stale) - except OSError: - pass + destination = os.path.join(_PVE_BACKUP_DIR, _pve_backup_prefix(path) + ts) + shutil.copy2(path, destination) + # The private file holds the secrets of every notification target. + os.chmod(destination, 0o600) + _pve_prune_backups(path) + except Exception: + pass + + +def _pve_prune_backups(path): + """Keep only the newest copies of a file.""" + import os + for stale in _pve_backups(path)[_PVE_BACKUPS_KEPT:]: + try: + os.remove(stale) + except OSError: + pass + + +def _pve_collect_legacy_backups(path): + """Earlier versions left the copies next to the file, inside /etc/pve. + The newest ones are moved with the rest and none is left there.""" + import os, shutil + try: + directory, name = os.path.split(path) + legacy = sorted( + (os.path.join(directory, f) for f in os.listdir(directory) if f.startswith(name + _PVE_BACKUP_MARK)), + key=os.path.getmtime, reverse=True, + ) + if not legacy: + return + os.makedirs(_PVE_BACKUP_DIR, mode=0o700, exist_ok=True) + os.chmod(_PVE_BACKUP_DIR, 0o700) + for position, source in enumerate(legacy): + if position < _PVE_BACKUPS_KEPT: + stamp = os.path.basename(source)[len(name + _PVE_BACKUP_MARK):] + destination = os.path.join(_PVE_BACKUP_DIR, _pve_backup_prefix(path) + stamp) + if not os.path.exists(destination): + shutil.copy2(source, destination) + os.chmod(destination, 0o600) + os.remove(source) + _pve_prune_backups(path) except Exception: pass @@ -1091,6 +1158,11 @@ def setup_pve_webhook_core() -> dict: } try: + if not _pve_cluster_fs_mounted(): + result['error'] = _PVE_NOT_MOUNTED + result['waiting'] = True + return result + # ── Step 1: Ensure webhook secret exists (for our own internal use) ── secret = notification_manager.get_webhook_secret() if not secret: @@ -1109,11 +1181,10 @@ def setup_pve_webhook_core() -> dict: if err: priv_text = None - # ── Step 4: Create backups before ANY modification ── - _pve_backup_file(_PVE_NOTIFICATIONS_CFG) - if priv_text is not None: - _pve_backup_file(_PVE_PRIV_CFG) - + # ── Step 4: Backups are taken below, only of a file about to change ── + _pve_collect_legacy_backups(_PVE_NOTIFICATIONS_CFG) + _pve_collect_legacy_backups(_PVE_PRIV_CFG) + # ── Step 5: Remove any previous proxmenux blocks from BOTH files ── cleaned_cfg = _pve_remove_our_blocks(cfg_text, _PVE_OUR_HEADERS) @@ -1161,10 +1232,12 @@ def setup_pve_webhook_core() -> dict: new_cfg = cleaned_cfg + endpoint_block + '\n' + matcher_block - # ── Step 8: Write main config ── + # ── Step 8: Write main config, when it is not already as wanted ── try: - with open(_PVE_NOTIFICATIONS_CFG, 'w') as f: - f.write(new_cfg) + if new_cfg != cfg_text: + _pve_backup_file(_PVE_NOTIFICATIONS_CFG) + with open(_PVE_NOTIFICATIONS_CFG, 'w') as f: + f.write(new_cfg) except PermissionError: result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}' result['fallback_commands'] = _build_webhook_fallback() @@ -1214,8 +1287,11 @@ def setup_pve_webhook_core() -> dict: new_priv = priv_block try: - with open(_PVE_PRIV_CFG, 'w') as f: - f.write(new_priv) + if new_priv != priv_text: + if priv_text is not None: + _pve_backup_file(_PVE_PRIV_CFG) + with open(_PVE_PRIV_CFG, 'w') as f: + f.write(new_priv) except PermissionError: result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}' result['fallback_commands'] = _build_webhook_fallback() @@ -1238,6 +1314,17 @@ def setup_pve_webhook_core() -> dict: return result +def setup_pve_webhook_when_mounted(timeout=900, interval=5) -> dict: + """Configure the webhook once /etc/pve is mounted. For the Monitor that + starts while the cluster service is still starting or being restarted.""" + deadline = time.monotonic() + timeout + while not _pve_cluster_fs_mounted(): + if time.monotonic() >= deadline: + return {'configured': False, 'error': _PVE_NOT_MOUNTED, 'waiting': True} + time.sleep(interval) + return setup_pve_webhook_core() + + @notification_bp.route('/api/notifications/proxmox/setup-webhook', methods=['POST']) @require_auth def setup_proxmox_webhook(): @@ -1254,6 +1341,10 @@ def cleanup_pve_webhook_core() -> dict: result = {'cleaned': False, 'error': None} try: + if not _pve_cluster_fs_mounted(): + result['error'] = _PVE_NOT_MOUNTED + return result + # Read both files cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG) if err: @@ -1336,10 +1427,6 @@ def read_pve_notification_cfg(): 'priv_cfg': '/etc/pve/priv/notifications.cfg', } - # Also look for any backups we created - backup_dir = '/etc/pve' - priv_backup_dir = '/etc/pve/priv' - result = {} for key, path in files.items(): try: @@ -1357,24 +1444,18 @@ def read_pve_notification_cfg(): except Exception as e: result[key] = {'path': path, 'content': None, 'size': 0, 'error': str(e)} - # Find backups + # The copies kept of both files backups = [] - for d in [backup_dir, priv_backup_dir]: + for fpath in _pve_backups(_PVE_NOTIFICATIONS_CFG) + _pve_backups(_PVE_PRIV_CFG): try: - for fname in sorted(os.listdir(d)): - if 'proxmenux_backup' in fname: - fpath = os.path.join(d, fname) - try: - with open(fpath, 'r') as f: - backups.append({ - 'path': fpath, - 'content': f.read(), - 'size': os.path.getsize(fpath), - }) - except Exception: - backups.append({'path': fpath, 'content': None, 'error': 'read_failed'}) + with open(fpath, 'r') as f: + backups.append({ + 'path': fpath, + 'content': f.read(), + 'size': os.path.getsize(fpath), + }) except Exception: - pass + backups.append({'path': fpath, 'content': None, 'error': 'read_failed'}) result['backups'] = backups return jsonify(result), 200 @@ -1391,35 +1472,26 @@ def restore_pve_notification_cfg(): import os import shutil - files_to_restore = { - '/etc/pve': '/etc/pve/notifications.cfg', - '/etc/pve/priv': '/etc/pve/priv/notifications.cfg', - } - restored = [] errors = [] - - for search_dir, target_path in files_to_restore.items(): - try: - # Pick the most recent backup by mtime, not lexicographic name. - # An attacker (or accidental rename) with a write primitive - # could craft `notifications.cfg.proxmenux_backup_99999999_999999` - # and have it sort first, hijacking the restore. mtime tracks - # the actual file age so renamed/touched files don't fool us. - # Audit Tier 3.1 — restore-cfg sort lexicográfico. - candidates = [ - f for f in os.listdir(search_dir) - if 'proxmenux_backup' in f and f.startswith('notifications.cfg') - ] + if not _pve_cluster_fs_mounted(): + return jsonify({'restored': [], 'errors': [{'target': '/etc/pve', 'error': _PVE_NOT_MOUNTED}], + 'success': False}), 200 + + for target_path in (_PVE_NOTIFICATIONS_CFG, _PVE_PRIV_CFG): + try: + _pve_collect_legacy_backups(target_path) + # The newest by modification time: a name can be crafted to sort + # first, a file's age cannot. + candidates = _pve_backups(target_path) if candidates: - candidates.sort( - key=lambda f: os.path.getmtime(os.path.join(search_dir, f)), - reverse=True, - ) - backup_path = os.path.join(search_dir, candidates[0]) - shutil.copy2(backup_path, target_path) - restored.append({'target': target_path, 'from_backup': backup_path}) + # Content only: the cluster filesystem sets its own permissions. + with open(candidates[0], 'rb') as source: + content = source.read() + with open(target_path, 'wb') as target: + target.write(content) + restored.append({'target': target_path, 'from_backup': candidates[0]}) else: errors.append({'target': target_path, 'error': 'no_backup_found'}) except Exception as e: diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 823a5e1a..0df590f5 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,22 +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) - # Auto-dismiss stale observations (> 30 days old) - health_persistence.cleanup_stale_observations() + disk_info['observations_count'] = health_persistence.count_disk_observations( + disk_name, disk_info.get('serial', '')) except Exception: pass @@ -22844,6 +22875,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..a94d13e7 100644 --- a/AppImage/scripts/health_persistence.py +++ b/AppImage/scripts/health_persistence.py @@ -463,9 +463,8 @@ class HealthPersistence: # and looks "new" again on the next boot. Audit Tier 5 — Health stack. # # IMPORTANT: Only cleans the `errors` table (health monitor state). - # The `disk_observations` table is a PERMANENT historical record - # and must NEVER be auto-modified on startup. Users dismiss - # observations manually from the disk detail UI. + # The `disk_observations` table is a PERMANENT historical record: + # nothing removes, hides or dismisses an observation. # # Covers: disk I/O (smart_*, disk_*), VM/CT (vm_*, ct_*, vmct_*), # and log errors (log_*) — all journal-sourced categories. @@ -510,6 +509,30 @@ class HealthPersistence: except Exception as e: print(f"[HealthPersistence] Startup cleanup warning: {e}") + # Earlier versions marked an observation as dismissed once it had not + # been seen for 30 days, or when its device was gone, and left it out + # of the history of its disk even when the error came back. The mark + # is no longer used; it is taken off once so the table says what is + # shown. + _OBSERVATION_HISTORY_VERSION = '1' + try: + cursor = conn.cursor() + cursor.execute('SELECT setting_value FROM user_settings WHERE setting_key = ?', + ('observation_history_version',)) + row = cursor.fetchone() + if not (row and row[0] == _OBSERVATION_HISTORY_VERSION): + cursor.execute('UPDATE disk_observations SET dismissed = 0 WHERE dismissed = 1') + restored = cursor.rowcount + cursor.execute(''' + INSERT OR REPLACE INTO user_settings (setting_key, setting_value, updated_at) + VALUES (?, ?, ?) + ''', ('observation_history_version', _OBSERVATION_HISTORY_VERSION, datetime.now().isoformat())) + conn.commit() + if restored > 0: + print(f"[HealthPersistence] Disk observation history: {restored} observation(s) shown again") + except Exception as e: + print(f"[HealthPersistence] Observation history warning: {e}") + conn.close() @staticmethod @@ -1437,12 +1460,8 @@ class HealthPersistence: # Clean up errors for resources that no longer exist (VMs/CTs deleted, disks removed) self._cleanup_stale_resources() - # NOTE: cleanup_orphan_observations() is deliberately NOT invoked here. - # Running it on the 5-minute auto-resolve cycle silently dismissed legitimate - # observations (ZFS pool errors, ATA host events, dm-* aliases) before the user - # could see them in the UI history, even though notifications were already sent. - # The cleanup is still available as an explicit user action via - # POST /api/health/cleanup-disconnected-disks (flask_health_routes.py). + # NOTE: nothing here touches `disk_observations`. That table is the + # permanent history of each disk and no cycle removes or hides rows. def _cleanup_stale_resources(self): """Resolve errors for resources that no longer exist. @@ -2560,7 +2579,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: @@ -2663,17 +2682,10 @@ class HealthPersistence: first_col = 'first_occurrence' if 'first_occurrence' in columns else 'first_seen' last_col = 'last_occurrence' if 'last_occurrence' in columns else 'last_seen' - # Upsert observation: if same (disk, type, signature), bump count + update last timestamp. - # IMPORTANT: Do NOT reset dismissed — if the user dismissed this observation, - # re-detecting the same journal entry must not un-dismiss it. BUT we DO - # keep counting + updating last_occurrence even when dismissed, because the - # responsible-monitoring contract is: every error counts toward the - # accumulated total shown in the disk modal ("324 connection errors"), - # even errors of the same signature the user already saw once. Dismissed - # only mutes notifications, NOT the per-disk error history surfaced in the - # UI. Reverting the earlier "WHERE dismissed=0" gate that froze the - # counter and last_occurrence for /dev/sdh on 2026-05-09, leaving 10 - # silent days of unreported ATA errors (Pedro Rico, 19/05). + # The same observation seen again adds to its count and moves its + # last date; a different one is a new row. Observations are never + # dismissed nor hidden: the `dismissed` column stays in the table + # for databases of earlier versions and is not used. cursor.execute(f''' INSERT INTO disk_observations (disk_registry_id, {type_col}, error_signature, {first_col}, @@ -2691,13 +2703,90 @@ 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: + """How many observations a disk has: 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}', 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. + """The observations of one disk, or of every disk. + + The history of a disk is permanent and an observation cannot be + dismissed: each different one is listed, with the number of times it + was seen. """ try: conn = self._get_conn() @@ -2712,48 +2801,29 @@ 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}, - o.occurrence_count, o.raw_message, o.severity, o.dismissed, + o.occurrence_count, o.raw_message, o.severity, 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} ORDER BY o.{last_col} DESC - ''', all_ids) + ''', params) else: cursor.execute(f''' SELECT o.id, o.{type_col}, o.error_signature, o.{first_col}, o.{last_col}, - o.occurrence_count, o.raw_message, o.severity, o.dismissed, + o.occurrence_count, o.raw_message, o.severity, d.device_name, d.serial, d.model FROM disk_observations o JOIN disk_registry d ON o.disk_registry_id = d.id - WHERE o.dismissed = 0 ORDER BY o.{last_col} DESC ''') @@ -2769,10 +2839,9 @@ class HealthPersistence: 'occurrence_count': r[5], 'raw_message': r[6] or '', 'severity': r[7], - 'dismissed': bool(r[8]), - 'device_name': r[9], - 'serial': r[10], - 'model': r[11], + 'device_name': r[8], + 'serial': r[9], + 'model': r[10], } for r in rows] except Exception as e: print(f"[HealthPersistence] Error getting observations: {e}") @@ -2794,7 +2863,6 @@ class HealthPersistence: SELECT DISTINCT dr.device_name, dr.serial FROM disk_observations o JOIN disk_registry dr ON o.disk_registry_id = dr.id - WHERE o.dismissed = 0 ''') rows = cursor.fetchall() return [{'device_name': r[0], 'serial': r[1] or ''} for r in rows] @@ -2802,124 +2870,11 @@ 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: - conn = self._get_conn() - cursor = conn.cursor() - cursor.execute( - 'UPDATE disk_observations SET dismissed = 1 WHERE id = ?', - (observation_id,)) - conn.commit() - conn.close() - except Exception as e: - print(f"[HealthPersistence] Error dismissing observation: {e}") - - def cleanup_stale_observations(self, max_age_days: int = 30): - """Auto-dismiss observations not seen in max_age_days.""" - try: - from datetime import timedelta - cutoff = (datetime.now() - timedelta(days=max_age_days)).isoformat() - conn = self._get_conn() - cursor = conn.cursor() - - # Detect column name for backward compatibility - 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' - - cursor.execute(f''' - UPDATE disk_observations - SET dismissed = 1 - WHERE dismissed = 0 AND {last_col} < ? - ''', (cutoff,)) - conn.commit() - conn.close() - 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,71 +2885,19 @@ 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: print(f"[HealthPersistence] Error marking removed disks: {e}") - # Logical (non-block) device-name prefixes used as observation keys for events that - # don't map to a /dev/ entry: ZFS pool names, ATA host identifiers (e.g. "ata8" - # from "ata8.00: exception ..." journal lines), device-mapper aliases, etc. These are - # never visible in /dev/ by design, so the original presence-based cleanup would - # always wrongly dismiss them. They are excluded from automatic cleanup; the user's - # explicit "clean up disconnected disks" action also skips them. - _LOGICAL_DEVICE_PREFIXES = ('zpool_', 'ata', 'dm-', 'nbd', 'loop', 'sr') - - def cleanup_orphan_observations(self): - """ - Dismiss observations for devices that no longer exist in /dev/. - Useful for cleaning up after USB drives or temporary devices are disconnected. - - Observations whose `device_name` uses a logical (non-block) prefix are skipped — - ZFS pools, ATA hosts and dm-* aliases never appear under /dev/ by design and were - being silently dismissed by the previous version of this routine. - """ - import os - import re - try: - conn = self._get_conn() - cursor = conn.cursor() - - # Get all active (non-dismissed) observations with device info from disk_registry - cursor.execute(''' - SELECT do.id, dr.device_name, dr.serial - FROM disk_observations do - JOIN disk_registry dr ON do.disk_registry_id = dr.id - WHERE do.dismissed = 0 - ''') - observations = cursor.fetchall() - - dismissed_count = 0 - for obs_id, device_name, serial in observations: - # Skip non-block observations (ZFS pools, ATA hosts, dm-mapper, etc.) - if device_name and device_name.startswith(self._LOGICAL_DEVICE_PREFIXES): - continue - # Check if device exists - dev_path = f'/dev/{device_name}' - # Also check base device (remove partition number) - base_dev = disk_base_name(device_name) - base_path = f'/dev/{base_dev}' - - if not os.path.exists(dev_path) and not os.path.exists(base_path): - cursor.execute(''' - UPDATE disk_observations SET dismissed = 1 - WHERE id = ? - ''', (obs_id,)) - dismissed_count += 1 - - conn.commit() - conn.close() - if dismissed_count > 0: - print(f"[HealthPersistence] Cleaned up {dismissed_count} orphan observations") - return dismissed_count - except Exception as e: - print(f"[HealthPersistence] Error cleaning orphan observations: {e}") - return 0 - - # ── Remote Storage Exclusions Methods ── # Types considered "remote" and eligible for exclusion 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/notification_manager.py b/AppImage/scripts/notification_manager.py index 9a19bf63..682d76e9 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -1141,6 +1141,11 @@ class NotificationManager: wh_result = setup_pve_webhook_core() if wh_result.get('configured'): print("[NotificationManager] PVE webhook configured OK.") + elif wh_result.get('waiting'): + # /etc/pve is not mounted yet: nothing is written until it is. + print("[NotificationManager] PVE webhook: waiting for /etc/pve to be mounted.") + threading.Thread(target=self._configure_pve_webhook_when_mounted, daemon=True, + name='pve-webhook-setup').start() elif wh_result.get('error'): print(f"[NotificationManager] PVE webhook warning: {wh_result['error']}") except ImportError: @@ -2285,6 +2290,18 @@ class NotificationManager: 'auth_fail', ) + def _configure_pve_webhook_when_mounted(self): + """Configure the PVE webhook once the cluster filesystem is back.""" + try: + from flask_notification_routes import setup_pve_webhook_when_mounted + result = setup_pve_webhook_when_mounted() + if result.get('configured'): + print("[NotificationManager] PVE webhook configured OK.") + elif result.get('error'): + print(f"[NotificationManager] PVE webhook warning: {result['error']}") + except Exception as e: + print(f"[NotificationManager] PVE webhook setup error: {e}") + def _reset_cooldowns_on_start(self): """Clear DB rows in notification_last_sent for the curated set of event types listed in `_EVENT_TYPES_RESET_ON_START`. diff --git a/AppImage/scripts/test_disk_observation.py b/AppImage/scripts/test_disk_observation.py index 9dfdfa9e..b1e717f5 100644 --- a/AppImage/scripts/test_disk_observation.py +++ b/AppImage/scripts/test_disk_observation.py @@ -103,7 +103,6 @@ def main(): print(f" First: {obs['first_occurrence']}") print(f" Last: {obs['last_occurrence']}") print(f" Count: {obs['occurrence_count']}") - print(f" Dismissed: {obs['dismissed']}") print() else: print(" No observations found!\n") 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..c2a8f9d7 --- /dev/null +++ b/AppImage/scripts/tests/test_disk_observations_follow_the_disk.py @@ -0,0 +1,170 @@ +"""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_each_different_observation_is_a_row_and_a_repeated_one_adds_to_its_count(self): + self.record('sdb', 'SN-1', 'read_error') + self.record('sdb', 'SN-1', 'read_error') + self.record('sdb', 'SN-1', 'read_error') + self.record('sdb', 'SN-1', 'crc_error') + listed = {o['error_signature']: o['occurrence_count'] for o in self.store.get_disk_observations('sdb', 'SN-1')} + self.assertEqual(listed, {'read_error': 3, 'crc_error': 1}) + self.assertEqual(self.store.count_disk_observations('sdb', 'SN-1'), 2) + + def test_an_observation_cannot_be_dismissed(self): + for name in ('dismiss_disk_observation', 'cleanup_stale_observations', 'cleanup_orphan_observations'): + self.assertFalse(hasattr(self.store, name), name) + self.record('sdb', 'SN-1', 'read_error') + self.assertNotIn('dismissed', self.store.get_disk_observations('sdb', 'SN-1')[0]) + routes = (SCRIPTS / 'flask_health_routes.py').read_text() + (SCRIPTS / 'flask_server.py').read_text() + self.assertNotIn('cleanup_orphan_observations', routes) + self.assertNotIn('cleanup_stale_observations', routes) + panel = (SCRIPTS.parent / 'components/storage-overview.tsx').read_text() + self.assertNotIn('obs.dismissed', panel) + + def test_an_old_observation_is_never_hidden(self): + self.record('sdb', 'SN-1', 'read_error') + self.set_time('disk_observations', 'last_occurrence', '2020-01-01T00:00:00', '1 = 1', ()) + self.assertEqual(self.seen('sdb', 'SN-1'), ['read_error']) + self.assertEqual(self.store.count_disk_observations('sdb', 'SN-1'), 1) + + def test_what_earlier_versions_hid_is_in_the_history_again(self): + self.record('sdb', 'SN-1', 'read_error') + conn = self.store._get_conn() + conn.execute('UPDATE disk_observations SET dismissed = 1') + conn.execute("DELETE FROM user_settings WHERE setting_key = 'observation_history_version'") + conn.commit() + conn.close() + # Listed whatever the old mark says, and the mark is taken off. + self.assertEqual(self.seen('sdb', 'SN-1'), ['read_error']) + self.assertEqual(self.store.count_disk_observations('sdb', 'SN-1'), 1) + with patch('builtins.print'): + self.store._init_database() + conn = self.store._get_conn() + marked = conn.execute('SELECT COUNT(*) FROM disk_observations WHERE dismissed = 1').fetchone()[0] + conn.close() + self.assertEqual(marked, 0) + + +class AuditAttributionTests(unittest.TestCase): + def test_the_audit_lists_the_events_of_the_disk_that_is_there_now(self): + import types + import audit_inventory + asked = [] + + def getter(name=None, serial=None): + asked.append((name, serial)) + return [{'error_type': 'io_error', 'severity': 'warning', 'occurrence_count': 3, + 'first_occurrence': '2026-10-01T00:00:00', 'last_occurrence': '2026-10-02T00:00:00', + 'raw_message': 'crc'}] if serial == 'NEW-GOOD' else [] + server = types.SimpleNamespace(health_persistence=types.SimpleNamespace(get_disk_observations=getter)) + by_name = {'sdb': [{'type': 'io_error', 'count': 99, 'message': 'of the previous disk'}]} + with patch.dict(sys.modules, {'flask_server': server}): + entries = audit_inventory._observations_of('sdb', 'NEW-GOOD', by_name) + self.assertEqual(asked, [('sdb', 'NEW-GOOD')]) + self.assertEqual([(e['type'], e['count']) for e in entries], [('io_error', 3)]) + + +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'], [ diff --git a/AppImage/scripts/tests/test_pve_notifications_cfg_writes.py b/AppImage/scripts/tests/test_pve_notifications_cfg_writes.py new file mode 100644 index 00000000..b1ec8e35 --- /dev/null +++ b/AppImage/scripts/tests/test_pve_notifications_cfg_writes.py @@ -0,0 +1,170 @@ +"""Proxmox's notification files are only written while /etc/pve is the +mounted cluster filesystem, and only when their content has to change.""" +import ast +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +SCRIPTS = Path(__file__).resolve().parents[1] +SOURCE = SCRIPTS / 'flask_notification_routes.py' +NAMES = {'_pve_cluster_fs_mounted', '_pve_read_file', '_pve_backup_file', '_pve_prune_backups', '_pve_backup_prefix', + '_pve_backups', '_pve_collect_legacy_backups', 'restore_pve_notification_cfg', '_pve_remove_our_blocks', '_pve_webhook_url', + '_build_webhook_fallback', 'setup_pve_webhook_core', 'cleanup_pve_webhook_core', 'setup_pve_webhook_when_mounted'} +ASSIGNED = {'_PVE_ENDPOINT_ID', '_PVE_MATCHER_ID', '_PVE_OUR_HEADERS', '_PVE_NOT_MOUNTED', '_PVE_BACKUPS_KEPT', + '_PVE_HEADER_RE', 'WEBHOOK_LOOPBACK_PORT', '_PVE_BACKUP_MARK'} + + +class FakeManager: + def __init__(self): + self.secret = 'c2VjcmV0LXRva2Vu' + + def get_webhook_secret(self): + return self.secret + + def _save_setting(self, _key, value): + self.secret = value + + +def load(directory): + """The functions under test, pointed at a directory standing for /etc/pve.""" + body = [] + for node in ast.parse(SOURCE.read_text()).body: + if isinstance(node, ast.FunctionDef) and node.name in NAMES: + node.decorator_list = [] + body.append(node) + elif isinstance(node, ast.Assign) and any(getattr(t, 'id', None) in ASSIGNED for t in node.targets): + body.append(node) + elif isinstance(node, ast.Import) and any(a.asname == '_re_pve_cfg' for a in node.names): + body.append(node) + import time + scope = {'notification_manager': FakeManager(), 'time': time, 'jsonify': lambda value: value, + '_PVE_NOTIFICATIONS_CFG': str(directory / 'notifications.cfg'), + '_PVE_PRIV_CFG': str(directory / 'priv' / 'notifications.cfg'), + '_PVE_BACKUP_DIR': str(directory.parent / 'proxmenux' / 'backups' / 'pve-notifications')} + exec(compile(ast.fix_missing_locations(ast.Module(body=body, type_ignores=[])), str(SOURCE), 'exec'), scope) + return scope + + +class PveNotificationWriteTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.pve = Path(tmp.name) / 'pve' + self.pve.mkdir() + self.scope = load(self.pve) + self.kept = Path(self.scope['_PVE_BACKUP_DIR']) + self.mounted = True + patcher = mock.patch('os.path.ismount', side_effect=lambda path: self.mounted and path == str(self.pve)) + patcher.start() + self.addCleanup(patcher.stop) + + def files(self): + return sorted(str(p.relative_to(self.pve)) for p in self.pve.rglob('*') if p.is_file()) + + def test_nothing_is_written_while_the_cluster_filesystem_is_down(self): + # The bare directory of the root disk: empty, and it has to stay so. + self.mounted = False + result = self.scope['setup_pve_webhook_core']() + self.assertFalse(result['configured']) + self.assertTrue(result['waiting']) + self.assertEqual(self.scope['cleanup_pve_webhook_core']()['error'], self.scope['_PVE_NOT_MOUNTED']) + self.assertEqual(list(self.pve.iterdir()), []) + + def test_the_webhook_is_written_once_and_not_again(self): + (self.pve / 'priv').mkdir() + existing = 'sendmail: mail-to-root\n\tcomment Send mails to root@pam\n\tmailto-user root@pam\n' + (self.pve / 'notifications.cfg').write_text(existing) + (self.pve / 'priv' / 'notifications.cfg').write_text('') + self.assertTrue(self.scope['setup_pve_webhook_core']()['configured']) + written = (self.pve / 'notifications.cfg').read_text() + self.assertTrue(written.startswith(existing)) + self.assertIn('webhook: proxmenux-webhook\n', written) + self.assertIn('matcher: proxmenux-default\n', written) + first = {name: (self.pve / name).stat().st_mtime_ns for name in self.files()} + # The copies taken before the change are kept with ProxMenux, not in /etc/pve. + self.assertEqual(sorted(first), ['notifications.cfg', 'priv/notifications.cfg']) + copies = sorted(p.name.split('.proxmenux_backup_')[0] for p in self.kept.iterdir()) + self.assertEqual(copies, ['notifications.cfg', 'priv-notifications.cfg']) + self.assertEqual({oct(p.stat().st_mode & 0o777) for p in self.kept.iterdir()}, {'0o600'}) + self.assertEqual(oct(self.kept.stat().st_mode & 0o777), '0o700') + # A second start finds both files as wanted: no write, no new backup. + os.utime(self.pve / 'notifications.cfg', ns=(1, 1)) + os.utime(self.pve / 'priv' / 'notifications.cfg', ns=(1, 1)) + self.assertTrue(self.scope['setup_pve_webhook_core']()['configured']) + self.assertEqual(self.files(), sorted(first)) + self.assertEqual((self.pve / 'notifications.cfg').stat().st_mtime_ns, 1) + self.assertEqual((self.pve / 'priv' / 'notifications.cfg').stat().st_mtime_ns, 1) + + def test_copies_left_in_etc_pve_by_an_earlier_version_are_collected_without_a_write(self): + (self.pve / 'priv').mkdir() + (self.pve / 'priv' / 'notifications.cfg').write_text('') + (self.pve / 'notifications.cfg').write_text('') + self.scope['setup_pve_webhook_core']() + for item in self.kept.iterdir(): + item.unlink() + for index in range(40): + for folder in (self.pve, self.pve / 'priv'): + stale = folder / f'notifications.cfg.proxmenux_backup_20260101_{index:06d}' + stale.write_text(f'old {index}') + os.utime(stale, ns=(index * 10 ** 9, index * 10 ** 9)) + current = (self.pve / 'notifications.cfg').read_text() + self.assertTrue(self.scope['setup_pve_webhook_core']()['configured']) + self.assertEqual(self.files(), ['notifications.cfg', 'priv/notifications.cfg']) + self.assertEqual((self.pve / 'notifications.cfg').read_text(), current) + kept = sorted(p.name for p in self.kept.iterdir()) + self.assertEqual(kept, [f'notifications.cfg.proxmenux_backup_20260101_{i:06d}' for i in (37, 38, 39)] + + [f'priv-notifications.cfg.proxmenux_backup_20260101_{i:06d}' for i in (37, 38, 39)]) + self.assertEqual((self.kept / kept[2]).read_text(), 'old 39') + + def test_restore_takes_the_newest_copy_of_each_file(self): + (self.pve / 'priv').mkdir() + (self.pve / 'notifications.cfg').write_text('before\n') + (self.pve / 'priv' / 'notifications.cfg').write_text('private before\n') + self.scope['setup_pve_webhook_core']() + answer, _status = self.scope['restore_pve_notification_cfg']() + self.assertTrue(answer['success']) + self.assertEqual((self.pve / 'notifications.cfg').read_text(), 'before\n') + self.assertEqual((self.pve / 'priv' / 'notifications.cfg').read_text(), 'private before\n') + self.mounted = False + answer, _status = self.scope['restore_pve_notification_cfg']() + self.assertFalse(answer['success']) + + def test_uninstalling_the_monitor_removes_its_target_from_proxmox(self): + menu = (SCRIPTS.parents[1] / 'scripts/menus/config_menu.sh').read_text() + body = menu[menu.index('remove_monitor_notification_target() {'):menu.index('uninstall_proxmenux_monitor() {')] + self.assertIn('mountpoint -q /etc/pve || return 0', body) + # The rule first: Proxmox does not delete a target that a rule still uses. + self.assertLess(body.index('matchers/proxmenux-default'), body.index('endpoints/webhook/proxmenux-webhook')) + self.assertIn('/etc/pve/priv/notifications.cfg.proxmenux_backup_*', body) + uninstall = menu[menu.index('uninstall_proxmenux_monitor() {'):menu.index('check_monitor_status() {')] + self.assertIn('remove_monitor_notification_target', uninstall) + + def test_a_start_during_a_restart_waits_for_the_mount(self): + self.mounted = False + (self.pve / 'priv').mkdir() + + def back(_seconds): + self.mounted = True + with mock.patch('time.sleep', side_effect=back): + result = self.scope['setup_pve_webhook_when_mounted'](timeout=60, interval=1) + self.assertTrue(result['configured']) + self.assertIn('webhook: proxmenux-webhook', (self.pve / 'notifications.cfg').read_text()) + + def test_the_wait_ends_without_writing(self): + self.mounted = False + result = self.scope['setup_pve_webhook_when_mounted'](timeout=0, interval=0) + self.assertTrue(result['waiting']) + self.assertEqual(list(self.pve.iterdir()), []) + + def test_the_monitor_starts_after_the_cluster_service(self): + root = SCRIPTS.parents[1] + for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh', 'scripts/menus/config_menu.sh'): + text = (root / name).read_text() + self.assertIn('After=network.target pve-cluster.service', text, name) + self.assertNotIn('After=network.target\n\n[Service]\nType=simple\nUser=root\nWorkingDirectory=$MONITOR', text, name) + + +if __name__ == '__main__': + unittest.main() diff --git a/install_proxmenux.sh b/install_proxmenux.sh index 883b06ca..e2aee65a 100755 --- a/install_proxmenux.sh +++ b/install_proxmenux.sh @@ -623,7 +623,7 @@ _proxmenux_rewrite_monitor_unit_for_apprun() { cat > "$MONITOR_SERVICE_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard -After=network.target +After=network.target pve-cluster.service [Service] Type=simple @@ -655,7 +655,7 @@ create_monitor_service() { cat > "$MONITOR_SERVICE_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard -After=network.target +After=network.target pve-cluster.service [Service] Type=simple diff --git a/install_proxmenux_beta.sh b/install_proxmenux_beta.sh index 3171de4e..90022cd2 100644 --- a/install_proxmenux_beta.sh +++ b/install_proxmenux_beta.sh @@ -496,7 +496,7 @@ update_monitor_service() { cat > "$MONITOR_SERVICE_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard (Beta) -After=network.target +After=network.target pve-cluster.service Before=shutdown.target reboot.target halt.target Conflicts=shutdown.target reboot.target halt.target @@ -535,7 +535,7 @@ create_monitor_service() { cat > "$MONITOR_SERVICE_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard (Beta) -After=network.target +After=network.target pve-cluster.service Before=shutdown.target reboot.target halt.target Conflicts=shutdown.target reboot.target halt.target diff --git a/lang/es.json b/lang/es.json index 37b1afff..2ddd41da 100644 --- a/lang/es.json +++ b/lang/es.json @@ -390,7 +390,7 @@ "Application": "Aplicación", "Application Specific": "Específico de la aplicación", "Application registered on this host:": "Aplicación registrada en este host:", - "Application responding:": "Solicitud de respuesta:", + "Application responding:": "Aplicación respondiendo:", "Application responding; checking its stability...": "Aplicación respondiendo; comprobando su estabilidad...", "Application suite: one independent LXC per selected application": "Suite de aplicación: un LXC independiente por aplicación seleccionada", "Application:": "Aplicación:", @@ -570,7 +570,7 @@ "Backup kept in": "Backup conservado en", "Backup location": "Ubicación del backup", "Backup metadata": "Metadatos de backup", - "Backup of the previous installation verified": "Backup de la instalación anterior verificada", + "Backup of the previous installation verified": "Backup de la instalación anterior verificado", "Backup on newer kernel:": "Backup de un kernel más nuevo:", "Backup on older kernel:": "Backup de un kernel anterior:", "Backup origin metadata:": "Metadatos de origen del backup:", @@ -760,6 +760,7 @@ "Changes prepared": "Cambios preparados", "Changes will apply after reboot.": "Los cambios se aplicarán después del reinicio.", "Changing Release Channel": "Cambio de canal de release", + "Changing between a privileged and an unprivileged container, or the host monitor mode, requires installing the application again": "Cambiar entre contenedor privilegiado y sin privilegios, o el modo de monitor del host, requiere instalar la aplicación de nuevo", "Changing the machine type on an existing installed VM is not safe: it changes the chipset and PCI slot layout, which typically prevents the guest OS from booting.": "Cambiar el tipo de máquina de una VM ya instalada no es seguro: cambia el chipset y la disposición de las ranuras PCI, lo que normalmente impide que arranque el sistema operativo de la VM.", "Changing the rootfs or its storage requires a separate migration": "Cambiar los rootfs o su almacenamiento requiere una migración separada", "Changing the storage or size of a disk requires a migration; empty disks are not created": "Cambiar el almacenamiento o el tamaño de un disco requiere una migración; los discos vacíos no se crean", @@ -2356,6 +2357,7 @@ "FileBrowser Quantum (new installation)": "FileBrowser Quantum (nueva instalación)", "FileDrop is a free, open source file sharing service": "FileDrop es un servicio gratuito y de código abierto para compartir archivos", "Files & Downloads": "Archivos y descargas", + "Files & Sharing": "Files & Sharing", "Files volume size in GB": "Tamaño del volumen de archivos en GB", "Filesystem": "Sistema de archivos", "Filesystem Tools Required": "Herramientas del sistema de archivos necesarias", @@ -2434,6 +2436,7 @@ "Free and easy to use Minecraft server management tool.": "Herramienta gratuita y fácil de usar para gestionar servidores de Minecraft.", "Free public Proxmox repository enabled": "Repositorio público gratuito de Proxmox habilitado", "Free space OK:": "Espacio libre Aceptar:", + "Free space on the host, or choose another storage for this backup from Manage installed OCI applications.": "Libera espacio en el host, o elige otro almacenamiento para este backup desde Gestionar aplicaciones OCI instaladas.", "Free up disk space": "Liberar espacio en disco", "Free:": "Libre:", "FreeCAD is a general-purpose parametric 3D computer-aided design (CAD) modeler and a building information modeling (BIM) software application with finite element method (FEM) support.": "FreeCAD es un modelador paramétrico 3D de diseño asistido por ordenador (CAD) de uso general y una aplicación de modelado de información de construcción (BIM) con soporte para el método de elementos finitos (FEM).", @@ -3120,7 +3123,7 @@ "Internal error: missing arguments in pmx_prepare_host_shared_dir": "Error interno: faltan argumentos en pmx_prepare_host_shared_dir", "Internal subnet of the tunnel (change it only if it clashes)": "Subred interna del túnel (cámbiala solo si entra en conflicto)", "Interrupted operation": "Operación interrumpida", - "Interrupted operation:": "Interrupted operación:", + "Interrupted operation:": "Operación interrumpida:", "Interrupted stack operation found": "Se ha encontrado una operación de pila interrumpida", "Invalid 'proxmox-ve' candidate (not 9.x or none). Please verify your repository configuration and network, then retry.": "Candidato 'proxmox-ve' no válido (ni 9.x ni ninguno). Verifique la configuración y la red de su repositorio y luego vuelva a intentarlo.", "Invalid ALLOWED_HOSTS value": "Valor inválido ALLOWED_HOSTS", @@ -3262,6 +3265,7 @@ "It asks for a system directory of the host:": "Pide un directorio del sistema del host:", "It asks for capabilities or a relaxed confinement profile.": "Pide capacidades o un perfil de confinamiento relajado.", "It asks to see the processes of the host.": "Pide ver los procesos del host.", + "It can be made on another storage of this host. This backup is temporary: it is deleted when the operation ends. The choice is remembered for the next operations of this application.": "Se puede hacer en otro almacenamiento de este host. Este backup es temporal: se borra cuando termina la operación. La elección se recuerda para las siguientes operaciones de esta aplicación.", "It cannot be removed on its own, because the application would stop working: continuing removes the whole application.": "No se puede eliminar por sí solo, porque la aplicación dejaría de funcionar: continuar elimina toda la aplicación.", "It cannot be removed on its own, because the application would stop working: continuing targets the whole application for removal.": "No se puede eliminar por separado, porque la aplicación dejaría de funcionar: si continúas, se eliminará toda la aplicación.", "It is created empty; existing data is not migrated automatically.": "Se crea vacía; los datos existentes no se migran automáticamente.", @@ -3856,6 +3860,7 @@ "Name or part of the description of the application": "Nombre o parte de la descripción de la aplicación", "Name:": "Nombre:", "Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Las cuentas con nombre necesitan el modo privado. Detén el contenedor, pon public: false en /config/config.js, vuelve a arrancarlo y crea cada usuario con: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ", + "Needed:": "Necesario:", "Neither /etc/kernel/cmdline nor /etc/default/grub found.": "No se encontraron /etc/kernel/cmdline ni /etc/default/grub.", "Nesting feature enabled": "Función de anidamiento habilitada", "NetBIOS Service: RUNNING": "Servicio NetBIOS: EN EJECUCIÓN", @@ -4141,6 +4146,7 @@ "No new Controller/NVMe entries were added.": "No se agregaron nuevas entradas de Controlador/NVMe.", "No online node has a guest of this job.": "Ningún nodo en línea tiene una VM o CT de este trabajo.", "No optimizations detected to uninstall.": "No se detectaron optimizaciones para desinstalar.", + "No other storage of this host that accepts backups has that much free space. Free space and try again; the application was not modified.": "Ningún otro almacenamiento de este host que acepte backups tiene tanto espacio libre. Libera espacio e inténtalo de nuevo; la aplicación no se ha modificado.", "No partitions found on disk. Nothing was wiped.": "No se encontraron particiones en el disco. No se borró nada.", "No password provided.": "No se proporcionó contraseña.", "No paths available": "No hay rutas disponibles", @@ -4220,7 +4226,6 @@ "Not an OVH server, skipping RTM installation": "No es un servidor OVH, se salta la instalación de RTM", "Not currently mounted": "Actualmente no montado", "Not currently mounted — skipping umount.": "Actualmente no montado: omitiendo el desmontaje.", - "Not enough free space for the backup": "No suficiente espacio libre para el backup", "Not found": "No encontrado", "Not found in the OCI archive:": "No se encuentra en el archivo OCI:", "Not imported:": "No importado:", @@ -5123,7 +5128,7 @@ "Restore source location": "Ubicación del origen de la restauración", "Restored OCI applications": "Aplicaciones OCI restauradas", "Restored config is on disk; reboot the host to apply.": "La configuración restaurada está en el disco; reinicie el host para aplicar.", - "Restored installation checked": "Instalación restaurada", + "Restored installation checked": "Instalación restaurada revisada", "Restored original /bin/gzip": "Restaurado original /bin/gzip", "Restored original /etc/vzdump.conf from .bak": "Restaurado /etc/vzdump.conf original desde .bak", "Restored:": "Restaurado:", @@ -5138,7 +5143,7 @@ "Restoring the backup": "Restaurar el backup", "Restoring the original rpcbind service state...": "Restaurando el estado original del servicio rpcbind...", "Restoring the previous Rclone configuration...": "Restaurar la configuración anterior de Rclone...", - "Restoring the previous backup...": "Restaurar el backup anterior...", + "Restoring the previous backup...": "Restaurando el backup anterior...", "Restoring the previous state of the stack...": "Restaurando el estado anterior de la pila...", "Restoring the stack records...": "Restaurando los registros de la pila...", "Results will be saved automatically to:": "Los resultados se guardarán automáticamente en:", @@ -5849,7 +5854,7 @@ "Stop uploading?": "¿Dejar de subir?", "Stopped": "Detenido", "Stopped and disabled": "Detenido y deshabilitado", - "Stopped at:": "Detuvo:", + "Stopped at:": "Se detuvo en:", "Stopping Coral kernel modules...": "Deteniendo los módulos del kernel de Coral...", "Stopping LXC": "Detener LXC", "Stopping NFS services...": "Deteniendo los servicios NFS...", @@ -5888,6 +5893,7 @@ "Storage for the Immich library": "Almacenamiento para la biblioteca Immich", "Storage for the Nextcloud data": "Almacenamiento para los datos Nextcloud", "Storage for the OCI image cache": "Almacenamiento para la caché de imágenes OCI", + "Storage for the backup of this operation": "Almacenamiento para el backup de esta operación", "Storage for the consume and export folders": "Almacenamiento para las carpetas de consumo y exportación", "Storage for the container and its data": "Almacenamiento para el contenedor y sus datos", "Storage for the containers and their data": "Almacenamiento para los contenedores y sus datos", @@ -6112,14 +6118,17 @@ "The application has been modified with the new options.": "La aplicación se ha modificado con las nuevas opciones.", "The application needs it for": "La aplicación lo necesita para", "The application stopped during its first start:": "La aplicación se detuvo durante su primer comienzo:", + "The application was not modified.": "La aplicación no se ha modificado.", "The application was removed": "La aplicación se ha eliminado", "The archive could not be extracted.": "No se pudo extraer el archivo.", "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "El directorio de destino del archivo está DENTRO de una de las rutas de las que va a hacer backup. Escribir el archivo allí copiaría el backup dentro de sí mismo, lo que produciría un archivo dañado o que crecería sin límite hasta llenar el disco.", "The backup could not be identified; the image is not replaced": "El backup no se pudo identificar; la imagen no es reemplazada", "The backup could not be started on node": "No se pudo iniciar el backup en el nodo", "The backup did not pass its integrity check; creating it again...": "El backup no superó la comprobación de integridad; creándolo de nuevo...", + "The backup made before changing the application does not fit on the system disk of the host.": "El backup que se hace antes de modificar la aplicación no cabe en el disco del sistema del host.", "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "Los metadatos del backup se compararon con este host. Los siguientes elementos se OMITIRÁN para mantener el arranque seguro:", "The backup of a member could not be identified": "No se pudo identificar el backup de un miembro", + "The backup of this operation cannot be read. If it is on another storage, make it available and recover the operation again:": "No se puede leer el backup de esta operación. Si está en otro almacenamiento, haz que esté disponible y recupera la operación de nuevo:", "The backup runs as a Proxmox task; follow it in the task log of Proxmox.": "El backup se ejecuta como tarea de Proxmox; sígalo en el registro de tareas de Proxmox.", "The backup storage does not exist:": "El almacenamiento de backups no existe:", "The backup was altered; recovery blocked": "El backup fue alterado; la recuperación bloqueada", @@ -6328,6 +6337,7 @@ "The new image changes the PostgreSQL major version; the data must be migrated before updating": "La nueva imagen cambia la versión principal de PostgreSQL; los datos deben ser migrados antes de actualizar", "The new image could not be installed": "La nueva imagen no se puede instalar", "The new image could not be installed:": "La nueva imagen no se puede instalar:", + "The new image does not fit on the storage where images are downloaded:": "La imagen nueva no cabe en el almacenamiento donde se descargan las imágenes:", "The new image does not keep a required executable": "La nueva imagen no mantiene un ejecutable necesario", "The new image is more recent than the minimum age set for scheduled updates; it is not installed yet.": "La imagen nueva es más reciente que la antigüedad mínima fijada para las actualizaciones programadas; todavía no se instala.", "The new image requires additional persistent paths": "La nueva imagen requiere caminos persistentes adicionales", @@ -6499,6 +6509,8 @@ "The startup differs from the declared Nextcloud adapter": "La startup difiere del adaptador Nextcloud declarado", "The startup differs from the declared adapter": "La startup difiere del adaptador declarado", "The staticfiles volume needs at least 1 GB": "El volumen de los ficheros estáticos necesita al menos 1 GB", + "The storage chosen for this backup cannot be written:": "No se puede escribir en el almacenamiento elegido para este backup:", + "The storage chosen for this backup has no room either:": "El almacenamiento elegido para este backup tampoco tiene espacio:", "The storage does not accept backups:": "El almacenamiento no admite backups:", "The storage has been removed and the disk unmounted.": "Se eliminó el almacenamiento y se desmontó el disco.", "The storage has less than 40 GB free for the ROCm image.": "El almacenamiento tiene menos de 40 GB libres para la imagen de ROCm.", @@ -6557,6 +6569,7 @@ "This application is under watchdog: it is restarted automatically when it crashes. Turn the watchdog off?": "Esta aplicación está bajo vigilancia: se reinicia automáticamente cuando falla. ¿Desactivar la vigilancia?", "This archive does not contain a recognized backup layout.": "Este archivo no contiene un diseño de backup reconocido.", "This backup does not contain any restorable paths.": "Este backup no contiene rutas restaurables.", + "This backup does not fit on the system disk of the host; it is made on the storage:": "Este backup no cabe en el disco del sistema del host; se hace en el almacenamiento:", "This backup includes /etc/zfs/zpool.cache (host-specific ZFS state).": "Este backup incluye /etc/zfs/zpool.cache (estado ZFS específico del host).", "This backup is encrypted.": "Este backup está cifrado.", "This backup was taken on kernel": "Este backup se realizó en el kernel", @@ -6912,7 +6925,7 @@ "Updated:": "Actualizado:", "Updates all Proxmox and Debian packages": "Actualiza todos los paquetes de Proxmox y Debian", "Updates and Packages Commands": "Comandos de actualizaciones y paquetes", - "Updates are not available yet for this application in this beta": "Aún no hay actualizaciones para esta aplicación en este beta", + "Updates are not available yet for this application in this beta": "Aún no hay actualizaciones para esta aplicación en esta beta", "Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Aún no se dispone de actualizaciones en esta beta para aplicaciones que utilizan un contenedor privilegiado o configuración avanzada LXC", "Updates file is empty or unreadable.": "El archivo de actualizaciones está vacío o es ilegible.", "Updating": "Actualizando", @@ -7185,7 +7198,7 @@ "Wait for each node to complete before starting next": "Espere a que se complete cada nodo antes de comenzar el siguiente", "Waiting for Home Assistant OS...": "Esperando a Home Assistant OS...", "Waiting for the FUSE mount:": "Esperando el montaje FUSE:", - "Waiting for the application to respond...": "Esperando que la aplicación responda...", + "Waiting for the application to respond...": "Esperando a que la aplicación responda...", "Waiting for the initial Jellyfin configuration...": "Esperando la configuración inicial de Jellyfin...", "Waiting for the network address...": "Esperando la dirección de la red...", "Waiting for the password of the application...": "Esperando la contraseña de la aplicación...", diff --git a/oci/README.md b/oci/README.md index 94db720e..ba6455e0 100644 --- a/oci/README.md +++ b/oci/README.md @@ -408,27 +408,37 @@ compatibility need separate review, not silent removal of these requirements. ### HAOS One native OCI profile -The community image `qweritos/haos-one:latest` is installable with the laboratory -adaptation: unprivileged LXC, `ostype=unmanaged`, `nesting=1`, `keyctl=1`, and a -managed `/mnt/data` volume included in Proxmox backups (32 GB default, 16 GB -minimum). The image's entrypoint, command and stop signal remain imported from -OCI metadata. No Docker daemon or compatibility proxy is installed by ProxMenux; -the nested runtime belongs to the image itself. +The community image `ghcr.io/sevenrats/haos:latest`, derived from HAOS One, is +installable with the laboratory adaptation: unprivileged LXC, `ostype=unmanaged`, +`nesting=1`, `keyctl=1`, and a managed `/mnt/data` volume included in Proxmox +backups (32 GB default, 16 GB minimum). The image's entrypoint, command and stop +signal remain imported from OCI metadata. No Docker daemon or compatibility proxy +is installed by ProxMenux; the nested runtime belongs to the image itself. The +profile is offered unprivileged only. The installer asks for explicit acknowledgement of the experimental profile and its inner AppArmor limitations. First boot may pull several images. A bounded 20-minute check reports progress and requires healthy/supported Supervisor, the real Core container rather than the landing page, running CLI/DNS/audio/multicast/ -Observer containers, and working Core and Observer HTTP endpoints. The final -Core URL is detected on port 80 or 8123 instead of assumed. A first-boot failure -preserves the LXC, data and root-only console log for diagnosis; it never reports -success. If starting is declined, no unverified URL is printed. +Observer containers, and working Core and Observer HTTP endpoints. The recipe sets +`SETUP_PORT=8123`, so Core answers on port 8123 during onboarding and afterwards; +the check probes 8123 first and then 80. A first-boot failure preserves the LXC, +data and root-only console log for diagnosis; it never reports success. If +starting is declined, no unverified URL is printed. -This installer path and the rolling latest image remain runtime-unvalidated. -The historical lab evidence stays in the template; it is not a guarantee for -new releases. Full backup restore, outer-image replacement, USB and multicast -discovery still need explicit tests. See the upstream project: -https://github.com/qweritos/haos-one +The upstream Compose file asks Docker to restart the container, so the watchdog +is proposed by default: a shutdown asked from Home Assistant itself is followed +by a new start, as it is under Docker. A reboot asked from Home Assistant is +handled by Proxmox, and the DHCP address of the container is given back by +`remote/oci_dhcp_lease.py`. + +Update and Modify work as for any other application: the installer runs the +same check on the rebuilt container, and a recovery restores the backup and +waits for that check too. Rebuilding with the data kept and recovering from the +backup are tested; the update to a newer image waits for the first one the +project publishes. Home Assistant Core, Supervisor and add-ons update themselves +from Home Assistant. See the upstream project: +https://github.com/sevenrats/docker-haos ### Native Compose resource limits diff --git a/oci/VALIDATION.md b/oci/VALIDATION.md index a77c4d37..5c2b7874 100644 --- a/oci/VALIDATION.md +++ b/oci/VALIDATION.md @@ -2,7 +2,7 @@ -42 of 335 applications in the OCI catalog have been tested for real: 40 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists. +43 of 336 applications in the OCI catalog have been tested for real: 41 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists. Each test describes the scenario that was run and names the image it ran on. It does not cover every possible configuration of the application, and a newer image has not been tested until someone reports it. @@ -65,6 +65,7 @@ Questions and ideas about OCI testing go in [discussion #360](https://github.com | Qbittorrent | Files & Downloads | | Rclone WebUI | Backup & Recovery | | Real-Debrid Torrent Client | Files & Downloads | +| ShareCoveX | Files & Sharing | | SnapOtter | Media & Streaming | | Tandoor Recipes | Productivity & Workflows | | Thelounge | Communication & Community | diff --git a/oci/catalog/apps/haos-one.json b/oci/catalog/apps/haos-one.json index 84ef3475..1f8c48f3 100644 --- a/oci/catalog/apps/haos-one.json +++ b/oci/catalog/apps/haos-one.json @@ -11,12 +11,12 @@ "en_US": "Community single-container Home Assistant OS image" }, "description": { - "en_US": "Community HAOS One image adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data." + "en_US": "Community Home Assistant OS image, derived from HAOS One, adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data." }, "category": "smarthome", "category_label": "IoT & Smart Home", - "author": "qweritos", - "developer": "qweritos", + "author": "sevenrats", + "developer": "sevenrats", "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp", "thumbnail": null, "screenshots": [], @@ -26,42 +26,42 @@ ], "launch": { "scheme": "http", - "port": 80, + "port": 8123, "path": "/" }, - "website": "https://github.com/qweritos/haos-one", - "documentation": "https://github.com/qweritos/haos-one#readme", - "repository": "https://github.com/qweritos/haos-one", + "website": "https://github.com/sevenrats/docker-haos", + "documentation": "https://github.com/sevenrats/docker-haos#readme", + "repository": "https://github.com/sevenrats/docker-haos", "tips": [ "This is a third-party community image and is not affiliated with Home Assistant.", "The validated profile uses a managed /mnt/data volume; preserve it during every image replacement.", - "Port 80 is used by current Core releases in the validated profile; port 8123 may appear during setup or on older releases.", + "Home Assistant answers on port 8123, during the first setup and afterwards.", "Experimental unprivileged profile with nesting and keyctl. Internal AppArmor profiles may not be available.", "The first start downloads internal images. Success is confirmed only with Supervisor healthy/supported and a real Core, internal services and Observer running.", - "The web port is detected between 80 and 8123. If the check fails, the CT, data and log are kept for diagnosis; success is not confirmed.", - "/mnt/data: Proxmox volume of 32 GB by default, minimum 16 GB, included in backup. Full restore and external image update pending testing." + "If the check of the first start fails, the CT, data and log are kept for diagnosis; success is not confirmed.", + "/mnt/data: Proxmox volume of 32 GB by default, minimum 16 GB, included in backup and kept when the application is updated or modified." ], "mini_changelog": [], "display_version": null, - "updated_at": "2026-08-20" + "updated_at": "2026-09-23" }, "source": { - "provider": "qweritos", - "repository": "https://github.com/qweritos/haos-one", - "revision": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede", - "image_repository_url": "https://hub.docker.com/r/qweritos/haos-one", - "readme_pushed_at": "2026-08-20T12:29:50Z", - "compose_sha256": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede", - "generated_at": "2026-09-12T15:54:10+00:00", + "provider": "sevenrats", + "repository": "https://github.com/sevenrats/docker-haos", + "revision": "e7fe7f6728b84763b76658ab5a9669f50cb029b0", + "image_repository_url": "https://github.com/sevenrats/docker-haos/pkgs/container/haos", + "readme_pushed_at": "2026-09-23T15:30:10Z", + "compose_sha256": "4301a2454ca1a5ff58d596dc1f2f99eba52ab77a307f394d81e6d5f66f824111", + "generated_at": "2026-10-07T00:00:00+00:00", "default_branch": "master" }, "container_contract": { "service_name": "haos-one", "container_name": "haos-one", "image": { - "reference": "qweritos/haos-one:latest", - "registry": "docker.io", - "repository": "qweritos/haos-one", + "reference": "ghcr.io/sevenrats/haos:latest", + "registry": "ghcr.io", + "repository": "sevenrats/haos", "tag": "latest", "digest": null, "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install" @@ -72,7 +72,7 @@ "example": "1", "required": true, "sensitive": false, - "source": "docker-compose", + "source": "image-default", "prompt_user": false }, { @@ -80,16 +80,31 @@ "example": "auto", "required": true, "sensitive": false, - "source": "docker-compose", + "source": "image-default", "prompt_user": false }, { - "name": "DEV", - "example": "0", + "name": "SETUP_PORT", + "example": "8123", "required": true, "sensitive": false, - "source": "docker-compose", + "source": "image-default", "prompt_user": false + }, + { + "name": "ENABLE_IPV6", + "example": "1", + "required": true, + "sensitive": false, + "source": "image-default", + "prompt_user": false + }, + { + "name": "TZ", + "example": "Europe/Madrid", + "required": true, + "sensitive": false, + "source": "upstream-compose-environment" } ], "volumes": [ @@ -111,8 +126,8 @@ ], "ports": [ { - "container_port": 80, - "published_example": 80, + "container_port": 8123, + "published_example": 8123, "protocol": "tcp", "required": true, "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" @@ -126,9 +141,9 @@ } ], "related_services": [], - "restart": null, - "stop_grace_period": null, - "original_compose": "name: haos-one\nservices:\n haos-one:\n image: qweritos/haos-one:latest\n privileged: true\n environment:\n USE_DUMMY_NETWORKMANAGER: '1'\n USE_UDEV_SHIM: auto\n DEV: '0'\n ports:\n - 80:80\n - 4357:4357\n volumes:\n - haos-data:/mnt/data\n stop_signal: SIGRTMIN+3\nvolumes:\n haos-data: {}\n" + "restart": "unless-stopped", + "stop_grace_period": "2m", + "original_compose": "name: haos-one\nservices:\n haos-one:\n image: ghcr.io/sevenrats/haos:latest\n privileged: true\n restart: unless-stopped\n stop_grace_period: 2m\n environment:\n USE_DUMMY_NETWORKMANAGER: '1'\n USE_UDEV_SHIM: auto\n SETUP_PORT: '8123'\n ENABLE_IPV6: '1'\n TZ: Europe/Madrid\n ports:\n - 8123:8123\n - 4357:4357\n volumes:\n - haos-data:/mnt/data\n stop_signal: SIGRTMIN+3\nvolumes:\n haos-data: {}\n" }, "compose_stack": { "project_name": "haos-one", @@ -139,7 +154,7 @@ "services": [ { "name": "haos-one", - "image": "qweritos/haos-one:latest", + "image": "ghcr.io/sevenrats/haos:latest", "is_main": true, "role": "frontend", "vmid_offset": 0, @@ -147,15 +162,19 @@ "frontend_network": true, "private_network": false, "compose": { - "image": "qweritos/haos-one:latest", + "image": "ghcr.io/sevenrats/haos:latest", "privileged": true, + "restart": "unless-stopped", + "stop_grace_period": "2m", "environment": { "USE_DUMMY_NETWORKMANAGER": "1", "USE_UDEV_SHIM": "auto", - "DEV": "0" + "SETUP_PORT": "8123", + "ENABLE_IPV6": "1", + "TZ": "Europe/Madrid" }, "ports": [ - "80:80", + "8123:8123", "4357:4357" ], "volumes": [ @@ -230,9 +249,9 @@ { "label": "Home Assistant", "scheme": "http", - "port": 80, + "port": 8123, "path": "/", - "source": "validated-current-core-profile" + "source": "image-setup-port" }, { "label": "Home Assistant Observer", @@ -269,7 +288,7 @@ "nesting=1", "keyctl=1" ], - "shutdown_timeout_seconds": 60 + "shutdown_timeout_seconds": 120 }, "image_metadata_policy": { "entrypoint": "import-from-oci-image", @@ -290,7 +309,7 @@ }, { "id": "nested-runtime-features", - "upstream_behavior": "haos-one starts systemd, Docker, Supervisor and nested Home Assistant containers.", + "upstream_behavior": "The image starts systemd, Docker, Supervisor and nested Home Assistant containers.", "native_lxc_behavior": "Use an unprivileged LXC with nesting=1 and keyctl=1.", "reason": "The inner Docker and containerd runtime require nested namespaces and keyring support.", "behavioral_impact": "The LXC remains unprivileged; no AppArmor unconfined override was required.", @@ -310,13 +329,21 @@ "native_lxc_behavior": "Use an upstream image digest that contains rewrite_create_request_payload; do not patch files locally.", "reason": "Older builds fail to start Core and plug-ins with kernel.domainname permission denied.", "behavioral_impact": "Uses the project-provided compatibility behavior unchanged.", - "validation": "passed-source-revision-5bffb27-and-runtime" + "validation": "passed-source-revision-e7fe7f6-and-runtime" + }, + { + "id": "shutdown-grace", + "upstream_behavior": "The Compose file gives systemd two minutes to stop Home Assistant and its Docker daemon.", + "native_lxc_behavior": "Shut the LXC down with a timeout of 120 seconds.", + "reason": "A shorter wait ends the nested containers before they have written their state.", + "behavioral_impact": "No application behavior is changed.", + "validation": "passed-clean-shutdown-and-start" } ], "laboratory_contract": { "requirements": { "proxmox_min_version": "9.2", - "validated_proxmox_version": "9.2.11", + "validated_proxmox_version": "9.2.20", "commands": [ "pct", "pvesm", @@ -358,7 +385,9 @@ "environment_defaults": { "USE_DUMMY_NETWORKMANAGER": "1", "USE_UDEV_SHIM": "auto", - "SETUP_PORT": null, + "SETUP_PORT": "8123", + "ENABLE_IPV6": "1", + "TZ": "UTC", "DEV": "0" }, "preserve_without_override": [ @@ -428,7 +457,7 @@ "type": "device-list", "required": false, "default": [], - "description": "Optional passthrough of Zigbee, Z-Wave, Bluetooth or other coordinators; not validated in this profile." + "description": "Optional passthrough of Zigbee, Z-Wave, Bluetooth or other coordinators." } }, "deployment": { @@ -457,9 +486,9 @@ ], "ports": [ { - "port": 80, + "port": 8123, "protocol": "tcp", - "purpose": "validated-home-assistant-web-for-core-2026.9.1" + "purpose": "home-assistant-web-fixed-by-SETUP_PORT" }, { "port": 4357, @@ -469,10 +498,10 @@ ], "conditional_ports": [ { - "port": 8123, + "port": 80, "protocol": "tcp", - "condition": "older-core-or-landing-page-stage-or-explicit-SETUP_PORT", - "warning": "Do not assume port 8123 remains active after onboarding on Home Assistant Core 2026.8 or newer." + "condition": "SETUP_PORT-emptied-on-core-2026.8-or-newer", + "warning": "The profile sets SETUP_PORT=8123. Port 80 is only used when that variable is emptied." } ] }, @@ -495,16 +524,16 @@ "data_role": "persistent-user-and-supervisor-state", "restart_test": { "status": "passed", - "method": "write-marker-shutdown-start-verify-hash-and-services", - "marker_sha256": "6c3762cede4f86dd5eeae16eff1067e188e9b47646f48ccc268c077711bffbd0", - "volume_before": "local-lvm:vm-128-disk-1", - "volume_after": "local-lvm:vm-128-disk-1", - "home_assistant_after_restart": "passed-http-200", + "method": "write-marker-reboot-shutdown-start-verify-hash-and-services", + "marker_sha256": "ddcc5557ff607ffc6ef0cdc54484f213c28f65b5ebde1c02cd8eefd2fa6b7c5e", + "volume_before": "local-lvm:vm-121-disk-1", + "volume_after": "local-lvm:vm-121-disk-1", + "home_assistant_after_restart": "passed-http-port-8123", "inner_containers_after_restart": "passed-all-running" }, "native_backup": { "expected": "included-by-mp0-backup-1", - "full-vzdump-restore-test": "pending" + "full-vzdump-restore-test": "passed" } }, "installation_steps": [ @@ -516,7 +545,7 @@ "Attach a host-managed network interface to the selected bridge.", "Preserve the imported /entrypoint.sh /sbin/init command and SIGRTMIN+3 stop signal.", "Start the LXC and allow several minutes for the first pull of Supervisor, Core and plug-ins.", - "Discover the assigned address and probe both port 80 and port 8123.", + "Discover the assigned address and probe port 8123, then port 80.", "Require Supervisor healthy=true and supported=true before reporting success.", "Verify Core, CLI, DNS, audio, multicast and observer containers are running.", "Report non-blocking HAOS resolution issues separately." @@ -526,8 +555,8 @@ "name": "home-assistant-web", "type": "http", "candidate_urls": [ - "http://${container_ip}/", - "http://${container_ip}:8123/" + "http://${container_ip}:8123/", + "http://${container_ip}/" ], "expected_status": 200, "startup_grace_seconds": 300 @@ -551,14 +580,14 @@ "update_strategy": { "core_supervisor_and_addons": "managed-by-home-assistant-supervisor", "outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data", - "outer_oci_update_validation": "pending", + "outer_oci_update_validation": "rebuild-and-recovery-passed-newer-image-pending", "required_before_update": [ "create-and-download-a-full-home-assistant-backup", "create-a-native-proxmox-backup-including-mp0", "record-current-image-digest", "verify-new-image-contains-required-compatibility-rules" ], - "warning": "Do not claim in-place OCI image updates are validated until rootfs replacement and rollback have been tested without losing the managed /mnt/data volume." + "warning": "Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed." }, "security": { "image_is_official_home_assistant": false, @@ -574,37 +603,30 @@ "Do not expose Home Assistant or Observer directly to the Internet without an authenticated reverse proxy and normal Home Assistant hardening." ] }, - "incompatible_builds": [ - { - "reference": "docker.io/qweritos/haos-one:18.1-amd64", - "digest": "sha256:164d579522da0875c3eaa64283c5f7b875afae2013f1b878c86f80c1eac286af", - "status": "failed-native-unprivileged-oci", - "reason": "The bundled compatibility proxy does not rewrite nested container create requests, causing kernel.domainname permission denied for Core and plug-ins.", - "do_not_use_for_this_profile": true - } - ], + "incompatible_builds": [], "notes": [ - "HAOS One is a community image, not an official Home Assistant image.", + "This is a community image derived from HAOS One, not an official Home Assistant image.", "This is not a simple Home Assistant container: the outer OCI runs systemd, Docker, Supervisor, Core and add-ons.", - "The direct Proxmox OCI path removes one extra Docker layer compared with the upstream-tested Proxmox LXC plus Docker plus haos-one deployment.", - "Proxmox privileged OCI import failed with setgid(0) Invalid argument; the validated profile is unprivileged and functional.", + "The direct Proxmox OCI path removes one extra Docker layer compared with a Proxmox LXC plus Docker plus this image deployment.", + "The profile is offered unprivileged only: Supervisor, Core, add-ons and USB devices work without privileges.", "Proxmox must use ostype=unmanaged because ID=haos is not recognized by guest distribution detection.", "The official /mnt/data path is a Proxmox-managed volume with backup=1, not a shared host bind.", - "A clean first boot used approximately 8.6G under /mnt/data after Core and Matter installation; 32G is the recommended starting size.", - "Home Assistant Core 2026.9.1 listens on port 80 in this image. Port 8123 was present only during the landing-page stage and must not be hard-coded.", + "A clean first boot used approximately 4.7G under /mnt/data; 32G is the recommended starting size.", + "The image sets SETUP_PORT=8123: Home Assistant Core keeps port 8123 after onboarding and port 80 stays closed.", "Supervisor reports healthy=true and supported=true even though several host-oriented HAOS units cannot run inside LXC.", "AppArmor profile loading inside HAOS fails in the current unprivileged LXC; this is a security limitation and must remain visible to users.", - "The current tag 18 works because it includes the upstream Docker API create-request rewrite; tag 18.1-amd64 does not.", + "Images are tagged by Home Assistant OS version, such as 18.3; latest is the newest build the project published. Builds are started by hand, not on every Home Assistant OS release.", "Mutable tags must be resolved to an immutable architecture-specific digest before installation.", - "Restart persistence is validated. Full vzdump restore, outer OCI image replacement, USB passthrough and real multicast discovery remain pending." + "Restart persistence, USB passthrough, rebuilding the container with its data kept and recovery from its backup are validated. The update to a newer image and real multicast discovery remain pending." ], "references": { - "project": "https://github.com/qweritos/haos-one", - "docker_image": "https://hub.docker.com/r/qweritos/haos-one", - "dockerfile": "https://github.com/qweritos/haos-one/blob/master/Dockerfile", - "compatibility_documentation": "https://github.com/qweritos/haos-one/blob/master/docs/haos-one-compat.md", + "project": "https://github.com/sevenrats/docker-haos", + "docker_image": "https://github.com/sevenrats/docker-haos/pkgs/container/haos", + "dockerfile": "https://github.com/sevenrats/docker-haos/blob/master/Dockerfile", + "compatibility_documentation": "https://github.com/sevenrats/docker-haos/blob/master/docs/haos-compat.md", "home_assistant_os_releases": "https://github.com/home-assistant/operating-system/releases", - "proxmox_pct_manual": "https://pve.proxmox.com/pve-docs/pct.1.html" + "proxmox_pct_manual": "https://pve.proxmox.com/pve-docs/pct.1.html", + "derived_from": "https://github.com/qweritos/haos-one" } }, "installer_profile": { @@ -634,22 +656,25 @@ "volumes" ], "untranslated_blockers": [], - "policy": "Native laboratory adaptations implemented. Rolling latest first boot, backup restore and outer-image upgrades still require runtime validation." + "policy": "Native laboratory adaptations implemented. Installation, modification with the data kept and recovery from the backup are validated; the update to a newer image waits for the first one the project publishes." }, "validation": { "schema": "passed-at-generation", "validated_architecture": "amd64", - "arm64": "supported-upstream-not-yet-validated-in-proxmenux-laboratory", + "arm64": "published-upstream-not-yet-validated-in-proxmenux-laboratory", "validated_profile": { - "id": "pve55-haos-one-native-oci", - "validated_on": "2026-09-08", - "validation_status": "passed-functional-restart-persistence-with-known-nonblocking-issues", - "proxmox_version": "9.2.11", - "container_id": 128, - "haos_version": "18.2", - "home_assistant_core": "2026.9.1", - "supervisor": "2026.09.0", - "matter_server": "9.2.0", + "id": "pve55-haos-native-oci", + "validated_on": "2026-10-07", + "validation_status": "passed-install-restart-persistence-with-known-nonblocking-issues", + "proxmox_version": "9.2.20", + "kernel": "7.0.14-19-pve", + "container_id": 121, + "image_reference": "ghcr.io/sevenrats/haos:latest", + "image_digest": "sha256:5b499b4782e7547fdce746ebd9c37b0de802626f4db7b80b24660da2f3442ba7", + "image_created": "2026-09-23", + "haos_version": "18.3", + "home_assistant_core": "2026.9.4", + "supervisor": "2026.09.3", "resources": { "cores": 4, "memory_mb": 4096, @@ -658,29 +683,33 @@ "data": "local-lvm:32G,mp=/mnt/data,backup=1" }, "observed_after_first_boot": { - "data_used": "approximately-8.6G", - "memory_used_bytes": 1368289280, - "rootfs_used_bytes": 624910336 + "data_used": "approximately-4.7G", + "first_boot_seconds": 90 }, "validation": { + "installer_default_mode": "passed", "oci_import": "passed-with-ostype-unmanaged", "image_entrypoint": "passed-/entrypoint.sh-/sbin/init", "halt_signal": "passed-SIGRTMIN+3", - "nested_docker": "passed-overlayfs", - "compatibility_proxy": "passed", - "home_assistant_web": "passed-http-200-port-80", + "unprivileged": "passed", + "home_assistant_web": "passed-http-port-8123", + "port_80": "closed", "observer": "passed-http-200-port-4357", "supervisor_healthy": true, "supervisor_supported": true, "core_running": true, "plugins_running": true, - "matter_addon_running": true, - "restart_persistence": "passed", + "notes_link_port": 8123, + "reboot_from_home_assistant": "passed-container-back-with-its-dhcp-address", + "shutdown_from_home_assistant": "passed-started-again-by-the-watchdog", + "shutdown_from_proxmox": "passed-25-seconds-left-stopped-by-the-watchdog", + "restart_persistence": "passed-marker-in-/mnt/data-unchanged", + "usb_passthrough": "passed-maintainer-test-unprivileged", "native_volume_backup_flag": "passed-mp0-backup-1", - "full_vzdump_restore": "pending", - "outer_image_upgrade": "pending", - "usb_passthrough": "pending", - "multicast_discovery": "pending-functional-device-test" + "full_vzdump_restore": "passed-recovery-of-an-interrupted-modification", + "outer_image_upgrade": "pending-no-newer-image-published", + "multicast_discovery": "pending-functional-device-test", + "modification": "passed-cores-changed-data-kept-watchdog-kept" }, "known_nonblocking_resolution_issues": [ "haos-mglru.service-failed", @@ -690,43 +719,10 @@ "dummy-networkmanager-may-report-ipv4-or-dns-diagnostics" ] }, - "source_profile": "haos-one-oci.json", - "service_health": "passed-observed-2026-09-14", - "restart_persistence": "passed-2026-09-14", - "backup_restore": "pending", - "update_preserves_data": "pending", - "latest_runtime_observation": { - "date": "2026-09-14", - "vmid": 100, - "architecture": "amd64", - "proxmox": "9.2.18", - "kernel": "7.0.14-16-pve", - "core_version": "2026.9.2", - "supervisor_version": "2026.09.0", - "image_reference": "qweritos/haos-one:latest", - "outer_image_digest_verified": false, - "supervisor_healthy": true, - "supervisor_supported": true, - "required_internal_containers_running": true, - "matter_server_healthy_after_restart": true, - "restart_method": "pct shutdown --timeout 90; pct start", - "managed_data_volume_unchanged": true, - "data_volume_gb": 32, - "data_volume_backup_flag": true, - "configuration_yaml_sha256_unchanged": true, - "temporary_marker_sha256_unchanged": true, - "temporary_marker_removed": true, - "core_http_port": 80, - "observer_http_port": 4357, - "lan_address_unchanged": true, - "limitations": [ - "Supervisor AppArmor profile could not be loaded inside this LXC.", - "Host kernel config/debug mounts, auditd and MGLRU units fail in this LXC.", - "PulseAudio warning appeared during boot; hassio_audio subsequently running, audio functionality not tested.", - "Core translation-domain warnings observed; no functionality test of associated integrations.", - "Full vzdump restore and outer OCI image replacement remain untested." - ] - } + "service_health": "passed-observed-2026-10-07", + "restart_persistence": "passed-2026-10-07", + "backup_restore": "passed-2026-10-07", + "update_preserves_data": "passed-by-modification-2026-10-07" }, "lifecycle": { "update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/mnt/data", @@ -741,14 +737,14 @@ "validated_workflows": { "core_supervisor_and_addons": "managed-by-home-assistant-supervisor", "outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data", - "outer_oci_update_validation": "pending", + "outer_oci_update_validation": "rebuild-and-recovery-passed-newer-image-pending", "required_before_update": [ "create-and-download-a-full-home-assistant-backup", "create-a-native-proxmox-backup-including-mp0", "record-current-image-digest", "verify-new-image-contains-required-compatibility-rules" ], - "warning": "Do not claim in-place OCI image updates are validated until rootfs replacement and rollback have been tested without losing the managed /mnt/data volume." + "warning": "Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed." } } } diff --git a/oci/catalog/apps/seerr.json b/oci/catalog/apps/seerr.json index 565e13e1..1893113d 100644 --- a/oci/catalog/apps/seerr.json +++ b/oci/catalog/apps/seerr.json @@ -382,7 +382,10 @@ "scheme": "http", "port": 5055, "path": "/api/v1/settings/public", - "timeout_seconds": 360 + "timeout_seconds": 360, + "request_timeout_seconds": 5, + "verify_tls": false, + "stability_seconds": 0 }, "security": { "options": { @@ -391,7 +394,20 @@ } }, "adaptations": [ - "Compose cap_drop ALL -> lxc.cap.drop reset and lxc.cap.keep none; no-new-privileges -> lxc.no_new_privs 1." + "Compose cap_drop ALL -> lxc.cap.drop reset and lxc.cap.keep none; no-new-privileges -> lxc.no_new_privs in the include of the container.", + "The configuration volume is given to the user the image runs as, uid 1000." + ], + "volume_owner": { + "uid": 1000, + "gid": 1000 + }, + "volume_preparations": [ + { + "container_path": "/app/config", + "remove_lost_found": true, + "owner_strategy": "mapped-application-user", + "only_when_mount_type": "managed-volume" + } ] } }, diff --git a/oci/catalog/apps/sharecovex.json b/oci/catalog/apps/sharecovex.json new file mode 100644 index 00000000..d4b6b3eb --- /dev/null +++ b/oci/catalog/apps/sharecovex.json @@ -0,0 +1,554 @@ +{ + "schema_version": "0.5.0", + "kind": "proxmenux.oci-template", + "id": "image-sharecovex", + "status": "laboratory-validated", + "catalog_ui": { + "title": { + "en_US": "ShareCoveX" + }, + "tagline": { + "en_US": "SMB, NFS and Time Machine shares from a web panel" + }, + "description": { + "en_US": "File-sharing appliance with a web panel: publish directories over SMB, NFS or both, with Samba users, client networks and Time Machine destinations. Each directory to share is a container volume or a directory of the host, mounted under /shares." + }, + "category": "sharing", + "category_label": "Files & Sharing", + "author": "MacRimi", + "developer": "MacRimi", + "icon": "https://raw.githubusercontent.com/MacRimi/ShareCoveX/refs/heads/main/assets/sharecovex-icon.svg", + "thumbnail": null, + "screenshots": [], + "architectures": [ + "amd64", + "arm64" + ], + "launch": { + "scheme": "http", + "port": 8080, + "path": "/" + }, + "website": "https://github.com/MacRimi/ShareCoveX", + "documentation": "https://github.com/MacRimi/ShareCoveX#readme", + "repository": "https://github.com/MacRimi/ShareCoveX", + "tips": [ + "Each directory to share is mounted under /shares with the name given to it during the installation; more can be added later with Modify.", + "A directory of the host keeps the owner it already has. The panel shows whether the shared identity (1000:1000 by default) can write to it.", + "Create the Samba users in Settings of the panel, then publish each directory from Resources.", + "NFS is served as NFSv3. NFSv4 needs the privileged profile offered by the advanced installation.", + "/config: Proxmox volume included in backup and kept when the application is updated or modified." + ], + "mini_changelog": [], + "display_version": null, + "updated_at": "2026-10-08", + "hidden": false + }, + "source": { + "provider": "MacRimi", + "repository": "https://github.com/MacRimi/ShareCoveX", + "revision": "d8499627f459559574118781ba78ff29b50b5083", + "image_repository_url": "https://github.com/MacRimi/ShareCoveX/pkgs/container/sharecovex", + "readme_pushed_at": "", + "compose_sha256": "6629a9e2aa650c430aeecc66308029304d4ffde3be8a76a9b7dc665470c67f1e", + "generated_at": "2026-10-08T19:09:08+00:00", + "default_branch": "main" + }, + "container_contract": { + "service_name": "sharecovex", + "container_name": "sharecovex", + "image": { + "reference": "ghcr.io/macrimi/sharecovex:latest", + "registry": "ghcr.io", + "repository": "ghcr.io/macrimi/sharecovex", + "tag": "latest", + "digest": null, + "pull_policy": "resolve-written-reference-to-architecture-digest-at-install" + }, + "environment": [ + { + "name": "SHARECOVEX_ADMIN_PASSWORD", + "example": "", + "prompt": "Password of the ShareCoveX panel administrator (8 characters or more)", + "prompt_user": true, + "required": true, + "sensitive": true, + "source": "upstream-documentation" + } + ], + "volumes": [ + { + "id": "config", + "container_path": "/config", + "compose_source_example": "sharecovex-config", + "read_only": false, + "required": true, + "installation_choice": [ + "managed-volume" + ], + "default": "managed-volume", + "managed_volume": { + "backup": true, + "default_size_gb": 16 + } + } + ], + "ports": [ + { + "container_port": 8080, + "published_example": 8080, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 445, + "published_example": 445, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 2049, + "published_example": 2049, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 20048, + "published_example": 20048, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + } + ], + "related_services": [], + "restart": "unless-stopped", + "stop_grace_period": "30s", + "original_compose": "name: sharecovex\nservices:\n sharecovex:\n image: ghcr.io/macrimi/sharecovex:latest\n container_name: sharecovex\n restart: unless-stopped\n stop_grace_period: 30s\n environment:\n SHARECOVEX_ADMIN_PASSWORD: ${GENERATED_SHARECOVEX_ADMIN_PASSWORD}\n ports:\n - 8080:8080/tcp\n - 445:445/tcp\n - 2049:2049/tcp\n - 20048:20048/tcp\n volumes:\n - sharecovex-config:/config\n healthcheck:\n test:\n - CMD\n - python3\n - -c\n - import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz',\n timeout=4)\n interval: 30s\n timeout: 5s\n retries: 3\n start_period: 20s\n" + }, + "compose_stack": { + "project_name": "sharecovex", + "deployment_model": "one-native-oci-lxc-per-compose-service", + "user_experience": "single-application-install", + "main_service": "sharecovex", + "service_count": 1, + "services": [ + { + "name": "sharecovex", + "image": "ghcr.io/macrimi/sharecovex:latest", + "is_main": true, + "role": "frontend", + "vmid_offset": 0, + "depends_on": [], + "frontend_network": true, + "private_network": false, + "compose": { + "image": "ghcr.io/macrimi/sharecovex:latest", + "container_name": "sharecovex", + "restart": "unless-stopped", + "stop_grace_period": "30s", + "environment": { + "SHARECOVEX_ADMIN_PASSWORD": "${GENERATED_SHARECOVEX_ADMIN_PASSWORD}" + }, + "ports": [ + "8080:8080/tcp", + "445:445/tcp", + "2049:2049/tcp", + "20048:20048/tcp" + ], + "volumes": [ + "sharecovex-config:/config" + ], + "healthcheck": { + "test": [ + "CMD", + "python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4)" + ], + "interval": "30s", + "timeout": "5s", + "retries": 3, + "start_period": "20s" + } + } + } + ], + "top_level": { + "name": "sharecovex" + }, + "networking": { + "frontend": "selected-proxmox-bridge", + "private_required": false, + "private_creation": "automatic-create-if-missing", + "private_address_allocation": "automatic-static-address-per-service", + "service_discovery": "private-addresses-with-compose-service-host-aliases", + "dependency_external_access": "disabled-unless-service-publishes-ports", + "prompt_user_for_private_network": false + }, + "storage": [ + { + "id": "sharecovex-volume-0", + "service": "sharecovex", + "container_path": "/config", + "mode": "managed-volume", + "user_selectable": false, + "backup": true, + "shared_with_other_lxc": false, + "source_path": null, + "source_path_prompt": null + } + ], + "orchestration": { + "reserve_vmids_atomically": 1, + "start_order": [ + "sharecovex" + ], + "stop_order": [ + "sharecovex" + ], + "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback", + "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes" + }, + "installer_inputs": { + "prompted": [ + "stack_name", + "base_vmid", + "rootfs_storage", + "persistent_data_destinations", + "frontend_bridge", + "frontend_ipv4_mode" + ], + "automatic": [ + "dependent_vmids", + "private_bridge", + "private_subnet", + "private_service_addresses", + "compose_service_aliases", + "generated_secrets", + "dependency_start_and_stop_order" + ], + "generated_secrets": [ + { + "id": "sharecovex-admin-password", + "strategy": "generate-cryptographically-random-at-install", + "bindings": [ + { + "service": "sharecovex", + "environment_variable": "SHARECOVEX_ADMIN_PASSWORD" + } + ] + } + ] + } + }, + "first_run": { + "endpoints": [ + { + "label": "ShareCoveX panel", + "scheme": "http", + "port": 8080, + "path": "/", + "source": "upstream-documentation" + } + ], + "credentials": [ + { + "label": "ShareCoveX panel", + "type": "configured-or-installer-generated", + "username": "admin", + "password": null, + "password_environment": "SHARECOVEX_ADMIN_PASSWORD", + "change_required": false, + "source": "https://github.com/MacRimi/ShareCoveX#readme" + } + ] + }, + "proxmox": { + "runtime": "native-oci-lxc", + "technology_status": "proxmox-technology-preview", + "defaults": { + "unprivileged": true, + "ostype": "auto-from-image", + "cores": 2, + "memory_mb": 1024, + "swap_mb": 512, + "rootfs_size_gb": 4, + "rootfs_storage": "local-lvm", + "volume_storage": "local-lvm", + "template_storage": "local", + "bridge": "vmbr0", + "ipv4": "dhcp", + "firewall": true, + "host_managed_network": true, + "onboot": true, + "features": [], + "shutdown_timeout_seconds": 30 + }, + "image_metadata_policy": { + "entrypoint": "import-from-oci-image", + "cmd": "import-from-oci-image", + "environment": "import-image-env-then-apply-compose-overrides", + "user": "import-from-oci-image", + "working_dir": "import-from-oci-image", + "stop_signal": "import-from-oci-image" + }, + "installer_profile": { + "startup_healthcheck": { + "type": "http", + "scheme": "http", + "port": 8080, + "path": "/healthz", + "timeout_seconds": 110, + "request_timeout_seconds": 5, + "stability_seconds": 0, + "verify_tls": true, + "required": true, + "source": "compose-healthcheck" + }, + "volume_owner": { + "uid": 1000, + "gid": 1000 + }, + "volume_preparations": [ + { + "container_path": "/config", + "remove_lost_found": true, + "owner_strategy": "mapped-root", + "only_when_mount_type": "managed-volume" + } + ], + "generated_sensitive_environment": { + "SHARECOVEX_ADMIN_PASSWORD": { + "strategy": "token-hex", + "bytes": 8 + } + }, + "minimum_secret_length": { + "SHARECOVEX_ADMIN_PASSWORD": 8 + }, + "shared_directories": { + "container_root": "/shares", + "reserved_names": [ + "config" + ], + "default_size_gb": 32 + }, + "security": { + "optional_relaxations": [ + { + "id": "nfsv4", + "enable_prompt": "NFSv4 needs a privileged container without AppArmor or seccomp confinement: a compromised image could affect the host. NFSv3, SMB and Time Machine work without it.\n\nPrepare this container for NFSv4?", + "enabled_default": false, + "privileged": true, + "options": { + "apparmor_profile": "unconfined", + "seccomp_profile": "unconfined" + } + } + ] + }, + "completion_notes": [ + "Sign in to the panel and create a Samba user in Settings before publishing a directory over SMB.", + "Publish each directory from Resources: open it, switch on SMB, NFS or both, and save." + ] + }, + "adaptations": [ + { + "id": "imported-compose-source", + "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", + "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.", + "reason": "Catalog import must not imply runtime compatibility.", + "behavioral_impact": "No automatic installation before review.", + "validation": "pending-per-application" + }, + { + "id": "rolling-latest-image", + "upstream_behavior": "A discovered Compose may pin a release tag or digest.", + "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.", + "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.", + "behavioral_impact": "The installed release can be newer than the discovered Compose revision.", + "validation": "pending-per-application" + }, + { + "id": "dedicated-lxc-network", + "upstream_behavior": "Docker publishes selected container ports on the Docker host.", + "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.", + "reason": "A native LXC has its own address and does not require Docker port NAT.", + "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.", + "validation": "pending-per-application" + }, + { + "id": "compose-shm-size", + "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.", + "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.", + "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-command", + "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.", + "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.", + "reason": "Proxmox stores the effective OCI process as one entrypoint string.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-privileged-mode", + "upstream_behavior": "Compose selects whether the container runs in privileged mode.", + "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.", + "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.", + "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.", + "validation": "native-equivalent" + }, + { + "id": "compose-process-runtime", + "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.", + "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.", + "reason": "The OCI process must start with the same identity, command and working directory without Docker.", + "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-healthcheck", + "upstream_behavior": "Docker periodically executes the declared container healthcheck.", + "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.", + "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.", + "behavioral_impact": "The check runs during installation rather than continuously after installation.", + "validation": "pending-per-application" + }, + { + "id": "compose-cpu-priority", + "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.", + "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.", + "reason": "Both settings express relative CPU priority on different scales.", + "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-resource-limits", + "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.", + "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.", + "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.", + "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-identity", + "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.", + "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.", + "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.", + "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-mode", + "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.", + "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.", + "reason": "The LXC is the application host and already has its own address and port namespace.", + "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-capabilities-and-sysctls", + "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.", + "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.", + "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.", + "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.", + "validation": "not-requested-by-compose" + }, + { + "id": "docker-engine-metadata", + "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.", + "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.", + "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.", + "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-device-passthrough", + "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.", + "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.", + "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.", + "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-host-ipc", + "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.", + "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.", + "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.", + "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.", + "validation": "not-requested-by-compose" + } + ], + "catalog": { + "replaces_discovered_ids": [] + } + }, + "compatibility": { + "automatic_install_candidate": true, + "validated": false, + "supported_compose_keys": [ + "cap_add", + "command", + "container_name", + "cpu_shares", + "deploy", + "devices", + "entrypoint", + "environment", + "extra_hosts", + "group_add", + "healthcheck", + "hostname", + "image", + "init", + "ipc", + "labels", + "logging", + "mac_address", + "mem_limit", + "network_mode", + "networks", + "ports", + "privileged", + "restart", + "runtime", + "security_opt", + "shm_size", + "stdin_open", + "stop_grace_period", + "sysctls", + "tty", + "ulimits", + "user", + "volumes", + "working_dir" + ], + "untranslated_blockers": [], + "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available." + }, + "validation": { + "schema": "passed-at-generation", + "clean_install": "passed-amd64", + "service_health": "passed-amd64", + "restart_persistence": "pending", + "backup_restore": "pending", + "update_preserves_data": "pending" + }, + "lifecycle": { + "update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/config-and-shared-directories", + "registry_state": { + "resolved_architecture": null, + "resolved_digest": null, + "image_version_label": null, + "image_created": null + }, + "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", + "automatic_unattended_updates": false + } +} diff --git a/oci/catalog/categories.json b/oci/catalog/categories.json index 3fe4430f..d9589a88 100644 --- a/oci/catalog/categories.json +++ b/oci/catalog/categories.json @@ -7,6 +7,7 @@ "network": "Network & Firewall", "documents": "Documents & Notes", "downloads": "Files & Downloads", + "sharing": "Files & Sharing", "arr": "*Arr Suite", "smarthome": "IoT & Smart Home", "monitoring": "Monitoring & Analytics", @@ -156,6 +157,7 @@ "scummvm": "gaming", "sealskin": "desktop", "shadps4": "gaming", + "sharecovex": "sharing", "shotcut": "creative", "sickgear": "arr", "signal": "communication", diff --git a/oci/catalog/curated/haos-one.json b/oci/catalog/curated/haos-one.json index bde76045..8f8ab10c 100644 --- a/oci/catalog/curated/haos-one.json +++ b/oci/catalog/curated/haos-one.json @@ -11,12 +11,12 @@ "en_US": "Community single-container Home Assistant OS image" }, "description": { - "en_US": "Community HAOS One image adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data." + "en_US": "Community Home Assistant OS image, derived from HAOS One, adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data." }, "category": "smarthome", "category_label": "IoT & Smart Home", - "author": "qweritos", - "developer": "qweritos", + "author": "sevenrats", + "developer": "sevenrats", "icon": null, "thumbnail": null, "screenshots": [], @@ -26,42 +26,42 @@ ], "launch": { "scheme": "http", - "port": 80, + "port": 8123, "path": "/" }, - "website": "https://github.com/qweritos/haos-one", - "documentation": "https://github.com/qweritos/haos-one#readme", - "repository": "https://github.com/qweritos/haos-one", + "website": "https://github.com/sevenrats/docker-haos", + "documentation": "https://github.com/sevenrats/docker-haos#readme", + "repository": "https://github.com/sevenrats/docker-haos", "tips": [ "This is a third-party community image and is not affiliated with Home Assistant.", "The validated profile uses a managed /mnt/data volume; preserve it during every image replacement.", - "Port 80 is used by current Core releases in the validated profile; port 8123 may appear during setup or on older releases.", + "Home Assistant answers on port 8123, during the first setup and afterwards.", "Experimental unprivileged profile with nesting and keyctl. Internal AppArmor profiles may not be available.", "The first start downloads internal images. Success is confirmed only with Supervisor healthy/supported and a real Core, internal services and Observer running.", - "The web port is detected between 80 and 8123. If the check fails, the CT, data and log are kept for diagnosis; success is not confirmed.", - "/mnt/data: Proxmox volume of 32 GB by default, minimum 16 GB, included in backup. Full restore and external image update pending testing." + "If the check of the first start fails, the CT, data and log are kept for diagnosis; success is not confirmed.", + "/mnt/data: Proxmox volume of 32 GB by default, minimum 16 GB, included in backup and kept when the application is updated or modified." ], "mini_changelog": [], "display_version": null, - "updated_at": "2026-08-20" + "updated_at": "2026-09-23" }, "source": { - "provider": "qweritos", - "repository": "https://github.com/qweritos/haos-one", - "revision": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede", - "image_repository_url": "https://hub.docker.com/r/qweritos/haos-one", - "readme_pushed_at": "2026-08-20T12:29:50Z", - "compose_sha256": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede", - "generated_at": "2026-09-12T15:54:10+00:00", + "provider": "sevenrats", + "repository": "https://github.com/sevenrats/docker-haos", + "revision": "e7fe7f6728b84763b76658ab5a9669f50cb029b0", + "image_repository_url": "https://github.com/sevenrats/docker-haos/pkgs/container/haos", + "readme_pushed_at": "2026-09-23T15:30:10Z", + "compose_sha256": "4301a2454ca1a5ff58d596dc1f2f99eba52ab77a307f394d81e6d5f66f824111", + "generated_at": "2026-10-07T00:00:00+00:00", "default_branch": "master" }, "container_contract": { "service_name": "haos-one", "container_name": "haos-one", "image": { - "reference": "qweritos/haos-one:latest", - "registry": "docker.io", - "repository": "qweritos/haos-one", + "reference": "ghcr.io/sevenrats/haos:latest", + "registry": "ghcr.io", + "repository": "sevenrats/haos", "tag": "latest", "digest": null, "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install" @@ -72,7 +72,7 @@ "example": "1", "required": true, "sensitive": false, - "source": "docker-compose", + "source": "image-default", "prompt_user": false }, { @@ -80,16 +80,31 @@ "example": "auto", "required": true, "sensitive": false, - "source": "docker-compose", + "source": "image-default", "prompt_user": false }, { - "name": "DEV", - "example": "0", + "name": "SETUP_PORT", + "example": "8123", "required": true, "sensitive": false, - "source": "docker-compose", + "source": "image-default", "prompt_user": false + }, + { + "name": "ENABLE_IPV6", + "example": "1", + "required": true, + "sensitive": false, + "source": "image-default", + "prompt_user": false + }, + { + "name": "TZ", + "example": "Europe/Madrid", + "required": true, + "sensitive": false, + "source": "upstream-compose-environment" } ], "volumes": [ @@ -111,8 +126,8 @@ ], "ports": [ { - "container_port": 80, - "published_example": 80, + "container_port": 8123, + "published_example": 8123, "protocol": "tcp", "required": true, "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" @@ -126,9 +141,9 @@ } ], "related_services": [], - "restart": null, - "stop_grace_period": null, - "original_compose": "name: haos-one\nservices:\n haos-one:\n image: qweritos/haos-one:latest\n privileged: true\n environment:\n USE_DUMMY_NETWORKMANAGER: '1'\n USE_UDEV_SHIM: auto\n DEV: '0'\n ports:\n - 80:80\n - 4357:4357\n volumes:\n - haos-data:/mnt/data\n stop_signal: SIGRTMIN+3\nvolumes:\n haos-data: {}\n" + "restart": "unless-stopped", + "stop_grace_period": "2m", + "original_compose": "name: haos-one\nservices:\n haos-one:\n image: ghcr.io/sevenrats/haos:latest\n privileged: true\n restart: unless-stopped\n stop_grace_period: 2m\n environment:\n USE_DUMMY_NETWORKMANAGER: '1'\n USE_UDEV_SHIM: auto\n SETUP_PORT: '8123'\n ENABLE_IPV6: '1'\n TZ: Europe/Madrid\n ports:\n - 8123:8123\n - 4357:4357\n volumes:\n - haos-data:/mnt/data\n stop_signal: SIGRTMIN+3\nvolumes:\n haos-data: {}\n" }, "compose_stack": { "project_name": "haos-one", @@ -139,7 +154,7 @@ "services": [ { "name": "haos-one", - "image": "qweritos/haos-one:latest", + "image": "ghcr.io/sevenrats/haos:latest", "is_main": true, "role": "frontend", "vmid_offset": 0, @@ -147,15 +162,19 @@ "frontend_network": true, "private_network": false, "compose": { - "image": "qweritos/haos-one:latest", + "image": "ghcr.io/sevenrats/haos:latest", "privileged": true, + "restart": "unless-stopped", + "stop_grace_period": "2m", "environment": { "USE_DUMMY_NETWORKMANAGER": "1", "USE_UDEV_SHIM": "auto", - "DEV": "0" + "SETUP_PORT": "8123", + "ENABLE_IPV6": "1", + "TZ": "Europe/Madrid" }, "ports": [ - "80:80", + "8123:8123", "4357:4357" ], "volumes": [ @@ -230,9 +249,9 @@ { "label": "Home Assistant", "scheme": "http", - "port": 80, + "port": 8123, "path": "/", - "source": "validated-current-core-profile" + "source": "image-setup-port" }, { "label": "Home Assistant Observer", @@ -269,7 +288,7 @@ "nesting=1", "keyctl=1" ], - "shutdown_timeout_seconds": 60 + "shutdown_timeout_seconds": 120 }, "image_metadata_policy": { "entrypoint": "import-from-oci-image", @@ -290,7 +309,7 @@ }, { "id": "nested-runtime-features", - "upstream_behavior": "haos-one starts systemd, Docker, Supervisor and nested Home Assistant containers.", + "upstream_behavior": "The image starts systemd, Docker, Supervisor and nested Home Assistant containers.", "native_lxc_behavior": "Use an unprivileged LXC with nesting=1 and keyctl=1.", "reason": "The inner Docker and containerd runtime require nested namespaces and keyring support.", "behavioral_impact": "The LXC remains unprivileged; no AppArmor unconfined override was required.", @@ -310,13 +329,21 @@ "native_lxc_behavior": "Use an upstream image digest that contains rewrite_create_request_payload; do not patch files locally.", "reason": "Older builds fail to start Core and plug-ins with kernel.domainname permission denied.", "behavioral_impact": "Uses the project-provided compatibility behavior unchanged.", - "validation": "passed-source-revision-5bffb27-and-runtime" + "validation": "passed-source-revision-e7fe7f6-and-runtime" + }, + { + "id": "shutdown-grace", + "upstream_behavior": "The Compose file gives systemd two minutes to stop Home Assistant and its Docker daemon.", + "native_lxc_behavior": "Shut the LXC down with a timeout of 120 seconds.", + "reason": "A shorter wait ends the nested containers before they have written their state.", + "behavioral_impact": "No application behavior is changed.", + "validation": "passed-clean-shutdown-and-start" } ], "laboratory_contract": { "requirements": { "proxmox_min_version": "9.2", - "validated_proxmox_version": "9.2.11", + "validated_proxmox_version": "9.2.20", "commands": [ "pct", "pvesm", @@ -358,7 +385,9 @@ "environment_defaults": { "USE_DUMMY_NETWORKMANAGER": "1", "USE_UDEV_SHIM": "auto", - "SETUP_PORT": null, + "SETUP_PORT": "8123", + "ENABLE_IPV6": "1", + "TZ": "UTC", "DEV": "0" }, "preserve_without_override": [ @@ -428,7 +457,7 @@ "type": "device-list", "required": false, "default": [], - "description": "Optional passthrough of Zigbee, Z-Wave, Bluetooth or other coordinators; not validated in this profile." + "description": "Optional passthrough of Zigbee, Z-Wave, Bluetooth or other coordinators." } }, "deployment": { @@ -457,9 +486,9 @@ ], "ports": [ { - "port": 80, + "port": 8123, "protocol": "tcp", - "purpose": "validated-home-assistant-web-for-core-2026.9.1" + "purpose": "home-assistant-web-fixed-by-SETUP_PORT" }, { "port": 4357, @@ -469,10 +498,10 @@ ], "conditional_ports": [ { - "port": 8123, + "port": 80, "protocol": "tcp", - "condition": "older-core-or-landing-page-stage-or-explicit-SETUP_PORT", - "warning": "Do not assume port 8123 remains active after onboarding on Home Assistant Core 2026.8 or newer." + "condition": "SETUP_PORT-emptied-on-core-2026.8-or-newer", + "warning": "The profile sets SETUP_PORT=8123. Port 80 is only used when that variable is emptied." } ] }, @@ -495,16 +524,16 @@ "data_role": "persistent-user-and-supervisor-state", "restart_test": { "status": "passed", - "method": "write-marker-shutdown-start-verify-hash-and-services", - "marker_sha256": "6c3762cede4f86dd5eeae16eff1067e188e9b47646f48ccc268c077711bffbd0", - "volume_before": "local-lvm:vm-128-disk-1", - "volume_after": "local-lvm:vm-128-disk-1", - "home_assistant_after_restart": "passed-http-200", + "method": "write-marker-reboot-shutdown-start-verify-hash-and-services", + "marker_sha256": "ddcc5557ff607ffc6ef0cdc54484f213c28f65b5ebde1c02cd8eefd2fa6b7c5e", + "volume_before": "local-lvm:vm-121-disk-1", + "volume_after": "local-lvm:vm-121-disk-1", + "home_assistant_after_restart": "passed-http-port-8123", "inner_containers_after_restart": "passed-all-running" }, "native_backup": { "expected": "included-by-mp0-backup-1", - "full-vzdump-restore-test": "pending" + "full-vzdump-restore-test": "passed" } }, "installation_steps": [ @@ -516,7 +545,7 @@ "Attach a host-managed network interface to the selected bridge.", "Preserve the imported /entrypoint.sh /sbin/init command and SIGRTMIN+3 stop signal.", "Start the LXC and allow several minutes for the first pull of Supervisor, Core and plug-ins.", - "Discover the assigned address and probe both port 80 and port 8123.", + "Discover the assigned address and probe port 8123, then port 80.", "Require Supervisor healthy=true and supported=true before reporting success.", "Verify Core, CLI, DNS, audio, multicast and observer containers are running.", "Report non-blocking HAOS resolution issues separately." @@ -526,8 +555,8 @@ "name": "home-assistant-web", "type": "http", "candidate_urls": [ - "http://${container_ip}/", - "http://${container_ip}:8123/" + "http://${container_ip}:8123/", + "http://${container_ip}/" ], "expected_status": 200, "startup_grace_seconds": 300 @@ -551,14 +580,14 @@ "update_strategy": { "core_supervisor_and_addons": "managed-by-home-assistant-supervisor", "outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data", - "outer_oci_update_validation": "pending", + "outer_oci_update_validation": "rebuild-and-recovery-passed-newer-image-pending", "required_before_update": [ "create-and-download-a-full-home-assistant-backup", "create-a-native-proxmox-backup-including-mp0", "record-current-image-digest", "verify-new-image-contains-required-compatibility-rules" ], - "warning": "Do not claim in-place OCI image updates are validated until rootfs replacement and rollback have been tested without losing the managed /mnt/data volume." + "warning": "Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed." }, "security": { "image_is_official_home_assistant": false, @@ -574,37 +603,30 @@ "Do not expose Home Assistant or Observer directly to the Internet without an authenticated reverse proxy and normal Home Assistant hardening." ] }, - "incompatible_builds": [ - { - "reference": "docker.io/qweritos/haos-one:18.1-amd64", - "digest": "sha256:164d579522da0875c3eaa64283c5f7b875afae2013f1b878c86f80c1eac286af", - "status": "failed-native-unprivileged-oci", - "reason": "The bundled compatibility proxy does not rewrite nested container create requests, causing kernel.domainname permission denied for Core and plug-ins.", - "do_not_use_for_this_profile": true - } - ], + "incompatible_builds": [], "notes": [ - "HAOS One is a community image, not an official Home Assistant image.", + "This is a community image derived from HAOS One, not an official Home Assistant image.", "This is not a simple Home Assistant container: the outer OCI runs systemd, Docker, Supervisor, Core and add-ons.", - "The direct Proxmox OCI path removes one extra Docker layer compared with the upstream-tested Proxmox LXC plus Docker plus haos-one deployment.", - "Proxmox privileged OCI import failed with setgid(0) Invalid argument; the validated profile is unprivileged and functional.", + "The direct Proxmox OCI path removes one extra Docker layer compared with a Proxmox LXC plus Docker plus this image deployment.", + "The profile is offered unprivileged only: Supervisor, Core, add-ons and USB devices work without privileges.", "Proxmox must use ostype=unmanaged because ID=haos is not recognized by guest distribution detection.", "The official /mnt/data path is a Proxmox-managed volume with backup=1, not a shared host bind.", - "A clean first boot used approximately 8.6G under /mnt/data after Core and Matter installation; 32G is the recommended starting size.", - "Home Assistant Core 2026.9.1 listens on port 80 in this image. Port 8123 was present only during the landing-page stage and must not be hard-coded.", + "A clean first boot used approximately 4.7G under /mnt/data; 32G is the recommended starting size.", + "The image sets SETUP_PORT=8123: Home Assistant Core keeps port 8123 after onboarding and port 80 stays closed.", "Supervisor reports healthy=true and supported=true even though several host-oriented HAOS units cannot run inside LXC.", "AppArmor profile loading inside HAOS fails in the current unprivileged LXC; this is a security limitation and must remain visible to users.", - "The current tag 18 works because it includes the upstream Docker API create-request rewrite; tag 18.1-amd64 does not.", + "Images are tagged by Home Assistant OS version, such as 18.3; latest is the newest build the project published. Builds are started by hand, not on every Home Assistant OS release.", "Mutable tags must be resolved to an immutable architecture-specific digest before installation.", - "Restart persistence is validated. Full vzdump restore, outer OCI image replacement, USB passthrough and real multicast discovery remain pending." + "Restart persistence, USB passthrough, rebuilding the container with its data kept and recovery from its backup are validated. The update to a newer image and real multicast discovery remain pending." ], "references": { - "project": "https://github.com/qweritos/haos-one", - "docker_image": "https://hub.docker.com/r/qweritos/haos-one", - "dockerfile": "https://github.com/qweritos/haos-one/blob/master/Dockerfile", - "compatibility_documentation": "https://github.com/qweritos/haos-one/blob/master/docs/haos-one-compat.md", + "project": "https://github.com/sevenrats/docker-haos", + "docker_image": "https://github.com/sevenrats/docker-haos/pkgs/container/haos", + "dockerfile": "https://github.com/sevenrats/docker-haos/blob/master/Dockerfile", + "compatibility_documentation": "https://github.com/sevenrats/docker-haos/blob/master/docs/haos-compat.md", "home_assistant_os_releases": "https://github.com/home-assistant/operating-system/releases", - "proxmox_pct_manual": "https://pve.proxmox.com/pve-docs/pct.1.html" + "proxmox_pct_manual": "https://pve.proxmox.com/pve-docs/pct.1.html", + "derived_from": "https://github.com/qweritos/haos-one" } }, "installer_profile": { @@ -634,22 +656,25 @@ "volumes" ], "untranslated_blockers": [], - "policy": "Native laboratory adaptations implemented. Rolling latest first boot, backup restore and outer-image upgrades still require runtime validation." + "policy": "Native laboratory adaptations implemented. Installation, modification with the data kept and recovery from the backup are validated; the update to a newer image waits for the first one the project publishes." }, "validation": { "schema": "passed-at-generation", "validated_architecture": "amd64", - "arm64": "supported-upstream-not-yet-validated-in-proxmenux-laboratory", + "arm64": "published-upstream-not-yet-validated-in-proxmenux-laboratory", "validated_profile": { - "id": "pve55-haos-one-native-oci", - "validated_on": "2026-09-08", - "validation_status": "passed-functional-restart-persistence-with-known-nonblocking-issues", - "proxmox_version": "9.2.11", - "container_id": 128, - "haos_version": "18.2", - "home_assistant_core": "2026.9.1", - "supervisor": "2026.09.0", - "matter_server": "9.2.0", + "id": "pve55-haos-native-oci", + "validated_on": "2026-10-07", + "validation_status": "passed-install-restart-persistence-with-known-nonblocking-issues", + "proxmox_version": "9.2.20", + "kernel": "7.0.14-19-pve", + "container_id": 121, + "image_reference": "ghcr.io/sevenrats/haos:latest", + "image_digest": "sha256:5b499b4782e7547fdce746ebd9c37b0de802626f4db7b80b24660da2f3442ba7", + "image_created": "2026-09-23", + "haos_version": "18.3", + "home_assistant_core": "2026.9.4", + "supervisor": "2026.09.3", "resources": { "cores": 4, "memory_mb": 4096, @@ -658,29 +683,33 @@ "data": "local-lvm:32G,mp=/mnt/data,backup=1" }, "observed_after_first_boot": { - "data_used": "approximately-8.6G", - "memory_used_bytes": 1368289280, - "rootfs_used_bytes": 624910336 + "data_used": "approximately-4.7G", + "first_boot_seconds": 90 }, "validation": { + "installer_default_mode": "passed", "oci_import": "passed-with-ostype-unmanaged", "image_entrypoint": "passed-/entrypoint.sh-/sbin/init", "halt_signal": "passed-SIGRTMIN+3", - "nested_docker": "passed-overlayfs", - "compatibility_proxy": "passed", - "home_assistant_web": "passed-http-200-port-80", + "unprivileged": "passed", + "home_assistant_web": "passed-http-port-8123", + "port_80": "closed", "observer": "passed-http-200-port-4357", "supervisor_healthy": true, "supervisor_supported": true, "core_running": true, "plugins_running": true, - "matter_addon_running": true, - "restart_persistence": "passed", + "notes_link_port": 8123, + "reboot_from_home_assistant": "passed-container-back-with-its-dhcp-address", + "shutdown_from_home_assistant": "passed-started-again-by-the-watchdog", + "shutdown_from_proxmox": "passed-25-seconds-left-stopped-by-the-watchdog", + "restart_persistence": "passed-marker-in-/mnt/data-unchanged", + "usb_passthrough": "passed-maintainer-test-unprivileged", "native_volume_backup_flag": "passed-mp0-backup-1", - "full_vzdump_restore": "pending", - "outer_image_upgrade": "pending", - "usb_passthrough": "pending", - "multicast_discovery": "pending-functional-device-test" + "full_vzdump_restore": "passed-recovery-of-an-interrupted-modification", + "outer_image_upgrade": "pending-no-newer-image-published", + "multicast_discovery": "pending-functional-device-test", + "modification": "passed-cores-changed-data-kept-watchdog-kept" }, "known_nonblocking_resolution_issues": [ "haos-mglru.service-failed", @@ -690,43 +719,10 @@ "dummy-networkmanager-may-report-ipv4-or-dns-diagnostics" ] }, - "source_profile": "haos-one-oci.json", - "service_health": "passed-observed-2026-09-14", - "restart_persistence": "passed-2026-09-14", - "backup_restore": "pending", - "update_preserves_data": "pending", - "latest_runtime_observation": { - "date": "2026-09-14", - "vmid": 100, - "architecture": "amd64", - "proxmox": "9.2.18", - "kernel": "7.0.14-16-pve", - "core_version": "2026.9.2", - "supervisor_version": "2026.09.0", - "image_reference": "qweritos/haos-one:latest", - "outer_image_digest_verified": false, - "supervisor_healthy": true, - "supervisor_supported": true, - "required_internal_containers_running": true, - "matter_server_healthy_after_restart": true, - "restart_method": "pct shutdown --timeout 90; pct start", - "managed_data_volume_unchanged": true, - "data_volume_gb": 32, - "data_volume_backup_flag": true, - "configuration_yaml_sha256_unchanged": true, - "temporary_marker_sha256_unchanged": true, - "temporary_marker_removed": true, - "core_http_port": 80, - "observer_http_port": 4357, - "lan_address_unchanged": true, - "limitations": [ - "Supervisor AppArmor profile could not be loaded inside this LXC.", - "Host kernel config/debug mounts, auditd and MGLRU units fail in this LXC.", - "PulseAudio warning appeared during boot; hassio_audio subsequently running, audio functionality not tested.", - "Core translation-domain warnings observed; no functionality test of associated integrations.", - "Full vzdump restore and outer OCI image replacement remain untested." - ] - } + "service_health": "passed-observed-2026-10-07", + "restart_persistence": "passed-2026-10-07", + "backup_restore": "passed-2026-10-07", + "update_preserves_data": "passed-by-modification-2026-10-07" }, "lifecycle": { "update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/mnt/data", @@ -741,14 +737,14 @@ "validated_workflows": { "core_supervisor_and_addons": "managed-by-home-assistant-supervisor", "outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data", - "outer_oci_update_validation": "pending", + "outer_oci_update_validation": "rebuild-and-recovery-passed-newer-image-pending", "required_before_update": [ "create-and-download-a-full-home-assistant-backup", "create-a-native-proxmox-backup-including-mp0", "record-current-image-digest", "verify-new-image-contains-required-compatibility-rules" ], - "warning": "Do not claim in-place OCI image updates are validated until rootfs replacement and rollback have been tested without losing the managed /mnt/data volume." + "warning": "Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed." } } } diff --git a/oci/catalog/curated/seerr.json b/oci/catalog/curated/seerr.json index 60596c0b..d95c0f4c 100644 --- a/oci/catalog/curated/seerr.json +++ b/oci/catalog/curated/seerr.json @@ -382,7 +382,10 @@ "scheme": "http", "port": 5055, "path": "/api/v1/settings/public", - "timeout_seconds": 360 + "timeout_seconds": 360, + "request_timeout_seconds": 5, + "verify_tls": false, + "stability_seconds": 0 }, "security": { "options": { @@ -391,7 +394,20 @@ } }, "adaptations": [ - "Compose cap_drop ALL -> lxc.cap.drop reset and lxc.cap.keep none; no-new-privileges -> lxc.no_new_privs 1." + "Compose cap_drop ALL -> lxc.cap.drop reset and lxc.cap.keep none; no-new-privileges -> lxc.no_new_privs in the include of the container.", + "The configuration volume is given to the user the image runs as, uid 1000." + ], + "volume_owner": { + "uid": 1000, + "gid": 1000 + }, + "volume_preparations": [ + { + "container_path": "/app/config", + "remove_lost_found": true, + "owner_strategy": "mapped-application-user", + "only_when_mount_type": "managed-volume" + } ] } }, diff --git a/oci/catalog/curated/sharecovex.json b/oci/catalog/curated/sharecovex.json new file mode 100644 index 00000000..d4b6b3eb --- /dev/null +++ b/oci/catalog/curated/sharecovex.json @@ -0,0 +1,554 @@ +{ + "schema_version": "0.5.0", + "kind": "proxmenux.oci-template", + "id": "image-sharecovex", + "status": "laboratory-validated", + "catalog_ui": { + "title": { + "en_US": "ShareCoveX" + }, + "tagline": { + "en_US": "SMB, NFS and Time Machine shares from a web panel" + }, + "description": { + "en_US": "File-sharing appliance with a web panel: publish directories over SMB, NFS or both, with Samba users, client networks and Time Machine destinations. Each directory to share is a container volume or a directory of the host, mounted under /shares." + }, + "category": "sharing", + "category_label": "Files & Sharing", + "author": "MacRimi", + "developer": "MacRimi", + "icon": "https://raw.githubusercontent.com/MacRimi/ShareCoveX/refs/heads/main/assets/sharecovex-icon.svg", + "thumbnail": null, + "screenshots": [], + "architectures": [ + "amd64", + "arm64" + ], + "launch": { + "scheme": "http", + "port": 8080, + "path": "/" + }, + "website": "https://github.com/MacRimi/ShareCoveX", + "documentation": "https://github.com/MacRimi/ShareCoveX#readme", + "repository": "https://github.com/MacRimi/ShareCoveX", + "tips": [ + "Each directory to share is mounted under /shares with the name given to it during the installation; more can be added later with Modify.", + "A directory of the host keeps the owner it already has. The panel shows whether the shared identity (1000:1000 by default) can write to it.", + "Create the Samba users in Settings of the panel, then publish each directory from Resources.", + "NFS is served as NFSv3. NFSv4 needs the privileged profile offered by the advanced installation.", + "/config: Proxmox volume included in backup and kept when the application is updated or modified." + ], + "mini_changelog": [], + "display_version": null, + "updated_at": "2026-10-08", + "hidden": false + }, + "source": { + "provider": "MacRimi", + "repository": "https://github.com/MacRimi/ShareCoveX", + "revision": "d8499627f459559574118781ba78ff29b50b5083", + "image_repository_url": "https://github.com/MacRimi/ShareCoveX/pkgs/container/sharecovex", + "readme_pushed_at": "", + "compose_sha256": "6629a9e2aa650c430aeecc66308029304d4ffde3be8a76a9b7dc665470c67f1e", + "generated_at": "2026-10-08T19:09:08+00:00", + "default_branch": "main" + }, + "container_contract": { + "service_name": "sharecovex", + "container_name": "sharecovex", + "image": { + "reference": "ghcr.io/macrimi/sharecovex:latest", + "registry": "ghcr.io", + "repository": "ghcr.io/macrimi/sharecovex", + "tag": "latest", + "digest": null, + "pull_policy": "resolve-written-reference-to-architecture-digest-at-install" + }, + "environment": [ + { + "name": "SHARECOVEX_ADMIN_PASSWORD", + "example": "", + "prompt": "Password of the ShareCoveX panel administrator (8 characters or more)", + "prompt_user": true, + "required": true, + "sensitive": true, + "source": "upstream-documentation" + } + ], + "volumes": [ + { + "id": "config", + "container_path": "/config", + "compose_source_example": "sharecovex-config", + "read_only": false, + "required": true, + "installation_choice": [ + "managed-volume" + ], + "default": "managed-volume", + "managed_volume": { + "backup": true, + "default_size_gb": 16 + } + } + ], + "ports": [ + { + "container_port": 8080, + "published_example": 8080, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 445, + "published_example": 445, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 2049, + "published_example": 2049, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 20048, + "published_example": 20048, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + } + ], + "related_services": [], + "restart": "unless-stopped", + "stop_grace_period": "30s", + "original_compose": "name: sharecovex\nservices:\n sharecovex:\n image: ghcr.io/macrimi/sharecovex:latest\n container_name: sharecovex\n restart: unless-stopped\n stop_grace_period: 30s\n environment:\n SHARECOVEX_ADMIN_PASSWORD: ${GENERATED_SHARECOVEX_ADMIN_PASSWORD}\n ports:\n - 8080:8080/tcp\n - 445:445/tcp\n - 2049:2049/tcp\n - 20048:20048/tcp\n volumes:\n - sharecovex-config:/config\n healthcheck:\n test:\n - CMD\n - python3\n - -c\n - import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz',\n timeout=4)\n interval: 30s\n timeout: 5s\n retries: 3\n start_period: 20s\n" + }, + "compose_stack": { + "project_name": "sharecovex", + "deployment_model": "one-native-oci-lxc-per-compose-service", + "user_experience": "single-application-install", + "main_service": "sharecovex", + "service_count": 1, + "services": [ + { + "name": "sharecovex", + "image": "ghcr.io/macrimi/sharecovex:latest", + "is_main": true, + "role": "frontend", + "vmid_offset": 0, + "depends_on": [], + "frontend_network": true, + "private_network": false, + "compose": { + "image": "ghcr.io/macrimi/sharecovex:latest", + "container_name": "sharecovex", + "restart": "unless-stopped", + "stop_grace_period": "30s", + "environment": { + "SHARECOVEX_ADMIN_PASSWORD": "${GENERATED_SHARECOVEX_ADMIN_PASSWORD}" + }, + "ports": [ + "8080:8080/tcp", + "445:445/tcp", + "2049:2049/tcp", + "20048:20048/tcp" + ], + "volumes": [ + "sharecovex-config:/config" + ], + "healthcheck": { + "test": [ + "CMD", + "python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4)" + ], + "interval": "30s", + "timeout": "5s", + "retries": 3, + "start_period": "20s" + } + } + } + ], + "top_level": { + "name": "sharecovex" + }, + "networking": { + "frontend": "selected-proxmox-bridge", + "private_required": false, + "private_creation": "automatic-create-if-missing", + "private_address_allocation": "automatic-static-address-per-service", + "service_discovery": "private-addresses-with-compose-service-host-aliases", + "dependency_external_access": "disabled-unless-service-publishes-ports", + "prompt_user_for_private_network": false + }, + "storage": [ + { + "id": "sharecovex-volume-0", + "service": "sharecovex", + "container_path": "/config", + "mode": "managed-volume", + "user_selectable": false, + "backup": true, + "shared_with_other_lxc": false, + "source_path": null, + "source_path_prompt": null + } + ], + "orchestration": { + "reserve_vmids_atomically": 1, + "start_order": [ + "sharecovex" + ], + "stop_order": [ + "sharecovex" + ], + "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback", + "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes" + }, + "installer_inputs": { + "prompted": [ + "stack_name", + "base_vmid", + "rootfs_storage", + "persistent_data_destinations", + "frontend_bridge", + "frontend_ipv4_mode" + ], + "automatic": [ + "dependent_vmids", + "private_bridge", + "private_subnet", + "private_service_addresses", + "compose_service_aliases", + "generated_secrets", + "dependency_start_and_stop_order" + ], + "generated_secrets": [ + { + "id": "sharecovex-admin-password", + "strategy": "generate-cryptographically-random-at-install", + "bindings": [ + { + "service": "sharecovex", + "environment_variable": "SHARECOVEX_ADMIN_PASSWORD" + } + ] + } + ] + } + }, + "first_run": { + "endpoints": [ + { + "label": "ShareCoveX panel", + "scheme": "http", + "port": 8080, + "path": "/", + "source": "upstream-documentation" + } + ], + "credentials": [ + { + "label": "ShareCoveX panel", + "type": "configured-or-installer-generated", + "username": "admin", + "password": null, + "password_environment": "SHARECOVEX_ADMIN_PASSWORD", + "change_required": false, + "source": "https://github.com/MacRimi/ShareCoveX#readme" + } + ] + }, + "proxmox": { + "runtime": "native-oci-lxc", + "technology_status": "proxmox-technology-preview", + "defaults": { + "unprivileged": true, + "ostype": "auto-from-image", + "cores": 2, + "memory_mb": 1024, + "swap_mb": 512, + "rootfs_size_gb": 4, + "rootfs_storage": "local-lvm", + "volume_storage": "local-lvm", + "template_storage": "local", + "bridge": "vmbr0", + "ipv4": "dhcp", + "firewall": true, + "host_managed_network": true, + "onboot": true, + "features": [], + "shutdown_timeout_seconds": 30 + }, + "image_metadata_policy": { + "entrypoint": "import-from-oci-image", + "cmd": "import-from-oci-image", + "environment": "import-image-env-then-apply-compose-overrides", + "user": "import-from-oci-image", + "working_dir": "import-from-oci-image", + "stop_signal": "import-from-oci-image" + }, + "installer_profile": { + "startup_healthcheck": { + "type": "http", + "scheme": "http", + "port": 8080, + "path": "/healthz", + "timeout_seconds": 110, + "request_timeout_seconds": 5, + "stability_seconds": 0, + "verify_tls": true, + "required": true, + "source": "compose-healthcheck" + }, + "volume_owner": { + "uid": 1000, + "gid": 1000 + }, + "volume_preparations": [ + { + "container_path": "/config", + "remove_lost_found": true, + "owner_strategy": "mapped-root", + "only_when_mount_type": "managed-volume" + } + ], + "generated_sensitive_environment": { + "SHARECOVEX_ADMIN_PASSWORD": { + "strategy": "token-hex", + "bytes": 8 + } + }, + "minimum_secret_length": { + "SHARECOVEX_ADMIN_PASSWORD": 8 + }, + "shared_directories": { + "container_root": "/shares", + "reserved_names": [ + "config" + ], + "default_size_gb": 32 + }, + "security": { + "optional_relaxations": [ + { + "id": "nfsv4", + "enable_prompt": "NFSv4 needs a privileged container without AppArmor or seccomp confinement: a compromised image could affect the host. NFSv3, SMB and Time Machine work without it.\n\nPrepare this container for NFSv4?", + "enabled_default": false, + "privileged": true, + "options": { + "apparmor_profile": "unconfined", + "seccomp_profile": "unconfined" + } + } + ] + }, + "completion_notes": [ + "Sign in to the panel and create a Samba user in Settings before publishing a directory over SMB.", + "Publish each directory from Resources: open it, switch on SMB, NFS or both, and save." + ] + }, + "adaptations": [ + { + "id": "imported-compose-source", + "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", + "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.", + "reason": "Catalog import must not imply runtime compatibility.", + "behavioral_impact": "No automatic installation before review.", + "validation": "pending-per-application" + }, + { + "id": "rolling-latest-image", + "upstream_behavior": "A discovered Compose may pin a release tag or digest.", + "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.", + "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.", + "behavioral_impact": "The installed release can be newer than the discovered Compose revision.", + "validation": "pending-per-application" + }, + { + "id": "dedicated-lxc-network", + "upstream_behavior": "Docker publishes selected container ports on the Docker host.", + "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.", + "reason": "A native LXC has its own address and does not require Docker port NAT.", + "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.", + "validation": "pending-per-application" + }, + { + "id": "compose-shm-size", + "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.", + "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.", + "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-command", + "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.", + "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.", + "reason": "Proxmox stores the effective OCI process as one entrypoint string.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-privileged-mode", + "upstream_behavior": "Compose selects whether the container runs in privileged mode.", + "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.", + "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.", + "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.", + "validation": "native-equivalent" + }, + { + "id": "compose-process-runtime", + "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.", + "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.", + "reason": "The OCI process must start with the same identity, command and working directory without Docker.", + "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-healthcheck", + "upstream_behavior": "Docker periodically executes the declared container healthcheck.", + "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.", + "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.", + "behavioral_impact": "The check runs during installation rather than continuously after installation.", + "validation": "pending-per-application" + }, + { + "id": "compose-cpu-priority", + "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.", + "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.", + "reason": "Both settings express relative CPU priority on different scales.", + "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-resource-limits", + "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.", + "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.", + "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.", + "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-identity", + "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.", + "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.", + "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.", + "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-mode", + "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.", + "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.", + "reason": "The LXC is the application host and already has its own address and port namespace.", + "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-capabilities-and-sysctls", + "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.", + "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.", + "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.", + "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.", + "validation": "not-requested-by-compose" + }, + { + "id": "docker-engine-metadata", + "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.", + "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.", + "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.", + "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-device-passthrough", + "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.", + "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.", + "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.", + "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-host-ipc", + "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.", + "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.", + "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.", + "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.", + "validation": "not-requested-by-compose" + } + ], + "catalog": { + "replaces_discovered_ids": [] + } + }, + "compatibility": { + "automatic_install_candidate": true, + "validated": false, + "supported_compose_keys": [ + "cap_add", + "command", + "container_name", + "cpu_shares", + "deploy", + "devices", + "entrypoint", + "environment", + "extra_hosts", + "group_add", + "healthcheck", + "hostname", + "image", + "init", + "ipc", + "labels", + "logging", + "mac_address", + "mem_limit", + "network_mode", + "networks", + "ports", + "privileged", + "restart", + "runtime", + "security_opt", + "shm_size", + "stdin_open", + "stop_grace_period", + "sysctls", + "tty", + "ulimits", + "user", + "volumes", + "working_dir" + ], + "untranslated_blockers": [], + "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available." + }, + "validation": { + "schema": "passed-at-generation", + "clean_install": "passed-amd64", + "service_health": "passed-amd64", + "restart_persistence": "pending", + "backup_restore": "pending", + "update_preserves_data": "pending" + }, + "lifecycle": { + "update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/config-and-shared-directories", + "registry_state": { + "resolved_architecture": null, + "resolved_digest": null, + "image_version_label": null, + "image_created": null + }, + "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", + "automatic_unattended_updates": false + } +} diff --git a/oci/catalog/index.json b/oci/catalog/index.json index 28f1d0c6..28ff7d41 100644 --- a/oci/catalog/index.json +++ b/oci/catalog/index.json @@ -3927,19 +3927,19 @@ }, { "id": "haos-one", - "provider": "qweritos", + "provider": "sevenrats", "template_family": "curated-profile", "title": "HAOS One", - "repository": "https://github.com/qweritos/haos-one", - "description": "Community HAOS One image adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data.", - "website": "https://github.com/qweritos/haos-one", + "repository": "https://github.com/sevenrats/docker-haos", + "description": "Community Home Assistant OS image, derived from HAOS One, adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data.", + "website": "https://github.com/sevenrats/docker-haos", "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp", "architectures": [ "amd64", "arm64" ], - "updated_at": "2026-08-20", - "main_image": "qweritos/haos-one:latest", + "updated_at": "2026-09-23", + "main_image": "ghcr.io/sevenrats/haos:latest", "category": "smarthome", "category_label": "IoT & Smart Home", "replaces_discovered_ids": [], @@ -9765,6 +9765,38 @@ "hidden": false, "template_id": "linuxserver-shadps4" }, + { + "id": "sharecovex", + "provider": "MacRimi", + "template_family": "curated-profile", + "title": "ShareCoveX", + "repository": "https://github.com/MacRimi/ShareCoveX", + "description": "File-sharing appliance with a web panel: publish directories over SMB, NFS or both, with Samba users, client networks and Time Machine destinations. Each directory to share is a container volume or a directory of the host, mounted under /shares.", + "website": "https://github.com/MacRimi/ShareCoveX", + "icon": "https://raw.githubusercontent.com/MacRimi/ShareCoveX/refs/heads/main/assets/sharecovex-icon.svg", + "architectures": [ + "amd64", + "arm64" + ], + "updated_at": "2026-10-08", + "main_image": "ghcr.io/macrimi/sharecovex:latest", + "category": "sharing", + "category_label": "Files & Sharing", + "replaces_discovered_ids": [], + "curated_path": "catalog/curated/sharecovex.json", + "template": "apps/sharecovex.json", + "template_status": "laboratory-validated", + "automatic_install_candidate": true, + "untranslated_blockers": [], + "requires_privileged_lxc": false, + "optional_privileged_lxc": false, + "requires_host_pid_namespace": false, + "requires_relaxed_confinement": false, + "optional_relaxed_confinement": false, + "requires_security_confirmation": false, + "hidden": false, + "template_id": "image-sharecovex" + }, { "id": "shotcut", "provider": "linuxserver.io", diff --git a/oci/catalog/verification.json b/oci/catalog/verification.json index 8cdea6cb..46f0c379 100644 --- a/oci/catalog/verification.json +++ b/oci/catalog/verification.json @@ -117,6 +117,9 @@ "rdtclient": { "status": "laboratory-validated" }, + "sharecovex": { + "status": "laboratory-validated" + }, "snapotter": { "status": "laboratory-validated" }, diff --git a/oci/remote/haos_healthcheck.py b/oci/remote/haos_healthcheck.py index da50a2fb..5620a344 100644 --- a/oci/remote/haos_healthcheck.py +++ b/oci/remote/haos_healthcheck.py @@ -27,6 +27,9 @@ def note(text): """Detail for the run log; without one, for stderr.""" try: if OCI_LOG: + if not sys.stderr.isatty(): + # The progress line goes to a file, which may be this log: end it first. + print(file=sys.stderr, flush=True) log(OCI_LOG, text) return except OSError: @@ -110,7 +113,7 @@ def probe(vmid, ip, remaining): raise Pending(translate('Supervisor does not confirm healthy and supported yet')) if not http_ready(f'http://{ip}:4357/', max(0.1, min(5, remaining()))): raise Pending(translate('Observer is not responding on port 4357')) - for port in (80, 8123): + for port in (8123, 80): if http_ready(f'http://{ip}:{port}/', max(0.1, min(5, remaining()))): return [{'label': 'Home Assistant', 'url': f'http://{ip}:{port}/'}, {'label': 'Home Assistant Observer', 'url': f'http://{ip}:4357/'}] diff --git a/oci/remote/install_generic_stack.py b/oci/remote/install_generic_stack.py index 765b72b2..6170e353 100644 --- a/oci/remote/install_generic_stack.py +++ b/oci/remote/install_generic_stack.py @@ -103,6 +103,24 @@ def persist_instances(deployment, services, primary_id=None): oci_instances.publish_stack(oci_instances.ROOT, None, template, deployment, services) +def refresh_notes(services): + """The notes of each container were written when it was created, before + it had its leg on the local network: write them again with the address + the application is reached at.""" + from oci_description import render + from oci_native_stack import access_address as reachable_address + for service in services: + vmid = int(service['vmid']) + try: + record = oci_instances.read(oci_instances.ROOT, vmid) + running = 'running' in run('pct', 'status', vmid) + description = render(record['template'], '', record['installation_id'], + reachable_address(vmid, wait=20 if running else 0)) + run('pct', 'set', vmid, '--description', description) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + log(LOG, f'Notes of CT {vmid} not refreshed: {error}') + + def run(*args, capture=True, timeout=180): argv = list(map(str,args)) if capture: @@ -811,6 +829,7 @@ def main(): fcntl.flock(lock,fcntl.LOCK_UN) result = finish_independent_suite(services,subnet) persist_instances(deployment, services) + refresh_notes(services) result.update(completion_notes=list(deployment.get('completion_notes',[])), log=LOG) print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True) return @@ -852,6 +871,7 @@ def main(): msg_ok(translate('LAN address applied to the application URLs')) result=results[primary['name']] persist_instances(deployment, services, primary_id) + refresh_notes(services) # The credentials of the main service come from its own installation. result.update(vmid=primary_id,ip=lan,stack_vmids={s['name']:s['vmid'] for s in services}, urls=[{'label':'Web UI','url':public_url}], diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 44ba54d5..e6dc6b35 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -745,6 +745,21 @@ apply_post_start_configuration() { esac } +# The first address of the container on an interface Proxmox gave it. A +# container that runs Docker inside also lists the ones of its inner bridges. +# Fails when the interfaces cannot be read, so the caller takes what is listed. +own_ipv4() { + local name listed found own="" looked=0 + listed=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -E '^[0-9]+\.') || return 1 + for name in $(pct config "$VMID" 2>/dev/null | sed -n 's/^net[0-9]*: .*name=\([A-Za-z0-9_.-]*\).*/\1/p'); do + found=$(lxc-attach -n "$VMID" -s NETWORK -- ip -4 -o addr show dev "$name" scope global 2>/dev/null) || continue + looked=1 + own+=$(sed -n 's/.* inet \([0-9.]*\)\/.*/\1/p' <<<"$found")$'\n' + done + (( looked == 1 )) || return 1 + grep -m1 -Fx -f <(sed '/^$/d' <<<"$own") <<<"$listed" || true +} + # $1: optional result label shown before the address. detect_container_ipv4() { local attempt addresses label=${1:-} @@ -755,10 +770,12 @@ detect_container_ipv4() { fi IP="" for attempt in $(seq 1 15); do - IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true) - if [[ -z $IP ]]; then - addresses=$(pct exec "$VMID" -- hostname -I 2>/dev/null || true) - IP=$(tr ' ' '\n' <<<"$addresses" | grep -m1 -E '^[0-9]+\.' || true) + if ! IP=$(own_ipv4); then + IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true) + if [[ -z $IP ]]; then + addresses=$(pct exec "$VMID" -- hostname -I 2>/dev/null || true) + IP=$(tr ' ' '\n' <<<"$addresses" | grep -m1 -E '^[0-9]+\.' || true) + fi fi [[ -n $IP ]] && break msg_progress "$(translate "Waiting for the network address...") $((attempt * 2))/30 s" @@ -1705,7 +1722,8 @@ else fi SYSCTL_COUNT=$(jq '.security.sysctls? // [] | length' "$DEPLOYMENT_FILE") -if (( SYSCTL_COUNT > 0 )); then +NO_NEW_PRIVILEGES=$(jq -r '.security.options.no_new_privileges? // false' "$DEPLOYMENT_FILE") +if (( SYSCTL_COUNT > 0 )) || [[ $NO_NEW_PRIVILEGES == true ]]; then SYSCTL_INCLUDE="/etc/pve/proxmenux/${VMID}.sysctls" mkdir -p /etc/pve/proxmenux || die "$(translate "Could not create the ProxMenux folder in /etc/pve")" SYSCTL_TEMP=$(mktemp) @@ -1718,12 +1736,14 @@ if (( SYSCTL_COUNT > 0 )); then printf 'lxc.sysctl.%s = %s\n' "$SYSCTL_NAME" "$SYSCTL_VALUE" >>"$SYSCTL_TEMP" oci_log "Network sysctl prepared: ${SYSCTL_NAME}=${SYSCTL_VALUE}" done < <(jq -r '.security.sysctls[]? | [.name,.value] | @tsv' "$DEPLOYMENT_FILE") + # Proxmox does not take this setting in the configuration of a container. + [[ $NO_NEW_PRIVILEGES != true ]] || printf 'lxc.no_new_privs = 1\n' >>"$SYSCTL_TEMP" [[ ! -L $SYSCTL_INCLUDE ]] || die "$(translate "The sysctl include is a link:") $SYSCTL_INCLUDE" # pmxcfs assigns its own permissions and rejects chmod. cat "$SYSCTL_TEMP" >"$SYSCTL_INCLUDE" rm -f "$SYSCTL_TEMP" set_lxc_directive "lxc.include" "$SYSCTL_INCLUDE" - msg_ok "$(translate "Network sysctls prepared:") $SYSCTL_COUNT" + (( SYSCTL_COUNT == 0 )) || msg_ok "$(translate "Network sysctls prepared:") $SYSCTL_COUNT" fi REQUIRED_CAPABILITIES=$(jq -r '.security.required_capabilities? // [] | join(",")' "$DEPLOYMENT_FILE") @@ -1731,13 +1751,9 @@ if [[ -n $REQUIRED_CAPABILITIES ]]; then msg_ok "$(translate "Compose capabilities validated in the LXC user namespace:") $REQUIRED_CAPABILITIES" fi -NO_NEW_PRIVILEGES=$(jq -r '.security.options.no_new_privileges? // false' "$DEPLOYMENT_FILE") APPARMOR_PROFILE=$(jq -r '.security.options.apparmor_profile? // empty' "$DEPLOYMENT_FILE") SECCOMP_PROFILE=$(jq -r '.security.options.seccomp_profile? // empty' "$DEPLOYMENT_FILE") SELINUX_LABEL_DISABLED=$(jq -r '.security.options.selinux_label_disabled? // false' "$DEPLOYMENT_FILE") -if [[ $NO_NEW_PRIVILEGES == true ]]; then - set_lxc_directive "lxc.no_new_privs" "1" -fi if [[ $(jq -r '.security.options.drop_all_capabilities? // false' "$DEPLOYMENT_FILE") == true ]]; then [[ -z $REQUIRED_CAPABILITIES ]] || die "$(translate "Capabilities cannot be kept and all dropped at the same time")" set_lxc_directive "lxc.cap.drop" "" @@ -2057,9 +2073,9 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then HC_PORT=$(jq -er '.port' <<<"$STARTUP_HEALTHCHECK") HC_PATH=$(jq -er '.path' <<<"$STARTUP_HEALTHCHECK") HC_TIMEOUT=$(jq -er '.timeout_seconds' <<<"$STARTUP_HEALTHCHECK") - HC_REQUEST_TIMEOUT=$(jq -er '.request_timeout_seconds' <<<"$STARTUP_HEALTHCHECK") + HC_REQUEST_TIMEOUT=$(jq -r '.request_timeout_seconds // 5' <<<"$STARTUP_HEALTHCHECK") HC_STABILITY=$(jq -r '.stability_seconds // 0' <<<"$STARTUP_HEALTHCHECK") - HC_VERIFY_TLS=$(jq -r '.verify_tls' <<<"$STARTUP_HEALTHCHECK") + HC_VERIFY_TLS=$(jq -r '.verify_tls // false' <<<"$STARTUP_HEALTHCHECK") [[ $HC_SCHEME == http || $HC_SCHEME == https ]] || die "$(translate "Invalid healthcheck scheme")" [[ $HC_PORT =~ ^[0-9]+$ && $HC_PORT -ge 1 && $HC_PORT -le 65535 ]] \ || die "$(translate "Invalid healthcheck port")" diff --git a/oci/remote/oci_console_mark.sh b/oci/remote/oci_console_mark.sh index 08710919..e6c7dda3 100755 --- a/oci/remote/oci_console_mark.sh +++ b/oci/remote/oci_console_mark.sh @@ -1,9 +1,14 @@ #!/bin/sh -# Marks each start of an OCI container in its console log, so the log can be -# read from the last start. Run by the container's lxc.hook.pre-start. +# Run by the lxc.hook.pre-start of an OCI container. Marks each start in its +# console log, so the log can be read from the last start, and queues the +# check of its DHCP address for when it is up. case "$1" in ''|*[!0-9]*) exit 0 ;; esac printf '\n=== ProxMenux: container started %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" \ >> "/var/log/proxmenux/oci/$1.console.log" 2>/dev/null +lease="${0%/*}/oci_dhcp_lease.py" +if [ -r "$lease" ] && command -v systemd-run >/dev/null 2>&1; then + systemd-run --quiet --collect --on-active=20 /usr/bin/python3 "$lease" "$1" >/dev/null 2>&1 +fi exit 0 diff --git a/oci/remote/oci_dhcp_lease.py b/oci/remote/oci_dhcp_lease.py new file mode 100644 index 00000000..92213719 --- /dev/null +++ b/oci/remote/oci_dhcp_lease.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Give its DHCP address back to an OCI container that restarted itself. + +Proxmox runs the DHCP client of a host-managed interface from the host and +starts it only when it starts the container. A container that reboots from +inside, as Home Assistant OS does from its settings, is started again by +Proxmox without that client and comes back with no address. The start hook of +every OCI container queues this check: once the container is up, the client +that is missing is started the way Proxmox starts it. + +The client lives where the container was started from: one started from the +Monitor ends when the Monitor is restarted, and the address would be lost +when its lease runs out. The Monitor asks for every container when it starts. +""" +from __future__ import annotations + +import fcntl +from pathlib import Path +import re +import subprocess +import sys +import time + +CONFIGS = Path('/etc/pve/lxc') +HOOKS = Path('/var/lib/lxc') +PROC = Path('/proc') +LOCKS = Path('/run/proxmenux') +# Proxmox starts its own client right after the container; one that just +# started is left to it, and to the check its start hook queued. +SETTLE = 30 +NAME_RE = re.compile(r'[A-Za-z0-9_.-]{1,15}') +START = 'PVE::LXC::manage_dhclient("start", $ARGV[0], $ARGV[1], $ARGV[2], "/proc/$ARGV[3]/root")' + + +def managed(config): + """The host-managed interfaces of a container configuration that take + their address by DHCP: [(name, ip version)].""" + found = [] + for line in config.split('\n[', 1)[0].splitlines(): + match = re.match(r'net\d+:\s*(.*)', line) + if not match: + continue + options = dict(part.split('=', 1) for part in match.group(1).split(',') if '=' in part) + name = options.get('name', '') + if options.get('host-managed') != '1' or options.get('link_down') == '1' or not NAME_RE.fullmatch(name): + continue + found += [(name, version) for key, version in (('ip', 4), ('ip6', 6)) if options.get(key) == 'dhcp'] + return found + + +def client_running(vmid, name, version): + """Whether a DHCP client of the interface runs, with or without a lease yet.""" + mark = f'{HOOKS}/{vmid}/hook/dhclient{version}-{name}.pid'.encode() + for entry in PROC.glob('[0-9]*/cmdline'): + try: + if mark in entry.read_bytes().split(b'\0'): + return True + except OSError: + continue + return False + + +def init_pid(vmid): + result = subprocess.run(['lxc-info', '-n', str(vmid), '-pH'], capture_output=True, text=True, check=False, timeout=30) + pid = result.stdout.strip() + return int(pid) if result.returncode == 0 and pid.isdigit() and int(pid) > 0 else None + + +def running_for(pid): + """Seconds since the init of the container started.""" + try: + return time.time() - (PROC / str(pid)).stat().st_ctime + except OSError: + return 0.0 + + +def containers(): + """The containers of this node with a host-managed interface on DHCP.""" + found = [] + for path in sorted(CONFIGS.glob('*.conf')): + try: + if path.stem.isdigit() and managed(path.read_text(errors='replace')): + found.append(int(path.stem)) + except OSError: + continue + return found + + +def start_client(vmid, name, version, pid): + # In a scope of its own: the client stays when this check ends. + result = subprocess.run(['systemd-run', '--scope', '--quiet', '--collect', 'perl', '-MPVE::LXC', '-e', START, + str(vmid), str(version), name, str(pid)], + capture_output=True, text=True, check=False, timeout=180) + return result.returncode == 0 + + +def repair(vmid, settle=0): + """Start the DHCP clients a running container is missing. Returns the + interfaces that got one: [(name, ip version)].""" + try: + config = (CONFIGS / f'{vmid}.conf').read_text(errors='replace') + except OSError: + return [] + wanted = managed(config) + if not wanted: + return [] + pid = init_pid(vmid) + if pid is None or running_for(pid) < settle: + return [] + started = [] + for name, version in wanted: + if not client_running(vmid, name, version) and start_client(vmid, name, version, pid): + started.append((name, version)) + return started + + +def check(vmid, settle=0): + with (LOCKS / f'oci-dhcp-lease-{vmid}.lock').open('w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return + for name, version in repair(vmid, settle): + print(f'CT {vmid}: DHCP client of {name} (IPv{version}) started again', flush=True) + + +def main(): + if len(sys.argv) != 2 or not (sys.argv[1].isdigit() or sys.argv[1] == '--all'): + print('usage: oci_dhcp_lease.py | --all', file=sys.stderr) + return 2 + LOCKS.mkdir(parents=True, exist_ok=True) + if sys.argv[1] == '--all': + for vmid in containers(): + check(vmid, SETTLE) + else: + check(int(sys.argv[1])) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/oci/remote/oci_host_mounts.py b/oci/remote/oci_host_mounts.py index 925f2812..12fec030 100644 --- a/oci/remote/oci_host_mounts.py +++ b/oci/remote/oci_host_mounts.py @@ -34,9 +34,13 @@ def snapshot(source, allow_missing=False): 'mode': stat.S_IMODE(info.st_mode)} -def validate_source(source, allow_missing=False): +def validate_source(source, allow_missing=False, declared=False): + """`declared` is a source the recipe of the application names itself, as a + host monitor does with /proc and /sys: it is not one the user typed.""" valid_path(source) value = snapshot(source, allow_missing) + if declared: + return dict(value, declared=True) protected = ('/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/dev', '/proc', '/sys', '/run') protected += tuple(str(Path(p).resolve()) for p in protected) resolved = value['resolved_path'] @@ -52,7 +56,8 @@ def same_source(a, b): def verify_sources(expected): for source, previous in expected.items(): - current = validate_source(source, allow_missing=not previous['exists']) + current = validate_source(source, allow_missing=not previous['exists'], + declared=previous.get('declared', False)) if not same_source(previous, current): raise ValueError(f"{translate('The operation was stopped because a shared directory changed its identity:')} {source}") diff --git a/oci/remote/oci_installation_state.py b/oci/remote/oci_installation_state.py index 96cdddda..6914c93b 100644 --- a/oci/remote/oci_installation_state.py +++ b/oci/remote/oci_installation_state.py @@ -195,6 +195,7 @@ def resolve_candidate(reference, architecture): # version inside it moved. return {'manifest_digest': digest, 'registry_digest': registry_digest, 'layers': [layer['digest'] for layer in json.loads(raw).get('layers', [])], + 'size': sum(int(layer.get('size') or 0) for layer in json.loads(raw).get('layers', [])), 'defaults': defaults, 'version': version, 'created': config.get('created')} diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index 3a7b1c96..c667673c 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -33,6 +33,7 @@ import oci_host_mounts as host_mounts import oci_accelerators as gpu_devices import oci_runtime_settings as runtime_settings import oci_image_cache as image_cache +import oci_work_backup as work_backup import oci_ui from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2 @@ -48,7 +49,9 @@ BASIC = {'arch', 'cmode', 'console', 'tty', 'cores', 'cpulimit', 'cpuunits', 'de # The hook that marks each start in that log, set the same way. 'lxc.hook.pre-start'} # Their output is data (and may hold saved secrets); it is never logged. -DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get')} +DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get'), ('pct', 'df')} +# Kept across a modification: the watchdog and where the backups are made. +HOST_PREFERENCES = ('watchdog', 'work_storage') LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci')) ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]') SPINNER_FRAME = re.compile('^ ?[' + ''.join(oci_ui.FRAMES) + ']') @@ -316,13 +319,49 @@ def mounts(config): return result +def is_file_bind(mount, files=()): + return mount['type'] == 'host-bind' and (mount['source'] in files or Path(mount['source']).is_file()) + + +def declared_sources(contract): + """The host paths the recipe of a host monitor mounts on its own.""" + if not contract['deployment'].get('host_monitor'): + return set() + profile = contract['template'].get('proxmox', {}).get('installer_profile', {}) + return {item['source'] for item in profile.get('host_monitor_mounts', [])} + + +def profile_keys(deployment): + """The raw LXC settings the installer writes for what the application + declares, and writes again on the rebuilt container.""" + keys = set() + security = deployment.get('security', {}) + options = security.get('options') or {} + if options.get('apparmor_profile'): + keys.add('lxc.apparmor.profile') + if options.get('seccomp_profile'): + keys.add('lxc.seccomp.profile') + if options.get('no_new_privileges'): + keys.add('lxc.include') + if options.get('drop_all_capabilities'): + keys.update(('lxc.cap.drop', 'lxc.cap.keep')) + keys.update(f"lxc.prlimit.{limit['name']}" for limit in deployment.get('resources', {}).get('rlimits') or []) + if deployment.get('host_monitor'): + keys.update(('lxc.include', 'lxc.net.0.type', 'lxc.hook.mount')) + return keys + + def effective_healthcheck(template): """The template's own health check or, when it has none, one derived from its web address: any HTTP answer below 500 means the application is up.""" profile = template.get('proxmox', {}).get('installer_profile', {}) check = profile.get('startup_healthcheck') - if check or profile.get('haos_healthcheck') or profile.get('host_monitor'): + if check: return check + if profile.get('haos_healthcheck'): + # Home Assistant OS has a check of its own: Supervisor, Core and the + # services it runs inside. The installer runs it on the new container. + return {'type': 'haos', 'timeout_seconds': int(profile['haos_healthcheck'].get('timeout_seconds', 1200))} for endpoint in template.get('first_run', {}).get('endpoints', []): path = str(endpoint.get('path') or '/') if (endpoint.get('scheme') in ('http', 'https') and str(endpoint.get('port', '')).isdigit() @@ -336,7 +375,7 @@ def effective_healthcheck(template): def with_default_healthcheck(contract): check = effective_healthcheck(contract['template']) - if check: + if check and check.get('type') != 'haos': contract['template'].setdefault('proxmox', {}).setdefault('installer_profile', {})['startup_healthcheck'] = check return contract @@ -365,6 +404,13 @@ def candidate_contract(record, operation, proposal=None): result = copy.deepcopy(record) result.update(template=copy.deepcopy(candidate['template']), deployment=copy.deepcopy(candidate['deployment'])) + # What ProxMenux was told about how to treat the application is not part + # of what is being edited: it stays as the record has it now. + for key in HOST_PREFERENCES: + if key in record['deployment']: + result['deployment'][key] = record['deployment'][key] + else: + result['deployment'].pop(key, None) return with_default_healthcheck(result) @@ -435,23 +481,32 @@ def preflight(record, candidate, config, coordinated=None): runtime_keys.add('lxc.mount.entry') if deployment.get('security', {}).get('sysctls'): runtime_keys.add('lxc.include') + runtime_keys |= profile_keys(deployment) + files = runtime_settings.file_binds(config) + if files: + runtime_keys.add('lxc.mount.entry') runtime_settings.check(config, deployment, record['vmid']) coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS) if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k)}: raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile')) - for plan in (deployment, desired): - security = plan.get('security', {}) - if (security.get('unprivileged') is not True - or security.get('options') or plan.get('host_monitor') - or plan.get('resources', {}).get('rlimits')): - raise ValueError(translate('Updates are not available yet in this beta for applications that use ' - 'a privileged container or advanced LXC settings')) + unprivileged = deployment.get('security', {}).get('unprivileged', True) is not False + if (desired.get('security', {}).get('unprivileged', True) is not False) != unprivileged \ + or desired.get('host_monitor') != deployment.get('host_monitor'): + # The owners of the data belong to one mode, and a monitor of the + # host has no network of its own to keep. + raise ValueError(translate('Changing between a privileged and an unprivileged container, or the host monitor mode, requires installing the application again')) + for template, plan in ((record['template'], deployment), (candidate['template'], desired)): runtime_settings.sysctl_content(plan) runtime_settings.tmpfs_lines(plan) gpu_devices.planned(plan) paths = [] + # What the recipe of a host monitor mounts of the host, such as /sys + # and a folder inside it, is read only and laid out by the recipe. + declared = declared_sources({'template': template, 'deployment': plan}) for mount in plan.get('mounts', []): target = host_mounts.valid_path(mount['container_path']) + if mount['type'] == 'host-bind' and mount['source'] in declared and mount.get('read_only'): + continue if (any(target == p or target.startswith(p.rstrip('/') + '/') or p.startswith(target.rstrip('/') + '/') for p in paths)): raise ValueError(translate('Mount paths must not overlap')) @@ -465,15 +520,18 @@ def preflight(record, candidate, config, coordinated=None): else: raise ValueError(translate('Unsupported mount type')) paths.append(target) - if cfg.get('unprivileged') != '1' or ':' not in cfg.get('rootfs', ''): - raise ValueError(translate('A managed rootfs and an unprivileged container are required')) + if cfg.get('unprivileged', '0') != ('1' if unprivileged else '0') or ':' not in cfg.get('rootfs', ''): + raise ValueError(translate('The container disks do not match the saved record')) if desired['rootfs'] != deployment['rootfs']: raise ValueError(translate('Changing the rootfs or its storage requires a separate migration')) actual = mounts(config) gpu_devices.check(config, deployment) gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment) - old = {m['container_path']: m for m in deployment.get('mounts', [])} - new = {m['container_path']: m for m in desired.get('mounts', [])} + recorded_files = {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)} + if files != recorded_files: + raise ValueError(translate('A container mount has a source, backup or permission different from the saved record')) + old = {m['container_path']: m for m in deployment.get('mounts', []) if not is_file_bind(m, files)} + new = {m['container_path']: m for m in desired.get('mounts', []) if not is_file_bind(m, files)} if set(actual) != set(old): raise ValueError(translate('The container disks do not match the saved record')) for target, mount in actual.items(): @@ -500,24 +558,31 @@ def preflight(record, candidate, config, coordinated=None): check = effective_healthcheck(candidate['template']) if not check and not coordinated: raise ValueError(translate('Updates are not available yet for this application in this beta')) - mac = next((item[7:] for item in cfg['net0'].split(',') if item.startswith('hwaddr=')), None) + if deployment.get('host_monitor') and 'net0' not in cfg: + return cfg, actual, None + mac = next((item[7:] for item in cfg.get('net0', '').split(',') if item.startswith('hwaddr=')), None) if not mac: raise ValueError(translate('The MAC address of the container cannot be kept')) return cfg, actual, mac -def freeze_host_sources(record, candidate, acknowledge_external_data): - old = {m['source'] for m in record['deployment'].get('mounts', []) if m['type'] == 'host-bind'} - wanted = {m['source']: m for m in candidate['deployment'].get('mounts', []) if m['type'] == 'host-bind'} - if (old or wanted) and not acknowledge_external_data: +def freeze_host_sources(record, candidate, acknowledge_external_data, files=()): + # A single file mounted from the host holds no data of the application: + # the installer mounts it again, and nothing of it is pinned. + old = {m['source'] for m in record['deployment'].get('mounts', []) + if m['type'] == 'host-bind' and not is_file_bind(m, files)} + wanted = {m['source']: m for m in candidate['deployment'].get('mounts', []) + if m['type'] == 'host-bind' and not is_file_bind(m, files)} + declared = declared_sources(record) | declared_sources(candidate) + if (old | set(wanted)) - declared and not acknowledge_external_data: raise ValueError(translate('Host data is not restored by the backup; confirm it with --acknowledge-external-data')) baseline = record['observed'].get('host_bind_sources', {}) - original = {p: host_mounts.validate_source(p) for p in old} + original = {p: host_mounts.validate_source(p, declared=p in declared) for p in old} for source, previous in baseline.items(): if source in original and not host_mounts.same_source(previous, original[source]): raise ValueError(translate('A shared source does not match its recorded identity')) desired = {p: original[p] if p in original else - host_mounts.validate_source(p, allow_missing=m.get('create_if_missing') is True) + host_mounts.validate_source(p, allow_missing=m.get('create_if_missing') is True, declared=p in declared) for p, m in wanted.items()} if old or wanted: log('host directories: not included in the backup and not reverted by a recovery') @@ -529,7 +594,10 @@ def freeze_host_sources(record, candidate, acknowledge_external_data): def check_runtime_mounts(config, deployment): runtime_settings.check(config, deployment, deployment['vmid']) actual = mounts(config) - declared = {m['container_path']: m for m in deployment.get('mounts', [])} + files = runtime_settings.file_binds(config) + if files != {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)}: + raise ValueError(translate('The mounts of the new container do not match the proposal')) + declared = {m['container_path']: m for m in deployment.get('mounts', []) if not is_file_bind(m, files)} if actual.keys() != declared.keys(): raise ValueError(translate('The mounts of the new container do not match the proposal')) for target, value in actual.items(): @@ -548,7 +616,7 @@ def pin_host_source(root, vmid, journal, source): or record['installation_id'] != state['record']['installation_id'] or record['status'] != 'updating'): raise ValueError(translate('Mount not authorized by the operation')) expected = state['desired_host_sources'][source] - current = host_mounts.validate_source(source) + current = host_mounts.validate_source(source, declared=expected.get('declared', False)) if ((expected['exists'] and not host_mounts.same_source(expected, current)) or current['resolved_path'] != expected['resolved_path']): raise ValueError(translate('The host directory changed before it was mounted')) @@ -582,10 +650,48 @@ def stop(vmid): raise ValueError(translate('The container did not stop; its disks are not touched')) +def own_addresses(vmid): + """The IPv4 addresses of the interfaces Proxmox gave the container. One + that runs Docker inside also has the addresses of its inner bridges.""" + names = re.findall(r'^net[0-9]+: (?:.*,)?name=([A-Za-z0-9_.-]+)', run('pct', 'config', str(vmid)).decode(), re.M) + found = [] + for name in names: + try: + listed = run('lxc-attach', '-n', str(vmid), '-s', 'NETWORK', '--', + 'ip', '-4', '-o', 'addr', 'show', 'dev', name, 'scope', 'global').decode() + except RuntimeError: + continue + found += re.findall(r'\binet ([0-9.]+)/', listed) + return found + + +def haos_healthcheck(vmid, timeout): + import haos_healthcheck as haos + haos.note = log + started, ip = time.monotonic(), None + while ip is None and time.monotonic() - started < 60: + if run('pct', 'status', str(vmid)).strip() != b'status: running': + raise ValueError(translate('The restored service stopped; the recovery is not confirmed')) + ip = next(iter(own_addresses(vmid)), None) + if ip is None: + time.sleep(2) + try: + if ip is None: + raise RuntimeError('no address') + url = haos.wait_ready(vmid, ip, timeout)[0]['url'] + except RuntimeError as error: + log(f'restored Home Assistant OS not confirmed: {error}') + raise ValueError(translate('The restored service did not pass its health check')) + log(f'restored service responding: {url}') + return url + + def healthcheck(vmid, template): check = effective_healthcheck(template) if not check: return None + if check.get('type') == 'haos': + return haos_healthcheck(vmid, int(check['timeout_seconds'])) if check.get('type') == 'running': stability = int(check.get('stability_seconds', 20)) started = time.monotonic() @@ -603,10 +709,12 @@ def healthcheck(vmid, template): or not 0 <= stability < timeout <= 3600): raise ValueError(translate('Invalid health check')) started, stable_since = time.monotonic(), None + # A monitor of the host answers on the network of the host itself. + shared = parse_config(run('pct', 'config', str(vmid))).get('lxc.net.0.type') == 'none' while time.monotonic() - started < timeout: if run('pct', 'status', str(vmid)).strip() != b'status: running': raise ValueError(translate('The restored service stopped; the recovery is not confirmed')) - addresses = run('lxc-info', '-n', str(vmid), '-iH').decode().splitlines() + addresses = ['127.0.0.1'] if shared else run('lxc-info', '-n', str(vmid), '-iH').decode().splitlines() ip = next((a for a in addresses if re.fullmatch(r'[0-9]+(?:\.[0-9]+){3}', a)), None) ok = False if ip: @@ -820,16 +928,6 @@ def backup_size(vmid): return total -def require_backup_space(directory, vmids): - """The data in use on the backed-up volumes, plus a margin, must fit in - `directory`; data that is already compressed does not shrink.""" - needed = int(sum(backup_size(vmid) for vmid in vmids) * 1.1) + 1024**3 - free = shutil.disk_usage(directory).free - if free < needed: - raise ValueError(f"{translate('Not enough free space for the backup')} " - f"({translate('needed')}: {gib(needed)}, {translate('free')}: {gib(free)}, {directory})") - - def prune_backups(root, vmid): """The backups of closed operations are removed once the container works with its new image; their journal and log stay.""" @@ -843,9 +941,7 @@ def prune_backups(root, vmid): continue if phase not in ('committed', 'rolled-back'): continue - for backup in (directory / 'backup').glob('vzdump-lxc-*'): - if backup.is_file() and not backup.is_symlink(): - backup.unlink() + work_backup.clear(directory) def check_archive(archive): @@ -892,7 +988,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i before = run('pct', 'config', str(vmid)) cfg, actual, mac = preflight(record, candidate, before, coordinated) description = original_description({'record': record, 'before_config': before.decode()}) - original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data) + original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data, + runtime_settings.file_binds(before)) original_gpu = gpu_devices.planned(record['deployment']) desired_gpu = gpu_devices.planned(candidate['deployment']) resources = json.loads(run('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json')) @@ -912,10 +1009,17 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i for p in set(image['defaults'].get('Volumes') or {}) - non_persistent_image_volumes(candidate['template'])): raise ValueError(translate('The new image requires additional persistent paths; use Modify')) directory = instances.location(root, vmid).parent / 'transactions' / uuid.uuid4().hex + # Where the backup goes is settled, and checked for room, before anything + # is stopped. + work = directory if coordinated else work_backup.locate(directory, [vmid], record, vmid) private_directory(directory) open_log(directory) - if not coordinated: - require_backup_space(directory, [vmid]) + if work != directory: + work_backup.prepare(work) + work_backup.remember(directory, work) + if show: + msg_info2(f"{translate('This backup does not fit on the system disk of the host; it is made on the storage:')} " + f"{record['deployment'].get(work_backup.KEY)}") journal = directory / 'transaction.json' runtime_deployment = copy.deepcopy(candidate['deployment']) runtime_deployment['network']['mac_address'] = mac @@ -970,7 +1074,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i run('zstd', '-t', backup['archive']) state.update(backup=backup['archive'], backup_sha256=backup['sha256']) else: - backup_dir = directory / 'backup' + backup_dir = work / 'backup' private_directory(backup_dir) backup = verified_backup(vmid, backup_dir, backup_compression, translate('The backup could not be identified; the image is not replaced'), show) @@ -1195,6 +1299,8 @@ def recover(root, journal): if show: msg_ok(translate('Recovery completed. The container had not been modified yet.')) return + if not Path(backup).is_file(): + raise ValueError(f"{translate('The backup of this operation cannot be read. If it is on another storage, make it available and recover the operation again:')} {backup}") if filehash(backup) != state['backup_sha256']: raise ValueError(translate('The backup was altered; recovery blocked')) run('gzip' if state.get('backup_compression') == 'gzip' else 'zstd', '-t', backup) diff --git a/oci/remote/oci_remove.py b/oci/remote/oci_remove.py index 2f76c838..e734ced8 100644 --- a/oci/remote/oci_remove.py +++ b/oci/remote/oci_remove.py @@ -17,6 +17,7 @@ import subprocess import sys import oci_image_cache as image_cache +import oci_work_backup as work_backup import oci_instances as instances from oci_installation_state import parse_config import oci_console @@ -119,9 +120,11 @@ def _unlink(path): def remove_host_state(vmid): """What the installation kept on the host for a container that is gone: - its sysctl include, the Rclone mount hookscript and the views it - published, and its registration in the App tab of ProxMenux Monitor.""" - for include in (runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid)): + its sysctl include, its seccomp profile, the Rclone mount hookscript and + the views it published, and its registration in the App tab of ProxMenux + Monitor.""" + for include in (runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid), + runtime_settings.seccomp_path(vmid)): _unlink(include) hook = SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh' if hook.is_file() and not hook.is_symlink(): @@ -179,7 +182,7 @@ def release_shared_host_files(hookscripts): def _leftovers(vmid): """Whether anything of the container is still on the host.""" paths = [runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid), - SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh', MONITOR_APPS / f'{int(vmid)}.json', + runtime_settings.seccomp_path(vmid), SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh', MONITOR_APPS / f'{int(vmid)}.json', CLUSTER_RECORDS / f'{int(vmid)}.json', *oci_console.LOG_DIR.glob(f'{int(vmid)}.console.log*')] return any(path.exists() for path in paths) @@ -193,6 +196,7 @@ def sweep_orphans(root): found = {int(d.name) for d in root.iterdir() if d.name.isdecimal()} if root.is_dir() else set() for directory, pattern in ((runtime_settings.include_path(0).parent, r'([0-9]+)\.sysctls'), (runtime_settings.legacy_include_path(0).parent, r'([0-9]+)\.proxmenux-sysctls'), + (runtime_settings.seccomp_path(0).parent, r'([0-9]+)\.proxmenux-seccomp'), (oci_console.LOG_DIR, r'([0-9]+)\.console\.log.*'), (SNIPPETS, r'proxmenux-rclone-([0-9]+)-fuse-hook\.sh'), (CLUSTER_RECORDS, r'([0-9]+)\.json')): @@ -347,6 +351,7 @@ def remove(root, vmid): lifecycle.unlink() for member in members: directory = instances.location(root, member).parent + work_backup.forget(root, member) if directory.is_dir() and not directory.is_symlink(): shutil.rmtree(directory) msg_ok(translate('Saved record removed')) diff --git a/oci/remote/oci_runtime_settings.py b/oci/remote/oci_runtime_settings.py index caef7037..94433f07 100644 --- a/oci/remote/oci_runtime_settings.py +++ b/oci/remote/oci_runtime_settings.py @@ -18,6 +18,9 @@ def sysctl_content(deployment): raise ValueError(translate('Invalid or duplicated network sysctl')) seen.add(name) result.append(f'lxc.sysctl.{name} = {value}\n') + # Proxmox does not take this setting in the configuration of a container. + if (deployment.get('security', {}).get('options') or {}).get('no_new_privileges'): + result.append('lxc.no_new_privs = 1\n') return ''.join(result) @@ -58,6 +61,28 @@ def legacy_include_path(vmid): return Path(f'/etc/pve/lxc/{int(vmid)}.proxmenux-sysctls') +# A host monitor shares the process and network namespaces of the host. Proxmox +# takes those two settings from an include, the same for every monitor. +HOST_MONITOR_INCLUDES = (CLUSTER_DIR / 'host-monitor', Path('/etc/pve/lxc/proxmenux-host-monitor')) +HOST_MONITOR_CONTENT = 'lxc.namespace.share.pid = 1\nlxc.namespace.share.net = 1\n' + + +def host_monitor_include(includes): + """Whether the includes are the one of a host monitor, unchanged.""" + if len(includes) != 1 or Path(includes[0]) not in HOST_MONITOR_INCLUDES: + return False + try: + return Path(includes[0]).read_text() == HOST_MONITOR_CONTENT + except OSError: + return False + + +def seccomp_path(vmid): + """The seccomp profile the installer writes for a container that asks for + it relaxed.""" + return Path(f'/etc/pve/lxc/{int(vmid)}.proxmenux-seccomp') + + def check(config, deployment, vmid): expected = tmpfs_lines(deployment) lines = config.decode().splitlines() @@ -65,6 +90,10 @@ def check(config, deployment, vmid): if sorted(actual) != sorted(expected): raise ValueError(translate('The tmpfs mounts of the container differ from the saved record')) includes = [line.split(': ',1)[1] for line in lines if line.startswith('lxc.include: ')] + if deployment.get('host_monitor'): + if not host_monitor_include(includes): + raise ValueError(translate('The sysctl include is unknown or differs from the saved record')) + return content = sysctl_content(deployment) allowed = [[str(include_path(vmid))], [str(legacy_include_path(vmid))]] if content else [[]] if includes not in allowed: @@ -78,12 +107,34 @@ def check(config, deployment, vmid): raise ValueError(translate('The sysctl content was modified outside the saved record')) +FILE_BIND = re.compile(r'(/\S+) \S+ none bind,create=file(,ro)? 0 0') + + +def file_binds(config): + """The host files mounted in the container: {source: read only}. Proxmox + takes a directory as a mount point and a single file as a raw LXC entry, + so these are not among the mount points.""" + found = {} + for line in config.decode().splitlines(): + match = FILE_BIND.fullmatch(line.split(': ', 1)[1]) if line.startswith('lxc.mount.entry: ') else None + if match: + found[match.group(1)] = bool(match.group(2)) + return found + + def filter_config(config, deployment): + """The configuration without the entries of the mounts the deployment + declares itself: what is left belongs to the acceleration profile.""" declared = set(tmpfs_lines(deployment)) + files = {m['source'] for m in deployment.get('mounts', []) if m.get('type') == 'host-bind'} + + def own(line): + entry = line.decode().strip().split(': ', 1)[1] + match = FILE_BIND.fullmatch(entry) + return entry in declared or bool(match and match.group(1) in files) return b''.join(line for line in config.splitlines(keepends=True) if not line.startswith(b'lxc.include: ') and not ( - line.startswith(b'lxc.mount.entry: ') - and line.decode().strip().split(': ',1)[1] in declared)) + line.startswith(b'lxc.mount.entry: ') and own(line))) def check_recovery(config, state): @@ -94,6 +145,8 @@ def check_recovery(config, state): raise ValueError(translate('A tmpfs mount is not part of the journal; recovery blocked')) if line.startswith('lxc.include: '): path = Path(line.split(': ', 1)[1]) + if any(plan.get('host_monitor') for plan in plans) and host_monitor_include([str(path)]): + continue if path not in (include_path(state['vmid']), legacy_include_path(state['vmid'])): raise ValueError(translate('An include is not part of the journal; recovery blocked')) contents = {sysctl_content(plan) for plan in plans} - {''} diff --git a/oci/remote/oci_stack_native.py b/oci/remote/oci_stack_native.py index 133b8621..b679ee74 100644 --- a/oci/remote/oci_stack_native.py +++ b/oci/remote/oci_stack_native.py @@ -17,6 +17,7 @@ import oci_instance_transaction as member_tx import oci_stack_plan import oci_stack_transaction as stack_tx import oci_stack_replay as replay +import oci_work_backup as work_backup from oci_installation_state import image_from_archive, parse_config, private_directory, sha from oci_update_current import resolve_archive from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error @@ -251,7 +252,17 @@ class NativeAdapter: for s in self.services.values() if s['vmid'] != primary_id and not s.get('deferred_setup')] if spec.get('schema') != 1 or spec.get('dependencies') != expected: raise ValueError(translate('The dependency hook and the stack recipe differ')) - member_tx.require_backup_space(self.journal.parent, list(self.records)) + self.work_base() + + def work_base(self): + """Where the backups of this operation go. It is decided, and checked + for room, until the first backup exists; afterwards it is read back, + so the space the backups themselves take is not asked for again.""" + directory = self.journal.parent + if (directory / work_backup.MARK).exists() or any(directory.glob('backup-*')): + return work_backup.recall(directory) + primary = self.plan['primary_vmid'] + return work_backup.locate(directory, list(self.records), self.records[primary], primary) def is_running(self, vmid): return instances.command('pct', 'status', str(vmid)).strip() == b'status: running' @@ -331,7 +342,13 @@ class NativeAdapter: def backup(self, vmid, identity): self.validate(self.plan) - directory = self.journal.parent / ('backup-%s' % vmid) + base = self.work_base() + if base != self.journal.parent and not (self.journal.parent / work_backup.MARK).exists(): + work_backup.prepare(base) + work_backup.remember(self.journal.parent, base) + msg_warn(f"{translate('This backup does not fit on the system disk of the host; it is made on the storage:')} " + f"{self.records[self.plan['primary_vmid']]['deployment'].get(work_backup.KEY)}") + directory = base / ('backup-%s' % vmid) private_directory(directory) archive = member_tx.verified_backup(vmid, directory, 'zstd', translate('The backup of a member could not be identified')) @@ -339,6 +356,8 @@ class NativeAdapter: def verify_backups(self, backups): for backup in backups.values(): + if not Path(backup['archive']).is_file(): + raise ValueError(f"{translate('The backup of this operation cannot be read. If it is on another storage, make it available and recover the operation again:')} {backup['archive']}") if member_tx.filehash(backup['archive']) != backup['sha256']: raise ValueError(translate('A backup was modified')) member_tx.run('zstd', '-t', backup['archive']) @@ -579,7 +598,7 @@ class NativeAdapter: self.release_stages(recovered=state['phase'] == 'rolled-back') if self.keep_backup and state['phase'] == 'committed': import oci_keep_backup - for backup in sorted(self.journal.parent.glob('backup-*/vzdump-lxc-*.tar.zst')): + for backup in sorted(work_backup.recall(self.journal.parent).glob('backup-*/vzdump-lxc-*.tar.zst')): kept = oci_keep_backup.keep(backup, self.keep_backup) if kept: msg_ok(f"{translate('Backup kept in')} {self.keep_backup}: {Path(kept).name}") @@ -621,9 +640,7 @@ class NativeAdapter: except (OSError, ValueError): continue if phase in stack_tx.TERMINAL: - for backup in directory.glob('backup-*/vzdump-lxc-*'): - if backup.is_file() and not backup.is_symlink(): - backup.unlink() + work_backup.clear(directory) # The stack journal of this run, for the summary after a failure. diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py index 11c50fa4..a3761e40 100644 --- a/oci/remote/oci_update_current.py +++ b/oci/remote/oci_update_current.py @@ -12,6 +12,7 @@ import tempfile import oci_instances as instances import oci_instance_transaction as transaction +import oci_work_backup as work_backup from oci_installation_state import image_from_archive, same_image from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2 @@ -102,6 +103,12 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N return archive, digest msg_warn(translate('The cached image is damaged; it will be downloaded again.')) archive.unlink() + # The compressed layers are what the download writes; nothing was changed yet. + size, free = int(candidate.get('size') or 0), work_backup.available(archive.parent) + if size and free < int(size * 1.1) + 256 * 1024**2: + raise ValueError(f"{translate('The new image does not fit on the storage where images are downloaded:')} {storage}. " + f"{translate('Needed:')} {work_backup.gib(int(size * 1.1) + 256 * 1024**2)}. " + f"{translate('Free:')} {work_backup.gib(free)}. {translate('The application was not modified.')}") # A download can come back complete yet damaged when the connection drops # and the transfer resumes; the integrity check catches it, and a second # download is what repairs it. @@ -170,8 +177,8 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non transaction.preflight(record, desired, config) msg_ok(translate('Container checked')) current = record['observed']['image'] if operation == 'update' else None - archive, digest = resolve_archive(desired, config, current, lambda: transaction.require_backup_space( - instances.location(instances.ROOT, vmid).parent, [vmid])) + archive, digest = resolve_archive(desired, config, current, lambda: work_backup.locate( + instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid)) if archive is None: msg_ok(translate('The image is already up to date; nothing was changed.')) return diff --git a/oci/remote/oci_work_backup.py b/oci/remote/oci_work_backup.py new file mode 100644 index 00000000..b37a2c26 --- /dev/null +++ b/oci/remote/oci_work_backup.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Where the backup taken before changing an OCI application is written. + +An update, a modification or a recreation stops the application and takes a +verified backup first, to go back to if the new container does not work; it +is deleted when the operation ends. That backup goes next to the record of +the application, on the system disk of the host. An application with a lot +of data may not fit there: the user can then choose a file storage of the +host that accepts backups, and the choice is kept in the record so later +operations, the scheduled ones included, use it without asking. +""" +from __future__ import annotations + +import json +from pathlib import Path +import re +import shutil +import socket +import subprocess + +import oci_instances as instances +from oci_ui import translate + +FOLDER = 'proxmenux-oci-work' +KEY = 'work_storage' +MARK = 'backup-location' +STORAGE_RE = re.compile(r'[A-Za-z0-9._-]{1,64}') + + +def gib(size): + return f'{size / 1024**3:.1f} GB' + + +def required(vmids): + """The data in use on the backed-up volumes plus a margin: data that is + already compressed does not shrink.""" + import oci_instance_transaction as transaction + return int(sum(transaction.backup_size(vmid) for vmid in vmids) * 1.1) + 1024**3 + + +def available(path): + """Free bytes where `path` is or will be created.""" + path = Path(path) + while not path.exists() and path != path.parent: + path = path.parent + return shutil.disk_usage(path).free + + +def file_storages(): + """The storages of this node that accept backups and are a directory of + the host, with their free space: [{'storage', 'path', 'free'}].""" + node = socket.gethostname().split('.', 1)[0] + listed = subprocess.run(['pvesh', 'get', f'/nodes/{node}/storage', '--content', 'backup', '--enabled', '1', + '--output-format', 'json'], capture_output=True, text=True, timeout=60) + if listed.returncode != 0: + return [] + found = [] + for entry in json.loads(listed.stdout): + name = str(entry.get('storage') or '') + if not entry.get('active') or not STORAGE_RE.fullmatch(name): + continue + path = storage_path(name) + if path is not None and path.is_dir(): + found.append({'storage': name, 'path': path, 'free': available(path)}) + return found + + +def storage_path(storage): + """The directory of a file storage that accepts backups, or None.""" + if not STORAGE_RE.fullmatch(storage or ''): + return None + result = subprocess.run(['pvesh', 'get', f'/storage/{storage}', '--output-format', 'json'], + capture_output=True, text=True, timeout=30) + if result.returncode != 0: + return None + info = json.loads(result.stdout) + if 'backup' not in str(info.get('content', '')).split(',') or not info.get('path'): + return None + return Path(info['path']) + + +def explain(needed, free, storage=None, storage_free=None): + text = (f"{translate('The backup made before changing the application does not fit on the system disk of the host.')} " + f"{translate('Needed:')} {gib(needed)}. {translate('Free:')} {gib(free)}.") + if storage: + text += (f" {translate('The storage chosen for this backup has no room either:')} {storage}" + + (f" ({gib(storage_free)})." if storage_free is not None else '.')) + return (f"{text} {translate('The application was not modified.')} " + f"{translate('Free space on the host, or choose another storage for this backup from Manage installed OCI applications.')}") + + +def locate(default, vmids, record, owner): + """The directory the backups of one operation are written under: `default` + when they fit there, otherwise a folder of its own on the storage chosen + for the application. Nothing is created. Raises ValueError with what is + needed when there is no room.""" + default = Path(default) + needed = required(vmids) + free = available(default) + if free >= needed: + return default + storage = (record.get('deployment') or {}).get(KEY) + path = storage_path(storage) if storage else None + if path is None or not path.is_dir(): + raise ValueError(explain(needed, free)) + storage_free = available(path) + if storage_free < needed: + raise ValueError(explain(needed, free, storage, storage_free)) + return path / FOLDER / str(int(owner)) / default.name + + +def prepare(base): + """Create the folder of an operation on another storage and make sure it + can be written, before anything is stopped.""" + base = Path(base) + try: + base.mkdir(parents=True, exist_ok=True, mode=0o700) + probe = base / '.write-test' + probe.write_text('') + probe.unlink() + except OSError as error: + raise ValueError(f"{translate('The storage chosen for this backup cannot be written:')} {base} ({error.strerror or error})") + + +def remember(directory, base): + """Leave in the folder of the operation where its backups are, when they + are not in that folder: a recovery or a cleanup reads it from there.""" + directory, base = Path(directory), Path(base) + if base != directory: + (directory / MARK).write_text(str(base) + '\n') + + +def recall(directory): + """Where the backups of the operation kept in `directory` are.""" + directory = Path(directory) + try: + text = (directory / MARK).read_text().strip() + except OSError: + return directory + base = Path(text) + return base if base.is_absolute() and FOLDER in base.parts else directory + + +def clear(directory): + """Delete the backups of a closed operation, and its folder on another + storage once it is empty. The journal and the log of the operation stay.""" + directory = Path(directory) + base = recall(directory) + if base != directory and not base.exists(): + return + for backup in list(base.glob('backup/vzdump-lxc-*')) + list(base.glob('backup-*/vzdump-lxc-*')): + if backup.is_file() and not backup.is_symlink(): + backup.unlink() + if base == directory: + return + for folder in sorted(base.glob('backup*'), reverse=True): + _remove_empty(folder) + # The folder of the operation and the one of the application, never the + # one shared by every application nor the storage itself. + for folder in (base, base.parent): + if folder.parent.name == FOLDER or folder.parent.parent.name == FOLDER: + _remove_empty(folder) + + +def _remove_empty(folder): + try: + if folder.is_dir() and not folder.is_symlink(): + folder.rmdir() + except OSError: + pass + + +def forget(root, vmid): + """Before the record of an application is deleted: remove what its + operations left on another storage.""" + transactions = instances.location(root, vmid).parent / 'transactions' + if not transactions.is_dir(): + return + for directory in transactions.iterdir(): + base = recall(directory) + if base != directory and base.exists() and FOLDER in base.parts: + shutil.rmtree(base, ignore_errors=True) + _remove_empty(base.parent) + + +def application(root, vmid): + """The containers of the application `vmid` belongs to.""" + record = instances.read(root, vmid) + primary_id = int((record.get('stack_member') or {}).get('primary_vmid') or vmid) + primary = record if primary_id == vmid else instances.read(root, primary_id) + members = [int(member['vmid']) for member in (primary.get('stack') or {}).get('members') or []] + return primary_id, members or [vmid] + + +def status(root, vmid): + """What the menu needs to know before an operation: whether the backup + fits on the system disk, the storage chosen earlier and the ones that + have room.""" + primary_id, vmids = application(root, vmid) + record = instances.read(root, primary_id) + default = instances.location(root, primary_id).parent + needed, free = required(vmids), available(default) + saved = (record.get('deployment') or {}).get(KEY) + path = storage_path(saved) if saved else None + saved_free = available(path) if path is not None and path.is_dir() else None + return {'needed': needed, 'free': free, 'fits': free >= needed, 'saved': saved, + 'saved_fits': saved_free is not None and saved_free >= needed, + 'candidates': [{'storage': item['storage'], 'free': item['free']} + for item in file_storages() if item['free'] >= needed]} + + +def choose(root, vmid, storage): + """Keep the storage chosen for the backups of an application in the + record of each of its containers. None forgets it.""" + import oci_carried_record + if storage is not None and storage_path(storage) is None: + raise ValueError(f"{translate('The storage does not accept backups:')} {storage}") + with instances.locked(root): + _, vmids = application(root, vmid) + for member in vmids: + record = instances.read(root, member) + deployment = record.setdefault('deployment', {}) + if storage is None: + deployment.pop(KEY, None) + else: + deployment[KEY] = storage + instances.write(instances.location(root, member), record) + oci_carried_record.carry(root, member, mount_stopped=False) + return vmids diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index fca048a9..214b9396 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -137,6 +137,62 @@ def _answers_from_recipe(ui) -> bool: return isinstance(ui, DefaultsUI) +SHARED_DIRECTORY_NAME = re.compile(r"[a-z][a-z0-9_-]{0,31}") + + +def ask_shared_directories(ui, template: dict[str, Any], mounts: list[dict[str, Any]], storage: str, + advanced: bool, optional: bool = False) -> list[dict[str, Any]]: + """The directories an application exists to share. Its profile declares one + container directory, and each directory the user names is mounted below + it, on a container volume or from a directory of the host. A new + installation asks for the first one and then for more until the user says + no; `optional` is for an installed application, which may add none.""" + profile = template["proxmox"]["installer_profile"] + declared = profile["shared_directories"] + root = declared["container_root"].rstrip("/") + reserved = set(declared.get("reserved_names", [])) + taken = {mount["container_path"] for mount in mounts} + app_id = template["id"].removeprefix("image-") + labels = [("managed-volume", translate("Container volume (included in backups)")), + ("host-bind", translate("Host directory (not included in Proxmox backups)"))] + added: list[dict[str, Any]] = [] + if optional and not ui.confirm(translate("Add a directory to share"), False): + return added + while True: + name = str(ui.ask(translate("Name of the directory to share (lowercase letters, digits, - or _)"))).strip() + target = f"{root}/{name}" + if not SHARED_DIRECTORY_NAME.fullmatch(name) or name in reserved: + raise ValueError(translate("The name must start with a lowercase letter and use only lowercase " + "letters, digits, - or _")) + if target in taken: + raise ValueError(f"{translate('This directory is already shared:')} {target}") + location = ui.choose(f"{translate('Where to store')} {target}", labels, "managed-volume") + if location is None: + raise UserCancelled(translate("Volume configuration cancelled")) + mount = {"type": location, "container_path": target, "custom": True, "source": storage, + "size_gb": None, "backup": location == "managed-volume", "read_only": False, + "create_if_missing": location == "host-bind"} + if location == "managed-volume": + if advanced: + mount["source"] = ask_storage(ui, f"{translate('Storage for')} {target}", "rootdir", storage) + mount["size_gb"] = _ask_size(ui, target, int(declared.get("default_size_gb", 32))) + # The application writes to it as its own user, not as root. + preparation = {"container_path": target, "remove_lost_found": True, + "owner_strategy": "mapped-application-user", + "only_when_mount_type": "managed-volume"} + if preparation not in profile.setdefault("volume_preparations", []): + profile["volume_preparations"].append(preparation) + else: + mount["source"] = ui.ask(f"{translate('Host path for')} {target}", + f"/mnt/oci-shared/{app_id}/{name}") + from .custom_mounts import validate_mount + mount["container_path"] = validate_mount(mount, [*mounts, *added]) + added.append(mount) + taken.add(target) + if not ui.confirm(translate("Add another directory to share?"), False): + return added + + def ask_application_extra_paths(ui, existing: list[str], storage: str) -> list[dict[str, Any]]: """Extra paths for the application container of a multi-container application. An advanced installation asks them; a default one does not.""" @@ -327,6 +383,9 @@ def build_deployment( if enabled: selected_security_options.update(item.get("options", {})) security_relaxation_acknowledged = True + if item.get("privileged"): + unprivileged = False + privileged_acknowledged = True runtime_profile = installer_profile.get("runtime", {}) hostname_default = _hostname_default(runtime_profile.get("hostname") or template["container_contract"]["container_name"]) @@ -425,6 +484,9 @@ def build_deployment( } ) + if installer_profile.get("shared_directories"): + mounts += ask_shared_directories(ui, template, mounts, volume_storage, advanced) + if advanced: mounts = ask_custom_mounts(ui, mounts, volume_storage) @@ -522,6 +584,9 @@ def build_deployment( value = secrets.token_hex(int(generated.get("bytes", 16))) if not value and item["required"]: raise InstallError(f"{name}: {translate('a value is required')}") + minimum = installer_profile.get("minimum_secret_length", {}).get(name) + if minimum and len(value) < int(minimum): + raise InstallError(f"{name}: {translate('the minimum number of characters is')} {minimum}") elif not advanced and default not in (None, ""): value = default else: diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index fe2c94a4..e19ab09c 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -286,6 +286,44 @@ def set_watchdog(project, vmids, enabled): return True +def _work_backup_ready(project, ui, vmid): + """Before an operation that backs the application up: when the backup + does not fit on the system disk of the host, the user chooses the storage + where it is made. False when there is nowhere to make it.""" + if getattr(ui, 'unattended', False): + # The engine uses the storage chosen earlier, or says why it stops. + return True + sys.path.insert(0, str(project / 'remote')) + import oci_instances as instances + import oci_work_backup + try: + state = oci_work_backup.status(instances.ROOT, vmid) + except (OSError, ValueError, KeyError, subprocess.SubprocessError): + return True + if state['fits'] or state['saved_fits']: + return True + lacking = (f"{translate('The backup made before changing the application does not fit on the system disk of the host.')} " + f"{translate('Needed:')} {oci_work_backup.gib(state['needed'])}. {translate('Free:')} {oci_work_backup.gib(state['free'])}.") + candidates = sorted(state['candidates'], key=lambda item: item['free'], reverse=True) + if not candidates: + ui.message(f"{lacking}\n\n{translate('No other storage of this host that accepts backups has that much free space. Free space and try again; the application was not modified.')}", + translate('OCI management')) + return False + ui.message(f"{lacking}\n\n{translate('It can be made on another storage of this host. This backup is temporary: it is deleted when the operation ends. The choice is remembered for the next operations of this application.')}", + translate('OCI management')) + options = [(item['storage'], f"{item['storage']} - {oci_work_backup.gib(item['free'])} {translate('free')}") for item in candidates] + selected = ui.choose(translate('Storage for the backup of this operation'), options, options[0][0]) + if selected is None: + return False + try: + oci_work_backup.choose(instances.ROOT, vmid, selected) + except (OSError, ValueError, KeyError): + ui.message(translate('Another OCI operation is using the instance registry. Wait for it to finish and open this menu again; no container is modified.'), + translate('OCI management')) + return False + return True + + def _toggle_watchdog(project, ui, vmid, enabled): """Ask and change the watchdog of an application from its menu.""" question = (translate('This application is under watchdog: it is restarted automatically when it crashes. Turn the watchdog off?') @@ -342,6 +380,9 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): return _toggle_watchdog(project, ui, row['vmid'], watched) if action == 'remove': return _remove(project, ui, row['vmid']) + if not _work_backup_ready(project, ui, row['vmid']): + return False + record = instances.read(instances.ROOT, row['vmid']) import oci_instance_reconcile as reconcile try: current_config = instances.command('pct', 'config', str(row['vmid'])) @@ -565,6 +606,8 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()): return False import json members = json.loads(Path(pending).read_text())['plan']['members'] + if not pending and not _work_backup_ready(project, ui, primary_id): + return False command = [sys.executable, str(project / 'remote/oci_stack_native.py'), str(primary_id)] if pending: command.append('--recover') diff --git a/oci/src/proxmenux_oci/recreation.py b/oci/src/proxmenux_oci/recreation.py index c61ad56b..79ff53fa 100644 --- a/oci/src/proxmenux_oci/recreation.py +++ b/oci/src/proxmenux_oci/recreation.py @@ -111,6 +111,11 @@ def edit_recreation(record, ui): resources = deployment['resources'] resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores']) resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128) + template = candidate.get('template', {}) + if template.get('proxmox', {}).get('installer_profile', {}).get('shared_directories'): + from .installer import ask_shared_directories + deployment['mounts'] += ask_shared_directories( + ui, template, deployment['mounts'], deployment['rootfs']['storage'], True, optional=True) from .custom_mounts import ask_custom_mounts deployment['mounts'] = ask_custom_mounts( ui, deployment['mounts'], deployment['rootfs']['storage']) diff --git a/oci/tests/test_dhcp_lease.py b/oci/tests/test_dhcp_lease.py new file mode 100644 index 00000000..66beb718 --- /dev/null +++ b/oci/tests/test_dhcp_lease.py @@ -0,0 +1,89 @@ +"""The DHCP client of a container that restarted itself is started again.""" +import sys +import tempfile +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) +import oci_dhcp_lease as lease + +NET = 'name=eth0,bridge=vmbr0,firewall=1,host-managed=1,hwaddr=BC:24:11:D5:0F:FD,type=veth' + + +class ManagedInterfaceTests(TestCase): + def test_only_host_managed_interfaces_on_dhcp(self): + config = (f'net0: {NET},ip=dhcp\n' + 'net1: name=eth1,bridge=vmbr10,host-managed=1,ip=10.77.1.30/24,type=veth\n' + 'net2: name=eth2,bridge=vmbr0,ip=dhcp,type=veth\n' + 'net3: name=eth3,bridge=vmbr0,host-managed=1,ip=dhcp,ip6=dhcp,type=veth\n' + 'net4: name=eth4,bridge=vmbr0,host-managed=1,ip=dhcp,link_down=1,type=veth\n') + self.assertEqual(lease.managed(config), [('eth0', 4), ('eth3', 4), ('eth3', 6)]) + + def test_snapshots_and_odd_names_are_ignored(self): + config = ('net0: name=eth0;reboot,bridge=vmbr0,host-managed=1,ip=dhcp\n' + f'[before-update]\nnet1: {NET},ip=dhcp\n') + self.assertEqual(lease.managed(config), []) + + +class RepairTests(TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + (self.root / 'conf').mkdir() + (self.root / 'proc').mkdir() + (self.root / 'conf/121.conf').write_text(f'net0: {NET},ip=dhcp\n') + for item in (patch.object(lease, 'CONFIGS', self.root / 'conf'), patch.object(lease, 'PROC', self.root / 'proc'), + patch.object(lease, 'init_pid', return_value=53014)): + item.start() + self.addCleanup(item.stop) + + def client(self, pid, vmid): + (self.root / f'proc/{pid}').mkdir() + (self.root / f'proc/{pid}/cmdline').write_bytes( + f'/sbin/dhclient\0-1\0-4\0-pf\0/var/lib/lxc/{vmid}/hook/dhclient4-eth0.pid\0eth0\0'.encode()) + + def test_a_missing_client_is_started(self): + self.client(1661, 115) + with patch.object(lease, 'start_client', return_value=True) as start: + self.assertEqual(lease.repair(121), [('eth0', 4)]) + start.assert_called_once_with(121, 'eth0', 4, 53014) + + def test_a_running_client_is_left_alone(self): + self.client(42092, 121) + with patch.object(lease, 'start_client') as start: + self.assertEqual(lease.repair(121), []) + start.assert_not_called() + + def test_a_stopped_or_unknown_container_is_left_alone(self): + with patch.object(lease, 'start_client') as start: + with patch.object(lease, 'init_pid', return_value=None): + self.assertEqual(lease.repair(121), []) + self.assertEqual(lease.repair(999), []) + start.assert_not_called() + + def test_a_container_that_just_started_is_left_to_proxmox(self): + with patch.object(lease, 'start_client', return_value=True) as start: + with patch.object(lease, 'running_for', return_value=5.0): + self.assertEqual(lease.repair(121, settle=lease.SETTLE), []) + with patch.object(lease, 'running_for', return_value=3600.0): + self.assertEqual(lease.repair(121, settle=lease.SETTLE), [('eth0', 4)]) + start.assert_called_once() + + def test_every_container_with_a_managed_interface_is_listed(self): + (self.root / 'conf/109.conf').write_text('net0: name=eth0,bridge=vmbr0,ip=dhcp,type=veth\n') + (self.root / 'conf/100.conf').write_text(f'net0: {NET},ip=dhcp\n') + self.assertEqual(lease.containers(), [100, 121]) + + def test_the_start_hook_queues_the_check(self): + hook = (ROOT / 'remote/oci_console_mark.sh').read_text() + self.assertIn('lease="${0%/*}/oci_dhcp_lease.py"', hook) + self.assertIn('systemd-run --quiet --collect --on-active=20 /usr/bin/python3 "$lease" "$1"', hook) + self.assertTrue(hook.rstrip().endswith('exit 0')) + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/oci/tests/test_generic_stack_notes.py b/oci/tests/test_generic_stack_notes.py new file mode 100644 index 00000000..ab51250f --- /dev/null +++ b/oci/tests/test_generic_stack_notes.py @@ -0,0 +1,67 @@ +"""The notes of a container of a suite or stack carry the address it is reached at.""" + +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "remote")) + +import install_generic_stack as generic +import oci_native_stack + +RECORD = {"installation_id": "22d65764-750c-4aba-b0ef-55f5eb4dda0d", + "template": {"id": "linuxserver-prowlarr", "catalog_ui": {"title": {"en_US": "Prowlarr"}, + "launch": {"scheme": "http", "port": 9696, "path": "/"}}, + "container_contract": {"image": {"reference": "lscr.io/linuxserver/prowlarr:latest"}}}} + + +class GenericStackNotes(unittest.TestCase): + def refresh(self, listed, status="status: running\n"): + calls = [] + + def run(*args, **_): + calls.append([str(arg) for arg in args]) + return status if args[:2] == ("pct", "status") else "" + + def info(command, **_): + if command[0] == "lxc-info": + return subprocess.CompletedProcess(command, 0, stdout=listed, stderr="") + return subprocess.CompletedProcess(command, 0, stdout="net0: name=eth0,bridge=vmbr11,ip=10.77.1.30/24,type=veth\n" + "net1: name=eth1,bridge=vmbr0,ip=dhcp,type=veth\n", stderr="") + with patch.object(generic, "run", side_effect=run), patch.object(generic.oci_instances, "read", return_value=RECORD), \ + patch.object(oci_native_stack.subprocess, "run", side_effect=info), \ + patch.object(oci_native_stack.time, "sleep"): + generic.refresh_notes([{"vmid": 180}]) + written = [call for call in calls if call[:2] == ["pct", "set"]] + self.assertEqual(len(written), 1) + self.assertEqual(written[0][2:4], ["180", "--description"]) + return written[0][4] + + def test_the_local_network_address_is_written_not_the_one_of_the_private_network(self): + notes = self.refresh("10.77.1.30\n192.168.0.78\n") + self.assertIn("http://192.168.0.78:9696/", notes) + self.assertNotIn("10.77.1.30", notes) + + def test_it_does_not_depend_on_the_range_of_the_local_network(self): + notes = self.refresh("10.77.1.30\n10.0.1.141\n") + self.assertIn("http://10.0.1.141:9696/", notes) + self.assertNotIn("10.77.1.30", notes) + + def test_a_stopped_container_gets_its_notes_without_an_address(self): + notes = self.refresh("", status="status: stopped\n") + self.assertNotIn("http://10.", notes) + self.assertIn("Prowlarr OCI", notes) + self.assertIn("proxmenux-instance=22d65764-750c-4aba-b0ef-55f5eb4dda0d", notes) + + def test_a_container_whose_notes_cannot_be_written_does_not_stop_the_installation(self): + with patch.object(generic, "run", side_effect=subprocess.CalledProcessError(1, "pct")), \ + patch.object(generic.oci_instances, "read", return_value=RECORD), patch.object(generic, "log") as log: + generic.refresh_notes([{"vmid": 180}]) + self.assertIn("CT 180", log.call_args.args[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/oci/tests/test_haos_lifecycle.py b/oci/tests/test_haos_lifecycle.py new file mode 100644 index 00000000..0489d7b7 --- /dev/null +++ b/oci/tests/test_haos_lifecycle.py @@ -0,0 +1,95 @@ +"""Home Assistant OS is updated and modified like any other application.""" +import json +import subprocess +import sys +import tempfile +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) +import oci_instance_transaction as transaction + +HAOS = json.loads((ROOT / 'catalog/curated/haos-one.json').read_text()) +CONFIG = (b'net0: name=eth0,bridge=vmbr0,firewall=1,host-managed=1,hwaddr=BC:24:11:D5:0F:FD,ip=dhcp,type=veth\n' + b'ostype: unmanaged\n') +ETH0 = b'2: eth0 inet 192.168.0.100/24 brd 192.168.0.255 scope global eth0\\ valid_lft forever\n' + + +class HaosCheckTests(TestCase): + def test_the_recipe_has_a_check_so_the_operation_is_not_refused(self): + check = transaction.effective_healthcheck(HAOS) + self.assertEqual(check, {'type': 'haos', 'timeout_seconds': 1200}) + + def test_the_check_is_left_to_the_installer_profile(self): + contract = transaction.with_default_healthcheck({'template': json.loads(json.dumps(HAOS))}) + profile = contract['template']['proxmox']['installer_profile'] + self.assertNotIn('startup_healthcheck', profile) + self.assertEqual(profile['haos_healthcheck'], {'timeout_seconds': 1200}) + + def run_commands(self, *args): + if args[:2] == ('pct', 'config'): + return CONFIG + if args[:2] == ('pct', 'status'): + return b'status: running\n' + if args[0] == 'lxc-attach': + return ETH0 + raise AssertionError(args) + + def test_the_container_answers_on_its_own_interface(self): + with patch.object(transaction, 'run', side_effect=self.run_commands): + self.assertEqual(transaction.own_addresses(121), ['192.168.0.100']) + + def test_a_restored_container_passes_the_check_of_home_assistant(self): + import haos_healthcheck + urls = [{'label': 'Home Assistant', 'url': 'http://192.168.0.100:8123/'}] + with patch.object(transaction, 'run', side_effect=self.run_commands), \ + patch.object(transaction, 'log'), \ + patch.object(haos_healthcheck, 'wait_ready', return_value=urls) as wait: + self.assertEqual(transaction.healthcheck(121, HAOS), 'http://192.168.0.100:8123/') + wait.assert_called_once_with(121, '192.168.0.100', 1200) + + def test_a_restored_container_that_does_not_pass_is_not_confirmed(self): + import haos_healthcheck + with patch.object(transaction, 'run', side_effect=self.run_commands), \ + patch.object(transaction, 'log'), \ + patch.object(haos_healthcheck, 'wait_ready', side_effect=RuntimeError('time is up')): + with self.assertRaises(ValueError): + transaction.healthcheck(121, HAOS) + + +class InstallerAddressTests(TestCase): + """The installer takes the address of an interface of the container, not + the one of a bridge Docker created inside it.""" + + def address(self, listed, attach): + script = (ROOT / 'remote/install_oci.sh').read_text() + start = script.index('own_ipv4() {') + function = script[start:script.index('\n}\n', start) + 3] + with tempfile.TemporaryDirectory() as tmp: + for name, body in (('lxc-info', f'printf "{listed}"'), + ('pct', 'echo "net0: name=eth0,bridge=vmbr0,ip=dhcp,type=veth"'), + ('lxc-attach', attach)): + path = Path(tmp) / name + path.write_text(f'#!/bin/bash\n{body}\n') + path.chmod(0o755) + result = subprocess.run(['bash', '-c', f'PATH={tmp}:$PATH; VMID=121; {function}\nown_ipv4; echo "rc=$?"'], + capture_output=True, text=True) + return result.stdout.split() + + def test_the_inner_bridges_are_skipped(self): + attach = 'echo "2: eth0 inet 192.168.0.100/24 brd 192.168.0.255 scope global eth0"' + self.assertEqual(self.address('172.30.232.1\\n172.30.32.1\\n192.168.0.100\\n', attach), ['192.168.0.100', 'rc=0']) + + def test_no_address_yet_is_waited_for(self): + self.assertEqual(self.address('172.30.232.1\\n', 'true'), ['rc=0']) + + def test_interfaces_that_cannot_be_read_leave_it_to_the_caller(self): + self.assertEqual(self.address('192.168.0.100\\n', 'exit 1'), ['rc=1']) + self.assertEqual(self.address('', 'true'), ['rc=1']) + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/oci/tests/test_privileged_lifecycle.py b/oci/tests/test_privileged_lifecycle.py new file mode 100644 index 00000000..e2dc2508 --- /dev/null +++ b/oci/tests/test_privileged_lifecycle.py @@ -0,0 +1,177 @@ +"""A privileged container, relaxed confinement, process limits, a monitor of +the host and a single mounted file are rebuilt like any other application.""" +import json +import sys +import tempfile +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) +import oci_host_mounts as host_mounts +import oci_instance_transaction as transaction +import oci_runtime_settings as runtime_settings + +INSTALLER = (ROOT / 'remote/install_oci.sh').read_text() +GLANCES = json.loads((ROOT / 'catalog/apps/glances.json').read_text()) +MONITOR_CONFIG = (b'unprivileged: 0\n' + b'lxc.apparmor.profile: unconfined\n' + b'lxc.mount.entry: /etc/os-release etc/os-release none bind,create=file,ro 0 0\n' + b'lxc.mount.entry: /srv/app.conf etc/app.conf none bind,create=file 0 0\n' + b'lxc.mount.entry: tmpfs run/app tmpfs rw,size=64M,create=dir 0 0\n' + b'lxc.include: /etc/pve/proxmenux/host-monitor\n' + b'lxc.net.0.type: none\n') + + +def bind(source, target, read_only=True): + return {'type': 'host-bind', 'container_path': target, 'source': source, 'backup': False, 'read_only': read_only} + + +class ProfileKeyTests(TestCase): + def test_each_declared_setting_names_the_entries_the_installer_writes(self): + deployment = {'security': {'options': {'apparmor_profile': 'unconfined', 'seccomp_profile': 'unconfined', + 'drop_all_capabilities': True, 'no_new_privileges': True}}, + 'resources': {'rlimits': [{'name': 'nofile', 'soft': '65536', 'hard': '65536'}]}, + 'host_monitor': 'glances'} + self.assertEqual(transaction.profile_keys(deployment), { + 'lxc.apparmor.profile', 'lxc.seccomp.profile', 'lxc.cap.drop', 'lxc.cap.keep', 'lxc.include', + 'lxc.prlimit.nofile', 'lxc.net.0.type', 'lxc.hook.mount'}) + + def test_a_plain_application_adds_none(self): + self.assertEqual(transaction.profile_keys({'security': {'unprivileged': True, 'options': {}}, + 'resources': {'rlimits': []}}), set()) + + def test_a_host_monitor_is_checked_like_any_other_application(self): + self.assertEqual(transaction.effective_healthcheck(GLANCES)['port'], 61208) + template = {'proxmox': {'installer_profile': {'host_monitor': 'netdata'}}, + 'first_run': {'endpoints': [{'scheme': 'http', 'port': 19999, 'path': '/'}]}} + self.assertEqual(transaction.effective_healthcheck(template)['port'], 19999) + + +class FileBindTests(TestCase): + def test_single_files_are_read_from_the_raw_entries(self): + self.assertEqual(runtime_settings.file_binds(MONITOR_CONFIG), {'/etc/os-release': True, '/srv/app.conf': False}) + + def test_declared_files_are_not_left_to_the_acceleration_check(self): + deployment = {'mounts': [bind('/etc/os-release', '/etc/os-release')], + 'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 64, 'mount_options': ['rw']}]} + left = runtime_settings.filter_config(MONITOR_CONFIG, deployment).decode() + self.assertNotIn('/etc/os-release', left) + self.assertNotIn('tmpfs', left) + self.assertNotIn('lxc.include', left) + # One the deployment does not declare stays, and is refused there. + self.assertIn('/srv/app.conf', left) + + def test_a_file_is_told_apart_from_a_directory(self): + with tempfile.TemporaryDirectory() as tmp: + file = Path(tmp) / 'os-release' + file.write_text('x') + self.assertTrue(transaction.is_file_bind(bind(str(file), '/etc/os-release'))) + self.assertFalse(transaction.is_file_bind(bind(tmp, '/data'))) + self.assertTrue(transaction.is_file_bind(bind('/gone/file', '/x'), {'/gone/file': True})) + self.assertFalse(transaction.is_file_bind({'type': 'managed-volume', 'source': 'local-lvm'})) + + def test_files_are_not_pinned_nor_ask_for_the_host_data_notice(self): + with tempfile.TemporaryDirectory() as tmp: + file = Path(tmp) / 'os-release' + file.write_text('x') + record = {'template': {}, 'observed': {}, + 'deployment': {'mounts': [bind(str(file), '/etc/os-release')]}} + with patch.object(transaction, 'log'): + self.assertEqual(transaction.freeze_host_sources(record, record, False), ({}, {})) + + +class DeclaredSourceTests(TestCase): + def test_only_a_host_monitor_declares_sources(self): + contract = {'template': GLANCES, 'deployment': {'host_monitor': 'glances'}} + self.assertEqual(transaction.declared_sources(contract), {'/etc/os-release'}) + self.assertEqual(transaction.declared_sources({'template': GLANCES, 'deployment': {'host_monitor': None}}), set()) + + def test_a_declared_system_directory_is_accepted_and_stays_declared(self): + with self.assertRaises(ValueError): + host_mounts.validate_source('/usr') + value = host_mounts.validate_source('/usr', declared=True) + self.assertTrue(value['declared']) + host_mounts.verify_sources({'/usr': value}) + with self.assertRaises(ValueError): + host_mounts.verify_sources({'/usr': {k: v for k, v in value.items() if k != 'declared'}}) + + def test_the_system_mounts_of_a_monitor_need_no_notice_about_host_data(self): + record = {'template': {'proxmox': {'installer_profile': {'host_monitor_mounts': [{'source': '/usr', 'target': '/host/usr'}]}}}, + 'observed': {}, 'deployment': {'host_monitor': 'netdata', 'mounts': [bind('/usr', '/host/usr')]}} + with patch.object(transaction, 'log'): + original, desired = transaction.freeze_host_sources(record, record, False) + self.assertTrue(original['/usr']['declared'] and desired['/usr']['declared']) + + +class IncludeTests(TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.include = Path(tmp.name) / 'host-monitor' + patcher = patch.object(runtime_settings, 'HOST_MONITOR_INCLUDES', (self.include,)) + patcher.start() + self.addCleanup(patcher.stop) + + def config(self): + return f'lxc.include: {self.include}\n'.encode() + + def test_the_include_of_a_host_monitor_is_accepted_unchanged(self): + self.include.write_text(runtime_settings.HOST_MONITOR_CONTENT) + runtime_settings.check(self.config(), {'host_monitor': 'glances'}, 122) + runtime_settings.check_recovery(self.config(), {'vmid': 122, 'record': {'deployment': {'host_monitor': 'glances'}}}) + + def test_a_changed_include_or_one_nobody_declared_is_refused(self): + self.include.write_text(runtime_settings.HOST_MONITOR_CONTENT + 'lxc.cap.drop =\n') + with self.assertRaises(ValueError): + runtime_settings.check(self.config(), {'host_monitor': 'glances'}, 122) + self.include.write_text(runtime_settings.HOST_MONITOR_CONTENT) + with self.assertRaises(ValueError): + runtime_settings.check(self.config(), {}, 122) + + def test_no_new_privileges_goes_in_the_include_of_the_container(self): + deployment = {'security': {'options': {'no_new_privileges': True}, + 'sysctls': [{'name': 'net.ipv4.ip_forward', 'value': '1'}]}} + self.assertEqual(runtime_settings.sysctl_content(deployment), + 'lxc.sysctl.net.ipv4.ip_forward = 1\nlxc.no_new_privs = 1\n') + self.assertEqual(runtime_settings.sysctl_content({'security': {'options': {}}}), '') + + +class RemovalTests(TestCase): + def test_the_seccomp_profile_of_a_removed_container_is_deleted(self): + import oci_remove + with tempfile.TemporaryDirectory() as tmp: + profile = Path(tmp) / '126.proxmenux-seccomp' + profile.write_text('2\ndenylist\n[all]\n') + with patch.object(runtime_settings, 'seccomp_path', return_value=profile), \ + patch.object(runtime_settings, 'include_path', return_value=Path(tmp) / 'none'), \ + patch.object(runtime_settings, 'legacy_include_path', return_value=Path(tmp) / 'none'), \ + patch.object(oci_remove, 'SNIPPETS', Path(tmp) / 'snippets'), \ + patch.object(oci_remove, 'MONITOR_APPS', Path(tmp) / 'apps'), \ + patch.object(oci_remove, 'CLUSTER_RECORDS', Path(tmp) / 'records'): + self.assertTrue(oci_remove._leftovers(126)) + oci_remove.remove_host_state(126) + self.assertFalse(profile.exists()) + + +class InstallerTests(TestCase): + def test_no_new_privileges_is_not_written_as_a_container_setting(self): + self.assertNotIn('set_lxc_directive "lxc.no_new_privs"', INSTALLER) + self.assertIn("printf 'lxc.no_new_privs = 1\\n' >>\"$SYSCTL_TEMP\"", INSTALLER) + + def test_a_startup_check_without_its_optional_fields_does_not_end_the_installation(self): + self.assertIn(".request_timeout_seconds // 5", INSTALLER) + self.assertIn(".verify_tls // false", INSTALLER) + + def test_seerr_can_write_its_configuration(self): + for name in ('curated', 'apps'): + profile = json.loads((ROOT / f'catalog/{name}/seerr.json').read_text())['proxmox']['installer_profile'] + self.assertEqual(profile['volume_owner'], {'uid': 1000, 'gid': 1000}) + self.assertEqual(profile['volume_preparations'][0]['container_path'], '/app/config') + self.assertEqual(profile['startup_healthcheck']['request_timeout_seconds'], 5) + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/oci/tests/test_shared_directories.py b/oci/tests/test_shared_directories.py new file mode 100644 index 00000000..b928d2ab --- /dev/null +++ b/oci/tests/test_shared_directories.py @@ -0,0 +1,154 @@ +"""An application that exists to share directories asks for them in every +installation, and its privileged profile is offered by the advanced one only.""" + +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from proxmenux_oci.catalog import Catalog +from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, InstallError, build_deployment +from proxmenux_oci.recreation import edit_recreation +from test_advanced_flow_order import storages + +NAME = "Name of the directory to share (lowercase letters, digits, - or _)" +MORE = "Add another directory to share?" +NFSV4 = "Prepare this container for NFSv4?" + + +class ScriptedUI: + """Answers a question with the next scripted answer for it, then with its default.""" + + def __init__(self, answers=None): + self.answers = {key: list(value) for key, value in (answers or {}).items()} + self.asked = [] + + def _answer(self, text, default): + self.asked.append(text) + for key, values in self.answers.items(): + if (text == key or text.endswith(key)) and values: + return values.pop(0) + return default + + def ask(self, text, default=None, required=True): + return self._answer(text, default if default is not None else "") + + def password(self, text, required=True): + return self._answer(text, "") + + def choose(self, text, options, default=None): + return self._answer(text, default) + + def confirm(self, text, default=False): + return self._answer(text, default) + + def message(self, text): + pass + + +@patch("proxmenux_oci.i18n.language", return_value="en") +@patch("proxmenux_oci.installer.host.storages", side_effect=storages) +@patch("proxmenux_oci.installer.host.default_storage", side_effect=lambda content, default: default) +@patch("proxmenux_oci.installer.host.default_bridge", side_effect=lambda default: default) +@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}]) +@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid") +@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": [], "amd": [], "nvidia": False}) +@patch("proxmenux_oci.installer.access.ask_ipv4", return_value=("dhcp", None)) +class SharedDirectoriesTests(unittest.TestCase): + catalog = Catalog(ROOT) + + def build(self, answers, mode=DEFAULT_MODE): + template = self.catalog.compose("sharecovex") + ui = ScriptedUI(answers) + return ui, template, build_deployment(template, ui, mode) + + def test_a_default_installation_asks_for_directories_until_the_answer_is_no(self, *_): + ui, template, plan = self.build({ + NAME: ["media", "backups"], + "Where to store /shares/media": ["host-bind"], + "Host path for /shares/media": ["/tank/media"], + "Size in GB of /shares/backups": ["200"], + MORE: [True, False], + }) + mounts = {m["container_path"]: m for m in plan["mounts"]} + self.assertEqual(list(mounts), ["/config", "/shares/media", "/shares/backups"]) + self.assertEqual((mounts["/shares/media"]["type"], mounts["/shares/media"]["source"], + mounts["/shares/media"]["backup"], mounts["/shares/media"]["create_if_missing"]), + ("host-bind", "/tank/media", False, True)) + self.assertEqual((mounts["/shares/backups"]["type"], mounts["/shares/backups"]["size_gb"], + mounts["/shares/backups"]["backup"], mounts["/shares/backups"]["custom"]), + ("managed-volume", 200, True, True)) + self.assertEqual(mounts["/shares/backups"]["source"], mounts["/config"]["source"]) + self.assertEqual(ui.asked.count(NAME), 2) + self.assertEqual(ui.asked.count(MORE), 2) + # The volume is prepared for the identity the application writes with; /config stays with root. + prepared = {item["container_path"]: item["owner_strategy"] + for item in template["proxmox"]["installer_profile"]["volume_preparations"]} + self.assertEqual(prepared, {"/config": "mapped-root", "/shares/backups": "mapped-application-user"}) + + def test_a_default_installation_stays_unprivileged_and_generates_the_panel_password(self, *_): + ui, _template, plan = self.build({NAME: ["media"]}) + self.assertFalse(any(NFSV4 in text for text in ui.asked)) + self.assertFalse(any("Add an extra custom path" in text for text in ui.asked)) + self.assertEqual(plan["security"]["unprivileged"], True) + self.assertEqual(plan["security"]["options"], {}) + password = next(item for item in plan["environment"] if item["name"] == "SHARECOVEX_ADMIN_PASSWORD") + self.assertRegex(password["value"], r"^[0-9a-f]{16}$") + self.assertFalse(any("ShareCoveX panel administrator" in text for text in ui.asked)) + self.assertEqual([m["container_path"] for m in plan["mounts"]], ["/config", "/shares/media"]) + self.assertEqual(plan["mounts"][1]["size_gb"], 32) + + def test_only_the_advanced_installation_offers_nfsv4_and_it_makes_the_container_privileged(self, *_): + ui, _template, plan = self.build({NAME: ["media"]}, ADVANCED_MODE) + self.assertTrue(any(NFSV4 in text for text in ui.asked)) + self.assertEqual((plan["security"]["unprivileged"], plan["security"]["privileged_acknowledged"]), (True, False)) + question = next(text for text in ui.asked if NFSV4 in text) + ui, _template, plan = self.build({NAME: ["media"], question: [True]}, ADVANCED_MODE) + security = plan["security"] + self.assertEqual((security["unprivileged"], security["privileged_acknowledged"], + security["relaxation_acknowledged"]), (False, True, True)) + self.assertEqual(security["options"], {"apparmor_profile": "unconfined", "seccomp_profile": "unconfined"}) + + def test_an_advanced_installation_places_each_directory_and_takes_the_password(self, *_): + ui, _template, plan = self.build({ + NAME: ["media"], + "Storage for /shares/media": ["Public"], + "(empty = generate)": ["a-long-panel-password"], + }, ADVANCED_MODE) + mounts = {m["container_path"]: m["source"] for m in plan["mounts"]} + self.assertEqual(mounts["/shares/media"], "Public") + self.assertEqual(next(item["value"] for item in plan["environment"]), "a-long-panel-password") + with self.assertRaisesRegex(InstallError, "minimum number of characters is 8"): + self.build({NAME: ["media"], "(empty = generate)": ["short"]}, ADVANCED_MODE) + + def test_a_name_that_cannot_identify_a_share_is_refused(self, *_): + for name in ("Media", "my files", "9lives", "config", "", "a" * 33): + with self.assertRaisesRegex(ValueError, "lowercase"): + self.build({NAME: [name]}) + with self.assertRaisesRegex(ValueError, "already shared"): + self.build({NAME: ["media", "media"], MORE: [True, False]}) + + def test_modify_can_add_another_directory_and_may_add_none(self, *_): + _ui, template, plan = self.build({NAME: ["media"]}) + record = {"template": template, "deployment": plan} + with patch("proxmenux_oci.recreation.refresh_template"), \ + patch("proxmenux_oci.recreation.edit_acceleration"), \ + patch("proxmenux_oci.recreation.edit_environment"): + ui = ScriptedUI({"Add a directory to share": [True], NAME: ["photos"], + "Where to store /shares/photos": ["host-bind"], + "Host path for /shares/photos": ["/tank/photos"]}) + proposal = edit_recreation(record, ui) + mounts = [m["container_path"] for m in proposal["candidate"]["deployment"]["mounts"]] + self.assertEqual(mounts, ["/config", "/shares/media", "/shares/photos"]) + untouched = edit_recreation(record, ScriptedUI()) + self.assertEqual([m["container_path"] for m in untouched["candidate"]["deployment"]["mounts"]], + ["/config", "/shares/media"]) + self.assertEqual([m["container_path"] for m in plan["mounts"]], ["/config", "/shares/media"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/oci/tests/test_work_backup.py b/oci/tests/test_work_backup.py new file mode 100644 index 00000000..0735300e --- /dev/null +++ b/oci/tests/test_work_backup.py @@ -0,0 +1,258 @@ +"""Where the backup taken before changing an application is written.""" + +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "remote")) + +import oci_work_backup as work + +GIB = 1024 ** 3 + + +class Location(unittest.TestCase): + def locate(self, free, needed=10 * GIB, record=None, storage_free=None, storage_dir=None): + def available(path): + return storage_free if storage_dir is not None and Path(path) == storage_dir else free + with patch.object(work, "required", return_value=needed), patch.object(work, "available", side_effect=available), \ + patch.object(work, "storage_path", return_value=storage_dir): + return work.locate(Path("/registry/104/transactions/abc"), [104], record or {}, 104) + + def test_a_backup_that_fits_stays_next_to_the_record(self): + self.assertEqual(self.locate(free=11 * GIB), Path("/registry/104/transactions/abc")) + + def test_without_room_and_without_a_chosen_storage_it_says_what_is_needed(self): + with self.assertRaises(ValueError) as failure: + self.locate(free=2 * GIB) + text = str(failure.exception) + for expected in ("does not fit on the system disk", "10.0 GB", "2.0 GB", "The application was not modified."): + self.assertIn(expected, text) + self.assertNotIn("/registry", text) + + def test_the_chosen_storage_is_used_when_the_system_disk_has_no_room(self): + with tempfile.TemporaryDirectory() as folder: + base = self.locate(free=2 * GIB, record={"deployment": {"work_storage": "nas"}}, + storage_free=50 * GIB, storage_dir=Path(folder)) + self.assertEqual(base, Path(folder) / "proxmenux-oci-work" / "104" / "abc") + self.assertFalse((Path(folder) / "proxmenux-oci-work").exists()) + + def test_a_chosen_storage_without_room_is_named(self): + with tempfile.TemporaryDirectory() as folder, self.assertRaises(ValueError) as failure: + self.locate(free=2 * GIB, record={"deployment": {"work_storage": "nas"}}, + storage_free=3 * GIB, storage_dir=Path(folder)) + self.assertIn("nas", str(failure.exception)) + self.assertIn("3.0 GB", str(failure.exception)) + + def test_free_space_is_read_where_the_folder_will_be_created(self): + with tempfile.TemporaryDirectory() as folder: + self.assertGreater(work.available(Path(folder) / "not" / "yet" / "there"), 0) + + +class Remembering(unittest.TestCase): + def test_the_folder_of_the_operation_says_where_its_backups_are(self): + with tempfile.TemporaryDirectory() as folder: + operation = Path(folder) / "registry" / "abc" + operation.mkdir(parents=True) + self.assertEqual(work.recall(operation), operation) + work.remember(operation, operation) + self.assertFalse((operation / work.MARK).exists()) + outside = Path(folder) / "nas" / work.FOLDER / "104" / "abc" + work.remember(operation, outside) + self.assertEqual(work.recall(operation), outside) + + def test_a_location_that_is_not_one_of_ours_is_never_followed(self): + with tempfile.TemporaryDirectory() as folder: + operation = Path(folder) + (operation / work.MARK).write_text("/etc\n") + self.assertEqual(work.recall(operation), operation) + (operation / work.MARK).write_text("relative/proxmenux-oci-work/1/x\n") + self.assertEqual(work.recall(operation), operation) + + +class Cleaning(unittest.TestCase): + def test_the_backups_next_to_the_record_are_deleted_and_the_journal_stays(self): + with tempfile.TemporaryDirectory() as folder: + operation = Path(folder) + (operation / "backup").mkdir() + (operation / "backup-105").mkdir() + for name in ("backup/vzdump-lxc-104-x.tar.zst", "backup-105/vzdump-lxc-105-x.tar.zst", "transaction.json"): + (operation / name).write_text("x") + work.clear(operation) + self.assertEqual(sorted(path.name for path in operation.rglob("*") if path.is_file()), ["transaction.json"]) + + def test_the_folder_on_another_storage_goes_away_and_the_storage_is_left_alone(self): + with tempfile.TemporaryDirectory() as folder: + operation = Path(folder) / "registry" / "abc" + operation.mkdir(parents=True) + storage = Path(folder) / "nas" + base = storage / work.FOLDER / "104" / "abc" + (base / "backup").mkdir(parents=True) + (base / "backup" / "vzdump-lxc-104-x.tar.zst").write_text("x") + (storage / "dump").mkdir() + (storage / "dump" / "vzdump-lxc-104-kept.tar.zst").write_text("a backup of the user") + other = storage / work.FOLDER / "200" / "zzz" + other.mkdir(parents=True) + work.remember(operation, base) + work.clear(operation) + self.assertFalse((storage / work.FOLDER / "104").exists()) + self.assertTrue(other.is_dir()) + self.assertTrue((storage / "dump" / "vzdump-lxc-104-kept.tar.zst").is_file()) + + def test_removing_an_application_removes_what_its_operations_left_on_another_storage(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "registry" + operation = root / "104" / "transactions" / "abc" + operation.mkdir(parents=True) + base = Path(folder) / "nas" / work.FOLDER / "104" / "abc" + (base / "backup").mkdir(parents=True) + (base / "backup" / "vzdump-lxc-104-x.tar.zst").write_text("x") + work.remember(operation, base) + work.forget(root, 104) + self.assertFalse(base.exists()) + self.assertTrue((Path(folder) / "nas").is_dir()) + + +class Choice(unittest.TestCase): + def test_the_choice_is_kept_in_every_container_of_the_application(self): + import oci_carried_record + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + ids = {104: "11111111-1111-4111-8111-111111111111", 105: "22222222-2222-4222-8222-222222222222"} + for vmid, extra in ((104, {"stack": {"members": [{"vmid": 104}, {"vmid": 105}]}, "stack_member": {"primary_vmid": 104}}), + (105, {"stack_member": {"primary_vmid": 104}})): + (root / str(vmid)).mkdir() + (root / str(vmid) / "oci-compose.json").write_text(json.dumps({ + "schema_version": 1, "vmid": vmid, "installation_id": ids[vmid], "status": "installed", + "deployment": {"onboot": True}, **extra})) + saved = lambda vmid: json.loads((root / str(vmid) / "oci-compose.json").read_text())["deployment"] + with patch.object(work, "storage_path", return_value=Path(folder)), \ + patch.object(oci_carried_record, "carry", return_value="carried"): + self.assertEqual(work.choose(root, 105, "nas"), [104, 105]) + self.assertEqual((saved(104)["work_storage"], saved(105)["work_storage"]), ("nas", "nas")) + work.choose(root, 104, None) + self.assertNotIn("work_storage", saved(104)) + self.assertEqual(saved(105), {"onboot": True}) + + def test_a_storage_that_does_not_take_backups_is_not_accepted(self): + with patch.object(work, "storage_path", return_value=None), self.assertRaises(ValueError): + work.choose(Path("/registry"), 104, "isos") + + +class Engines(unittest.TestCase): + def test_a_modification_keeps_what_the_user_chose_for_the_application(self): + import oci_instance_transaction as transaction + record = {"status": "installed", "vmid": 109, "installation_id": "x", "template": {"id": "adguard"}, + "observed": {"config_sha256": "c"}, + "deployment": {"vmid": 109, "cores": 1, "watchdog": True, "work_storage": "nas"}} + edited = {"vmid": 109, "installation_id": "x", "template": {"id": "adguard"}, + "deployment": {"vmid": 109, "cores": 4, "watchdog": False}} + with patch.object(transaction, "with_default_healthcheck", side_effect=lambda value: value): + result = transaction.candidate_contract(record, "recreate", {"operation": "recreate", "candidate": edited}) + self.assertEqual(result["deployment"], {"vmid": 109, "cores": 4, "watchdog": True, "work_storage": "nas"}) + record["deployment"] = {"vmid": 109, "cores": 1} + edited["deployment"]["work_storage"] = "stale" + result = transaction.candidate_contract(record, "recreate", {"operation": "recreate", "candidate": edited}) + self.assertEqual(result["deployment"], {"vmid": 109, "cores": 4}) + + def test_a_stack_asks_for_room_only_until_its_first_backup_exists(self): + import oci_stack_native as native + with tempfile.TemporaryDirectory() as folder: + operation = Path(folder) / "abc" + operation.mkdir() + adapter = native.NativeAdapter.__new__(native.NativeAdapter) + adapter.journal = operation / "transaction.json" + adapter.plan = {"primary_vmid": 104} + adapter.records = {104: {"deployment": {}}, 105: {"deployment": {}}} + with patch.object(native.work_backup, "locate", return_value=operation) as locate: + self.assertEqual(adapter.work_base(), operation) + (operation / "backup-105").mkdir() + self.assertEqual(adapter.work_base(), operation) + self.assertEqual(locate.call_count, 1) + self.assertEqual(locate.call_args.args[1:], ([104, 105], {"deployment": {}}, 104)) + + def test_an_image_that_does_not_fit_is_refused_before_it_is_downloaded(self): + import oci_update_current as update + desired = {"template": {"container_contract": {"image": {"reference": "docker.io/library/app:latest"}}}, + "deployment": {"template_storage": "local"}} + candidate = {"manifest_digest": "sha256:" + "a" * 64, "registry_digest": None, "version": "1", "size": 3 * GIB} + called = [] + + def quiet(command, failure, capture=False): + called.append(command[0]) + return json.dumps(candidate) + with tempfile.TemporaryDirectory() as folder, \ + patch.object(update, "run_quiet", side_effect=quiet), \ + patch.object(update.transaction, "parse_config", return_value={"arch": "amd64"}), \ + patch.object(update.instances, "command", return_value=(folder + "/image.tar").encode()), \ + patch.object(update.work_backup, "available", return_value=GIB), \ + patch.object(update, "msg_info"), patch.object(update, "msg_ok"), patch.object(update.transaction, "log"): + with self.assertRaises(ValueError) as failure: + update.resolve_archive(desired, b"arch: amd64") + text = str(failure.exception) + for expected in ("does not fit on the storage where images are downloaded", "local", "1.0 GB", "The application was not modified."): + self.assertIn(expected, text) + self.assertNotIn("skopeo", called) + + +class Menu(unittest.TestCase): + def ready(self, state, pick=None, unattended=False): + sys.path.insert(0, str(ROOT / "src")) + from proxmenux_oci import management + shown = [] + + class UI: + def message(self, text, title=None): + shown.append(text) + + def choose(self, title, options, default=None): + shown.append(options) + return pick + ui = UI() + if unattended: + ui.unattended = True + with patch.object(work, "status", return_value=state) as status, patch.object(work, "choose") as choose, \ + patch.object(management, "translate", side_effect=lambda text: text): + result = management._work_backup_ready(ROOT, ui, 109) + return result, shown, status, choose + + STATE = {"needed": 10 * GIB, "free": 2 * GIB, "fits": False, "saved": None, "saved_fits": False, + "candidates": [{"storage": "small", "free": 12 * GIB}, {"storage": "nas", "free": 900 * GIB}]} + + def test_nothing_is_asked_when_the_backup_fits_or_a_storage_with_room_was_chosen(self): + for state in ({**self.STATE, "fits": True}, {**self.STATE, "saved": "nas", "saved_fits": True}): + result, shown, _, choose = self.ready(state) + self.assertTrue(result) + self.assertEqual(shown, []) + choose.assert_not_called() + + def test_the_storages_with_room_are_offered_with_the_roomiest_first_and_the_choice_is_saved(self): + result, shown, _, choose = self.ready(self.STATE, pick="nas") + self.assertTrue(result) + self.assertIn("10.0 GB", shown[0]) + self.assertIn("2.0 GB", shown[0]) + self.assertEqual([tag for tag, _ in shown[1]], ["nas", "small"]) + self.assertEqual(choose.call_args.args[1:], (109, "nas")) + + def test_leaving_the_list_or_having_no_storage_with_room_does_not_start_the_operation(self): + result, _, _, choose = self.ready(self.STATE, pick=None) + self.assertFalse(result) + choose.assert_not_called() + result, shown, _, _ = self.ready({**self.STATE, "candidates": []}) + self.assertFalse(result) + self.assertEqual(len(shown), 1) + self.assertIn("No other storage", shown[0]) + + def test_a_scheduled_run_asks_nothing_and_lets_the_engine_decide(self): + result, shown, status, _ = self.ready(self.STATE, unattended=True) + self.assertTrue(result) + self.assertEqual(shown, []) + status.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/menus/config_menu.sh b/scripts/menus/config_menu.sh index dc06268f..1cf1cdef 100644 --- a/scripts/menus/config_menu.sh +++ b/scripts/menus/config_menu.sh @@ -71,6 +71,17 @@ BACKTITLE="$(translate "$BACKTITLE")" # ========================================================== +# Proxmox would keep sending every notification to a Monitor that is gone. +# Its own interface removes the target and the rule that feeds it; the copies +# of the settings that earlier versions left in /etc/pve are removed too. +remove_monitor_notification_target() { + mountpoint -q /etc/pve || return 0 + pvesh delete /cluster/notifications/matchers/proxmenux-default > /dev/null 2>&1 || true + pvesh delete /cluster/notifications/endpoints/webhook/proxmenux-webhook > /dev/null 2>&1 || true + rm -f /etc/pve/notifications.cfg.proxmenux_backup_* /etc/pve/priv/notifications.cfg.proxmenux_backup_* + echo " - Notification target removed from Proxmox" +} + uninstall_proxmenux_monitor() { # 1. Stop service if it is running @@ -81,6 +92,8 @@ uninstall_proxmenux_monitor() { echo " - Service is not running (ok)" fi + remove_monitor_notification_target + # 2. Disable service if enabled if systemctl is-enabled --quiet "${MONITOR_SERVICE}"; then echo " - Disabling service..." @@ -213,7 +226,7 @@ normalize_stable_monitor_service() { cat > "$MONITOR_UNIT_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard -After=network.target +After=network.target pve-cluster.service [Service] Type=simple diff --git a/web/messages/en/docs/monitor/dashboard/storage.json b/web/messages/en/docs/monitor/dashboard/storage.json index bd552b47..f3aa6629 100644 --- a/web/messages/en/docs/monitor/dashboard/storage.json +++ b/web/messages/en/docs/monitor/dashboard/storage.json @@ -75,7 +75,7 @@ "Capacity — formatted human-readable.", "Temperature — current °C, coloured by the disk-type threshold (NVMe runs warmer than SATA).", "SMART status — passed / failed / unknown.", - "Observations badge — when the permanent disk_observations history has un-dismissed entries for this disk, a blue badge with the count appears (e.g. 3 obs.). Click the disk to drill in and review them.", + "Observations badge — when the permanent disk_observations history has entries for this disk, a blue badge with the count appears (e.g. 3 obs.). Click the disk to drill in and review them.", "Health badge — Healthy / Warning / Critical, derived from the SMART check + recent observations." ], "clickHint": "The whole row is clickable and opens the per-disk drill-in described below.", @@ -185,9 +185,9 @@ "obsDedupTitle": "How dedup and re-notification work", "obsDedupBody1": "Observations are deduplicated by their signature — a stable fingerprint of the error type, device and key fields of the kernel line. The same event repeating bumps the occurrence_count on the existing row rather than creating a new one. A different signature on the same disk creates a new observation and is treated as a new event for notification purposes.", "obsDedupBody2": "Notifications follow an anti-cascade rule: the first occurrence of a given (disk, signature, severity) combination pages the operator, and ProxMenux then waits 24 hours before pinging again about the same combination — even if the count keeps climbing. Escalating severity (WARNING → CRITICAL) breaks the cooldown so the operator is told when things get worse, not just when they happen.", - "obsDismissTitle": "Dismissing vs resolving", - "obsDismissBody1": "Each row has a dismiss action. Dismissing an observation tells ProxMenux \"I've seen this, stop notifying me about it\". It does not freeze the occurrence counter — if the same fault keeps happening the count keeps climbing in the background, ready to alert again if it ever escalates to a different severity tier or signature. A dismissed observation stays visible on the card with a muted style, so a future operator can still see \"this disk had history here\".", - "obsDismissBody2": "Resolving on the active-error side (Health Monitor) is independent of observation dismiss — the observation persists past the active error's auto-resolve. That's the whole point: it survives, so a transient warning from last week is still visible on the disk card today. See Health Monitor for the active-error side of the same picture." + "obsDismissTitle": "Permanent history vs active errors", + "obsDismissBody1": "An observation cannot be dismissed, hidden or removed. Each different observation of a disk is one card; the same one seen again adds to its occurrence count and moves its last date instead of creating a new card. The history belongs to the disk, identified by its serial number: a disk plugged where another one was starts with none, and a disk the kernel renames keeps its own.", + "obsDismissBody2": "Resolving or dismissing on the active-error side (Health Monitor) is independent of the observations — the observation persists past the active error's auto-resolve. That's the whole point: it survives, so a transient warning from last week is still visible on the disk card today. See Health Monitor for the active-error side of the same picture." }, "dataCollected": { "heading": "How the data is collected", diff --git a/web/messages/en/docs/monitor/notifications.json b/web/messages/en/docs/monitor/notifications.json index d6fd4b41..656991a1 100644 --- a/web/messages/en/docs/monitor/notifications.json +++ b/web/messages/en/docs/monitor/notifications.json @@ -342,7 +342,7 @@ "practiceBody": "The PVE setup writes the target as http://127.0.0.1:8008, so PVE-emitted notifications always go through the loopback path with rate-limit-only security. The remote-caller path with the shared secret is opt-in for custom integrations — point an external service at https://<monitor-host>:<port>/api/notifications/webhook and supply the X-Webhook-Secret header to use it.", "actionsIntro": "The Monitor manages this target through three actions on the Settings tab:", "actionsItems": [ - "Setup — runs automatically when you enable Notifications. Creates the entry in /etc/pve/notifications.cfg after backing up the current file.", + "Setup — runs automatically when you enable Notifications. Creates the entry in /etc/pve/notifications.cfg. The file is written only when its content has to change, and a copy of the previous one is kept in /usr/local/share/proxmenux/backups/pve-notifications/ (the three newest). Nothing is written while /etc/pve is not mounted: a Monitor that starts during a restart of pve-cluster registers the entry once it is back.", "Cleanup — removes the entry. The previous backup of the file is kept.", "Read config — shows the current targets and matchers as PVE sees them. This is how you confirm the Monitor's entry is the one firing when PVE has multiple notification routes configured." ], diff --git a/web/messages/en/docs/oci-manager/lifecycle.json b/web/messages/en/docs/oci-manager/lifecycle.json index 8b9728e7..96ccaf84 100644 --- a/web/messages/en/docs/oci-manager/lifecycle.json +++ b/web/messages/en/docs/oci-manager/lifecycle.json @@ -125,6 +125,9 @@ } ] } + }, + { + "p": "Before anything is stopped, the operation checks that the backup fits: the data in use on the volumes that are backed up, plus a margin. It is written next to the record of the application, on the system disk of the host. When it does not fit there, the menu asks for another storage of the host that accepts backups: the backup is made there, it is deleted when the operation ends, and the choice is remembered for that application, scheduled updates included. With no storage that has room, the operation is refused and the application stays as it was. The new image is checked the same way before it is downloaded." } ] }, diff --git a/web/messages/en/docs/settings/uninstall-proxmenux.json b/web/messages/en/docs/settings/uninstall-proxmenux.json index 3a1dda30..ff6433b6 100644 --- a/web/messages/en/docs/settings/uninstall-proxmenux.json +++ b/web/messages/en/docs/settings/uninstall-proxmenux.json @@ -19,7 +19,7 @@ "items": [ "Confirmation dialog. Yes/no — “Are you sure you want to uninstall ProxMenux?”", "Dependency removal checklist. Pre-selected list depends on install type (see table below). All boxes default to OFF — you opt in to removing each dependency.", - "Removal runs with a progress bar. Removes the venv (Translation install only), the menu launcher, the /usr/local/share/proxmenux/ tree, the ProxMenux Monitor service if present, the dependencies you ticked, and finally restores the system file backups (see below).", + "Removal runs with a progress bar. Removes the venv (Translation install only), the menu launcher, the /usr/local/share/proxmenux/ tree, the ProxMenux Monitor service if present together with the notification target it registered in Proxmox VE (proxmenux-webhook and its matcher), the dependencies you ticked, and finally restores the system file backups (see below).", "Completion dialog. Lists removed dependencies and exits." ] }, diff --git a/web/messages/es/docs/monitor/dashboard/storage.json b/web/messages/es/docs/monitor/dashboard/storage.json index 951799ce..066259a1 100644 --- a/web/messages/es/docs/monitor/dashboard/storage.json +++ b/web/messages/es/docs/monitor/dashboard/storage.json @@ -75,7 +75,7 @@ "Capacidad — formateada de forma legible.", "Temperatura — °C actuales, coloreada por el umbral de tipo de disco (NVMe corre más caliente que SATA).", "Estado SMART — passed / failed / unknown.", - "Insignia de observaciones — cuando el historial permanente disk_observations tiene entradas no descartadas para este disco, aparece una insignia azul con el recuento (p. ej. 3 obs.). Pulsa el disco para entrar y revisarlas.", + "Insignia de observaciones — cuando el historial permanente disk_observations tiene entradas para este disco, aparece una insignia azul con el recuento (p. ej. 3 obs.). Pulsa el disco para entrar y revisarlas.", "Insignia de salud — Healthy / Warning / Critical, derivada del chequeo SMART + observaciones recientes." ], "clickHint": "La fila entera es pulsable y abre la vista en detalle por disco descrita abajo.", @@ -185,9 +185,9 @@ "obsDedupTitle": "Cómo funcionan dedup y re-notificación", "obsDedupBody1": "Las observaciones se deduplican por su signature — una huella estable del tipo de error, dispositivo y campos clave de la línea del kernel. El mismo evento repitiéndose sube el occurrence_count de la fila existente en lugar de crear una nueva. Una signature diferente en el mismo disco crea una nueva observación y se trata como un evento nuevo para efectos de notificación.", "obsDedupBody2": "Las notificaciones siguen una regla anti-cascada: la primera ocurrencia de una combinación (disco, signature, severidad) dada avisa al operador, y entonces ProxMenux espera 24 horas antes de volver a pingar sobre la misma combinación — aunque el contador siga subiendo. Una severidad escalando (WARNING → CRITICAL) rompe el cooldown para que se le cuente al operador cuando las cosas empeoran, no solo cuando ocurren.", - "obsDismissTitle": "Descartar vs resolver", - "obsDismissBody1": "Cada fila tiene una acción dismiss. Descartar una observación le dice a ProxMenux \"ya lo he visto, deja de notificarme\". No congela el contador de ocurrencias — si la misma falla sigue pasando, el contador sigue subiendo en background, listo para alertar de nuevo si alguna vez escala a un nivel de severidad o signature diferente. Una observación descartada sigue visible en la tarjeta con un estilo atenuado, para que un operador futuro pueda seguir viendo \"este disco tuvo historial aquí\".", - "obsDismissBody2": "Resolver del lado de error activo (Monitor de salud) es independiente del dismiss de observaciones — la observación persiste más allá del auto-resolve del error activo. Ese es justo el punto: sobrevive, para que un warning transitorio de la semana pasada siga visible hoy en la tarjeta del disco. Mira Monitor de salud para el lado de error activo de la misma imagen." + "obsDismissTitle": "Historial permanente vs errores activos", + "obsDismissBody1": "Una observación no se puede descartar, ocultar ni eliminar. Cada observación distinta de un disco es una tarjeta; la misma observación vista de nuevo suma a su contador de ocurrencias y actualiza su última fecha en lugar de crear otra tarjeta. El historial pertenece al disco, identificado por su número de serie: un disco conectado donde había otro empieza sin ninguna, y un disco que el kernel renombra conserva las suyas.", + "obsDismissBody2": "Resolver o descartar del lado de error activo (Monitor de salud) es independiente de las observaciones — la observación persiste más allá del auto-resolve del error activo. Ese es justo el punto: sobrevive, para que un warning transitorio de la semana pasada siga visible hoy en la tarjeta del disco. Mira Monitor de salud para el lado de error activo de la misma imagen." }, "dataCollected": { "heading": "Cómo se recopilan los datos", diff --git a/web/messages/es/docs/monitor/notifications.json b/web/messages/es/docs/monitor/notifications.json index 8dec0844..a0d409ca 100644 --- a/web/messages/es/docs/monitor/notifications.json +++ b/web/messages/es/docs/monitor/notifications.json @@ -342,7 +342,7 @@ "practiceBody": "El setup de PVE escribe el destino como http://127.0.0.1:8008, así que las notificaciones emitidas por PVE siempre pasan por el camino loopback con seguridad solo rate-limit. El camino de llamador remoto con shared secret es opt-in para integraciones personalizadas — apunta un servicio externo a https://<host-monitor>:<port>/api/notifications/webhook y suministra la cabecera X-Webhook-Secret para usarlo.", "actionsIntro": "El Monitor gestiona este destino a través de tres acciones en la pestaña Settings:", "actionsItems": [ - "Setup — corre automáticamente cuando activas Notifications. Crea la entrada en /etc/pve/notifications.cfg tras hacer backup del archivo actual.", + "Setup — corre automáticamente cuando activas Notifications. Crea la entrada en /etc/pve/notifications.cfg. El archivo solo se escribe cuando su contenido tiene que cambiar, y se guarda un backup del anterior en /usr/local/share/proxmenux/backups/pve-notifications/ (los tres más recientes). No se escribe nada mientras /etc/pve no está montado: un Monitor que arranca durante un reinicio de pve-cluster registra la entrada cuando vuelve.", "Cleanup — elimina la entrada. El backup previo del archivo se conserva.", "Read config — muestra los destinos y matchers actuales como PVE los ve. Así es como confirmas que la entrada del Monitor es la que dispara cuando PVE tiene múltiples rutas de notificación configuradas." ], diff --git a/web/messages/es/docs/oci-manager/lifecycle.json b/web/messages/es/docs/oci-manager/lifecycle.json index 3396ad48..a722935a 100644 --- a/web/messages/es/docs/oci-manager/lifecycle.json +++ b/web/messages/es/docs/oci-manager/lifecycle.json @@ -125,6 +125,9 @@ } ] } + }, + { + "p": "Antes de detener nada, la operación comprueba que el backup cabe: los datos en uso de los volúmenes que entran en el backup, más un margen. Se escribe junto al registro de la aplicación, en el disco del sistema del host. Cuando no cabe ahí, el menú pide otro almacenamiento del host que acepte backups: el backup se hace allí, se borra cuando termina la operación y la elección se recuerda para esa aplicación, también en las actualizaciones programadas. Si ningún almacenamiento tiene espacio, la operación se rechaza y la aplicación queda como estaba. La imagen nueva se comprueba igual antes de descargarla." } ] }, diff --git a/web/messages/es/docs/settings/uninstall-proxmenux.json b/web/messages/es/docs/settings/uninstall-proxmenux.json index 079c820d..a799f687 100644 --- a/web/messages/es/docs/settings/uninstall-proxmenux.json +++ b/web/messages/es/docs/settings/uninstall-proxmenux.json @@ -19,7 +19,7 @@ "items": [ "Diálogo de confirmación. Sí/no — \"¿Seguro que quieres desinstalar ProxMenux?\"", "Checklist de eliminación de dependencias. La lista preseleccionada depende del tipo de instalación (mira la tabla de abajo). Todas las casillas por defecto en OFF — te apuntas a eliminar cada dependencia.", - "La eliminación corre con una barra de progreso. Elimina el venv (solo instalación con traducción), el lanzador menu, el árbol /usr/local/share/proxmenux/, el servicio ProxMenux Monitor si está presente, las dependencias que marcaste y, por último, restaura los backups de archivos del sistema (mira abajo).", + "La eliminación corre con una barra de progreso. Elimina el venv (solo instalación con traducción), el lanzador menu, el árbol /usr/local/share/proxmenux/, el servicio ProxMenux Monitor si está presente junto con el destino de notificaciones que registró en Proxmox VE (proxmenux-webhook y su matcher), las dependencias que marcaste y, por último, restaura los backups de archivos del sistema (mira abajo).", "Diálogo de finalización. Lista las dependencias eliminadas y sale." ] },