feat(oci): run official container images as native LXC containers

Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-22 18:24:59 +02:00
co-authored by Claude Opus 5
parent b36498f215
commit bcabcb618c
670 changed files with 221410 additions and 215 deletions
+116
View File
@@ -5,6 +5,7 @@ Flask routes for health monitoring with persistence support
from flask import Blueprint, jsonify, request
from health_monitor import health_monitor
from health_persistence import health_persistence
from jwt_middleware import require_auth, require_admin_scope
# Sprint 13: remote-mount monitor (NFS/CIFS/SMB) — separate module so a
# missing helper doesn't crash the health blueprint.
@@ -17,6 +18,7 @@ except ImportError:
health_bp = Blueprint('health', __name__)
@health_bp.route('/api/health/status', methods=['GET'])
@require_auth
def get_health_status():
"""Get overall health status summary"""
try:
@@ -26,6 +28,7 @@ def get_health_status():
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/details', methods=['GET'])
@require_auth
def get_health_details():
"""Get detailed health status with all checks"""
try:
@@ -58,6 +61,7 @@ def get_system_info():
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/acknowledge', methods=['POST'])
@require_admin_scope
def acknowledge_error():
"""
Acknowledge/dismiss an error manually.
@@ -156,6 +160,7 @@ def acknowledge_error():
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/un-acknowledge', methods=['POST'])
@require_admin_scope
def unacknowledge_error():
"""
Re-enable a previously dismissed error.
@@ -203,6 +208,7 @@ def unacknowledge_error():
@health_bp.route('/api/health/active-errors', methods=['GET'])
@require_auth
def get_active_errors():
"""Get all active persistent errors"""
try:
@@ -213,6 +219,7 @@ def get_active_errors():
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/dismissed', methods=['GET'])
@require_auth
def get_dismissed_errors():
"""
Get dismissed errors that are still within their suppression period.
@@ -225,6 +232,7 @@ def get_dismissed_errors():
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/full', methods=['GET'])
@require_auth
def get_full_health():
"""
Get complete health data in a single request: detailed status + active errors + dismissed.
@@ -271,6 +279,7 @@ def get_full_health():
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/cleanup-orphans', methods=['POST'])
@require_admin_scope
def cleanup_orphan_errors():
"""
Clean up errors for devices that no longer exist in the system.
@@ -331,6 +340,7 @@ def cleanup_orphan_errors():
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/pending-notifications', methods=['GET'])
@require_auth
def get_pending_notifications():
"""
Get events pending notification (for future Telegram/Gotify/Discord integration).
@@ -343,6 +353,7 @@ def get_pending_notifications():
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/mark-notified', methods=['POST'])
@require_admin_scope
def mark_events_notified():
"""
Mark events as notified after notification was sent successfully.
@@ -364,6 +375,7 @@ def mark_events_notified():
@health_bp.route('/api/health/settings', methods=['GET'])
@require_auth
def get_health_settings():
"""
Get per-category suppression duration settings.
@@ -377,6 +389,7 @@ def get_health_settings():
@health_bp.route('/api/health/settings', methods=['POST'])
@require_admin_scope
def save_health_settings():
"""
Save per-category suppression duration settings.
@@ -422,6 +435,7 @@ def save_health_settings():
# ── Remote Storage Exclusions Endpoints ──
@health_bp.route('/api/health/remote-storages', methods=['GET'])
@require_auth
def get_remote_storages():
"""
Get list of all remote storages with their exclusion status.
@@ -472,6 +486,7 @@ def get_remote_storages():
@health_bp.route('/api/health/storage-exclusions', methods=['GET'])
@require_auth
def get_storage_exclusions():
"""Get all storage exclusions."""
try:
@@ -482,6 +497,7 @@ def get_storage_exclusions():
@health_bp.route('/api/health/storage-exclusions', methods=['POST'])
@require_admin_scope
def save_storage_exclusion():
"""
Add or update a storage exclusion.
@@ -535,6 +551,7 @@ def save_storage_exclusion():
@health_bp.route('/api/health/storage-exclusions/<storage_name>', methods=['DELETE'])
@require_admin_scope
def delete_storage_exclusion(storage_name):
"""Remove a storage from the exclusion list."""
try:
@@ -555,6 +572,7 @@ def delete_storage_exclusion(storage_name):
# ═══════════════════════════════════════════════════════════════════════════
@health_bp.route('/api/health/interfaces', methods=['GET'])
@require_auth
def get_network_interfaces():
"""Get all network interfaces with their exclusion status."""
try:
@@ -615,6 +633,7 @@ def get_network_interfaces():
@health_bp.route('/api/health/interface-exclusions', methods=['GET'])
@require_auth
def get_interface_exclusions():
"""Get all interface exclusions."""
try:
@@ -625,6 +644,7 @@ def get_interface_exclusions():
@health_bp.route('/api/health/interface-exclusions', methods=['POST'])
@require_admin_scope
def save_interface_exclusion():
"""
Add or update an interface exclusion.
@@ -677,6 +697,7 @@ def save_interface_exclusion():
@health_bp.route('/api/health/interface-exclusions/<interface_name>', methods=['DELETE'])
@require_admin_scope
def delete_interface_exclusion(interface_name):
"""Remove an interface from the exclusion list."""
try:
@@ -692,7 +713,102 @@ def delete_interface_exclusion(interface_name):
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/disks', methods=['GET'])
@require_auth
def get_disks_for_exclusion():
"""Physical disks with whether each is excluded from periodic reads.
Listed from udev and /sys only, so opening the settings page does not
touch a disk the user is about to exclude precisely to leave it alone.
"""
try:
from disk_identity import list_physical_disks
import disk_temperature_history as _dth
excluded = {e['disk_key']: e for e in health_persistence.get_excluded_disks()}
present = set()
result = []
for disk in list_physical_disks():
present.add(disk['key'])
entry = excluded.get(disk['key'])
result.append({
**disk,
'excluded': entry is not None,
'excluded_at': entry.get('excluded_at') if entry else None,
'idle': _dth.is_disk_idle(disk['name']),
'present': True,
})
# An excluded disk that is not connected right now — an unplugged USB
# drive — stays in the list, so its exclusion can still be seen and
# removed rather than silently waiting for it to come back.
for key, entry in excluded.items():
if key in present:
continue
result.append({
'name': entry.get('disk_name') or '',
'key': key,
'model': entry.get('model') or '',
'serial': entry.get('serial') or '',
'size_bytes': 0,
'transport': '',
'rotational': False,
'excluded': True,
'excluded_at': entry.get('excluded_at'),
'idle': False,
'present': False,
})
result.sort(key=lambda d: (not d['present'], d['name']))
return jsonify({'disks': result})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/disk-exclusions', methods=['POST'])
@require_admin_scope
def save_disk_exclusion():
"""Exclude a disk from periodic reads.
Request body: {"disk_key": "serial:WD-...", "disk_name": "sdb",
"model": "...", "serial": "...", "reason": "..."}
The key is the one /api/health/disks reports; it follows the disk
across kernel renames.
"""
try:
data = request.get_json(silent=True) or {}
disk_key = str(data.get('disk_key') or '').strip()
if not disk_key or ':' not in disk_key or len(disk_key) > 200:
return jsonify({'error': 'a valid disk_key is required'}), 400
ok = health_persistence.exclude_disk(
disk_key,
disk_name=str(data.get('disk_name') or '')[:64] or None,
model=str(data.get('model') or '')[:128] or None,
serial=str(data.get('serial') or '')[:128] or None,
reason=str(data.get('reason') or '')[:500] or None,
)
if not ok:
return jsonify({'error': 'Failed to save exclusion'}), 500
import disk_temperature_history as _dth
_dth.invalidate_disk_exclusions()
return jsonify({'success': True, 'disk_key': disk_key})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/disk-exclusions/<path:disk_key>', methods=['DELETE'])
@require_admin_scope
def delete_disk_exclusion(disk_key):
"""Put a disk back under periodic reads."""
try:
if not health_persistence.remove_disk_exclusion(disk_key):
return jsonify({'error': 'Disk not found in exclusions'}), 404
import disk_temperature_history as _dth
_dth.invalidate_disk_exclusions()
return jsonify({'success': True, 'disk_key': disk_key})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/mounts', methods=['GET'])
@require_auth
def get_remote_mounts():
"""Sprint 13: list NFS/CIFS/SMB mounts on the host AND inside every
running LXC, with per-mount health (reachable / stale / read-only).