mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge pull request #428 from MacRimi/oci-lifecycle-dhcp-and-monitor-fixes
OCI: update and modify every application; Monitor fixes for /etc/pve, DHCP and disk observations
This commit is contained in:
@@ -80,6 +80,7 @@ class InventoryMessages(unittest.TestCase):
|
|||||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||||
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
'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))),
|
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
|
||||||
're': re, 'guest_node': lambda vmid: None,
|
're': re, 'guest_node': lambda vmid: None,
|
||||||
'remove_host_state': lambda vmid: events.append(('host', vmid)),
|
'remove_host_state': lambda vmid: events.append(('host', vmid)),
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class RemovalWordings(unittest.TestCase):
|
|||||||
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
||||||
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||||
|
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||||
'oci_console': SimpleNamespace(remove_log=lambda 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,
|
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
|
||||||
'translate': lambda text: text,
|
'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),
|
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
||||||
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||||
|
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||||
'oci_console': SimpleNamespace(remove_log=lambda 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,
|
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None,
|
||||||
'translate': lambda text: text,
|
'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),
|
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
||||||
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||||
|
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||||
'oci_console': SimpleNamespace(remove_log=lambda 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,
|
'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,
|
'translate': lambda text: text, 'msg_info': lambda text: None,
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ class UpdateWording(unittest.TestCase):
|
|||||||
'translate': lambda text: text, 'check_selected': lambda project, row: row,
|
'translate': lambda text: text, 'check_selected': lambda project, row: row,
|
||||||
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||||
'carry_records': lambda project, **options: {},
|
'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}
|
'_run_lifecycle': lambda command, title: events.append(('run', command, title)) or True}
|
||||||
row = {'vmid': 101, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'}
|
row = {'vmid': 101, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'}
|
||||||
decision = False
|
decision = False
|
||||||
|
|||||||
@@ -422,7 +422,8 @@ function buildWebUrl(ip: string | undefined | null, port: number | "", scheme?:
|
|||||||
const custom = (customUrl || "").trim()
|
const custom = (customUrl || "").trim()
|
||||||
if (custom) return custom
|
if (custom) return custom
|
||||||
if (!ip || ip === "DHCP" || !port) return null
|
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,
|
// Suggest a dpkg/apk package name from a friendly app name — lowercase,
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ interface DiskObservation {
|
|||||||
occurrence_count: number
|
occurrence_count: number
|
||||||
raw_message: string
|
raw_message: string
|
||||||
severity: string
|
severity: string
|
||||||
dismissed: boolean
|
|
||||||
device_name: string
|
device_name: string
|
||||||
serial: string
|
serial: string
|
||||||
model: string
|
model: string
|
||||||
@@ -2887,7 +2886,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
const severityLabel = severityText(obs.severity)
|
const severityLabel = severityText(obs.severity)
|
||||||
const firstDate = obs.first_occurrence ? new Date(obs.first_occurrence).toLocaleString() : na
|
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 lastDate = obs.last_occurrence ? new Date(obs.last_occurrence).toLocaleString() : na
|
||||||
const dismissedBadge = obs.dismissed ? `<span style="background:#16a34a20;color:#16a34a;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:4px;">${t("storage.smartReport.dismissed")}</span>` : ''
|
|
||||||
const errorTypeLabel = observationTypeText(type)
|
const errorTypeLabel = observationTypeText(type)
|
||||||
|
|
||||||
obsItemsHtml += `
|
obsItemsHtml += `
|
||||||
@@ -2897,7 +2895,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
<span style="background:${severityBadgeColor}20;color:${severityBadgeColor};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${severityLabel}</span>
|
<span style="background:${severityBadgeColor}20;color:${severityBadgeColor};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${severityLabel}</span>
|
||||||
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">ID: #${obs.id}</span>
|
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">ID: #${obs.id}</span>
|
||||||
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">${t("storage.smartReport.occurrences")}: <strong>${obs.occurrence_count}</strong></span>
|
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">${t("storage.smartReport.occurrences")}: <strong>${obs.occurrence_count}</strong></span>
|
||||||
${dismissedBadge}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:10px;">
|
<div style="margin-bottom:10px;">
|
||||||
|
|||||||
@@ -3268,15 +3268,12 @@ def _disk_errors(ctx):
|
|||||||
"""
|
"""
|
||||||
records = _recorded_disk_events()
|
records = _recorded_disk_events()
|
||||||
if not records:
|
if not records:
|
||||||
# An empty store and a store whose entries were all dismissed
|
# An empty store does not support the claim that no disk ever
|
||||||
# look the same from here, and neither supports the claim that
|
# reported an error: the monitor may not have been running when
|
||||||
# no disk reported an error. Saying which of the two it is is
|
# one did. Saying there is nothing to grade is what can be said.
|
||||||
# not possible; saying there is nothing to grade is.
|
|
||||||
return {"classification": CLASS_NOT_APPLICABLE,
|
return {"classification": CLASS_NOT_APPLICABLE,
|
||||||
"summary_key": "noEvents",
|
"summary_key": "noEvents",
|
||||||
"evidence": "The health monitor holds no undismissed disk "
|
"evidence": "The health monitor holds no disk event."}
|
||||||
"event. A dismissed observation is dropped by "
|
|
||||||
"the monitor and is not read back here."}
|
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
recent_days = ctx.policy.threshold("disk_error_recent_days")
|
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 "
|
evidence += ("\nRecorded because Linux reported them; neither SMART nor "
|
||||||
"Proxmox surfaces these, and both may report the device as "
|
"Proxmox surfaces these, and both may report the device as "
|
||||||
"healthy. Stated as separate warnings without overriding "
|
"healthy. Stated as separate warnings without overriding "
|
||||||
"that current health result. Events the reader dismissed are not "
|
"that current health result. Every recorded event is "
|
||||||
"listed: the "
|
"listed: the history of a disk is permanent. "
|
||||||
"monitor drops them and this reads what the monitor keeps. "
|
|
||||||
"Recorded by the health monitor as events occurred. SMART "
|
"Recorded by the health monitor as events occurred. SMART "
|
||||||
"reports the device's present opinion of itself and is read "
|
"reports the device's present opinion of itself and is read "
|
||||||
"by a separate check; a device can report healthy while its "
|
"by a separate check; a device can report healthy while its "
|
||||||
|
|||||||
@@ -587,6 +587,33 @@ def _disk_observations() -> dict[str, list[dict[str, Any]]]:
|
|||||||
return grouped
|
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]]:
|
def _physical_disks(ctx) -> list[dict[str, Any]]:
|
||||||
observations = _disk_observations()
|
observations = _disk_observations()
|
||||||
# The SMART cache is keyed by device, each entry a (collected_at, data)
|
# 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"),
|
"health": health.get("smart_status"),
|
||||||
"temperature": health.get("temperature"),
|
"temperature": health.get("temperature"),
|
||||||
"power_on_hours": health.get("power_on_hours"),
|
"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"])
|
return sorted(disks, key=lambda d: d["name"])
|
||||||
|
|
||||||
|
|||||||
@@ -325,12 +325,6 @@ def cleanup_orphan_errors():
|
|||||||
health_persistence.resolve_error(err_key, 'Device no longer present (manual cleanup)')
|
health_persistence.resolve_error(err_key, 'Device no longer present (manual cleanup)')
|
||||||
cleaned.append({'error_key': err_key, 'device': dev_path})
|
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({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'cleaned_count': len(cleaned),
|
'cleaned_count': len(cleaned),
|
||||||
|
|||||||
@@ -895,6 +895,20 @@ def _pve_webhook_url() -> str:
|
|||||||
_PVE_WEBHOOK_URL = _pve_webhook_url()
|
_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):
|
def _pve_read_file(path):
|
||||||
"""Read file, return (content, error). Content is '' if missing."""
|
"""Read file, return (content, error). Content is '' if missing."""
|
||||||
try:
|
try:
|
||||||
@@ -909,26 +923,47 @@ def _pve_read_file(path):
|
|||||||
|
|
||||||
|
|
||||||
_PVE_BACKUPS_KEPT = 3
|
_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):
|
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
|
A copy identical to the newest one is not written again and only the
|
||||||
size is limited: a copy identical to the newest one is not written again
|
newest few are kept. Restore uses the newest.
|
||||||
and only the newest few are kept. Restore uses the newest.
|
|
||||||
"""
|
"""
|
||||||
import os, shutil
|
import os, shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return
|
return
|
||||||
directory, name = os.path.split(path)
|
os.makedirs(_PVE_BACKUP_DIR, mode=0o700, exist_ok=True)
|
||||||
prefix = f"{name}.proxmenux_backup_"
|
os.chmod(_PVE_BACKUP_DIR, 0o700)
|
||||||
existing = sorted(
|
existing = _pve_backups(path)
|
||||||
(os.path.join(directory, f) for f in os.listdir(directory) if f.startswith(prefix)),
|
|
||||||
key=os.path.getmtime, reverse=True,
|
|
||||||
)
|
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
current = f.read()
|
current = f.read()
|
||||||
newest_matches = False
|
newest_matches = False
|
||||||
@@ -940,16 +975,48 @@ def _pve_backup_file(path):
|
|||||||
pass
|
pass
|
||||||
if not newest_matches:
|
if not newest_matches:
|
||||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
shutil.copy2(path, os.path.join(directory, prefix + ts))
|
destination = os.path.join(_PVE_BACKUP_DIR, _pve_backup_prefix(path) + ts)
|
||||||
existing = sorted(
|
shutil.copy2(path, destination)
|
||||||
(os.path.join(directory, f) for f in os.listdir(directory) if f.startswith(prefix)),
|
# The private file holds the secrets of every notification target.
|
||||||
key=os.path.getmtime, reverse=True,
|
os.chmod(destination, 0o600)
|
||||||
)
|
_pve_prune_backups(path)
|
||||||
for stale in existing[_PVE_BACKUPS_KEPT:]:
|
except Exception:
|
||||||
try:
|
pass
|
||||||
os.remove(stale)
|
|
||||||
except OSError:
|
|
||||||
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1091,6 +1158,11 @@ def setup_pve_webhook_core() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
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) ──
|
# ── Step 1: Ensure webhook secret exists (for our own internal use) ──
|
||||||
secret = notification_manager.get_webhook_secret()
|
secret = notification_manager.get_webhook_secret()
|
||||||
if not secret:
|
if not secret:
|
||||||
@@ -1109,11 +1181,10 @@ def setup_pve_webhook_core() -> dict:
|
|||||||
if err:
|
if err:
|
||||||
priv_text = None
|
priv_text = None
|
||||||
|
|
||||||
# ── Step 4: Create backups before ANY modification ──
|
# ── Step 4: Backups are taken below, only of a file about to change ──
|
||||||
_pve_backup_file(_PVE_NOTIFICATIONS_CFG)
|
_pve_collect_legacy_backups(_PVE_NOTIFICATIONS_CFG)
|
||||||
if priv_text is not None:
|
_pve_collect_legacy_backups(_PVE_PRIV_CFG)
|
||||||
_pve_backup_file(_PVE_PRIV_CFG)
|
|
||||||
|
|
||||||
# ── Step 5: Remove any previous proxmenux blocks from BOTH files ──
|
# ── Step 5: Remove any previous proxmenux blocks from BOTH files ──
|
||||||
cleaned_cfg = _pve_remove_our_blocks(cfg_text, _PVE_OUR_HEADERS)
|
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
|
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:
|
try:
|
||||||
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
if new_cfg != cfg_text:
|
||||||
f.write(new_cfg)
|
_pve_backup_file(_PVE_NOTIFICATIONS_CFG)
|
||||||
|
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
||||||
|
f.write(new_cfg)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}'
|
result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}'
|
||||||
result['fallback_commands'] = _build_webhook_fallback()
|
result['fallback_commands'] = _build_webhook_fallback()
|
||||||
@@ -1214,8 +1287,11 @@ def setup_pve_webhook_core() -> dict:
|
|||||||
new_priv = priv_block
|
new_priv = priv_block
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(_PVE_PRIV_CFG, 'w') as f:
|
if new_priv != priv_text:
|
||||||
f.write(new_priv)
|
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:
|
except PermissionError:
|
||||||
result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}'
|
result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}'
|
||||||
result['fallback_commands'] = _build_webhook_fallback()
|
result['fallback_commands'] = _build_webhook_fallback()
|
||||||
@@ -1238,6 +1314,17 @@ def setup_pve_webhook_core() -> dict:
|
|||||||
return result
|
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'])
|
@notification_bp.route('/api/notifications/proxmox/setup-webhook', methods=['POST'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def setup_proxmox_webhook():
|
def setup_proxmox_webhook():
|
||||||
@@ -1254,6 +1341,10 @@ def cleanup_pve_webhook_core() -> dict:
|
|||||||
result = {'cleaned': False, 'error': None}
|
result = {'cleaned': False, 'error': None}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if not _pve_cluster_fs_mounted():
|
||||||
|
result['error'] = _PVE_NOT_MOUNTED
|
||||||
|
return result
|
||||||
|
|
||||||
# Read both files
|
# Read both files
|
||||||
cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG)
|
cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG)
|
||||||
if err:
|
if err:
|
||||||
@@ -1336,10 +1427,6 @@ def read_pve_notification_cfg():
|
|||||||
'priv_cfg': '/etc/pve/priv/notifications.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 = {}
|
result = {}
|
||||||
for key, path in files.items():
|
for key, path in files.items():
|
||||||
try:
|
try:
|
||||||
@@ -1357,24 +1444,18 @@ def read_pve_notification_cfg():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
result[key] = {'path': path, 'content': None, 'size': 0, 'error': str(e)}
|
result[key] = {'path': path, 'content': None, 'size': 0, 'error': str(e)}
|
||||||
|
|
||||||
# Find backups
|
# The copies kept of both files
|
||||||
backups = []
|
backups = []
|
||||||
for d in [backup_dir, priv_backup_dir]:
|
for fpath in _pve_backups(_PVE_NOTIFICATIONS_CFG) + _pve_backups(_PVE_PRIV_CFG):
|
||||||
try:
|
try:
|
||||||
for fname in sorted(os.listdir(d)):
|
with open(fpath, 'r') as f:
|
||||||
if 'proxmenux_backup' in fname:
|
backups.append({
|
||||||
fpath = os.path.join(d, fname)
|
'path': fpath,
|
||||||
try:
|
'content': f.read(),
|
||||||
with open(fpath, 'r') as f:
|
'size': os.path.getsize(fpath),
|
||||||
backups.append({
|
})
|
||||||
'path': fpath,
|
|
||||||
'content': f.read(),
|
|
||||||
'size': os.path.getsize(fpath),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
backups.append({'path': fpath, 'content': None, 'error': 'read_failed'})
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
backups.append({'path': fpath, 'content': None, 'error': 'read_failed'})
|
||||||
|
|
||||||
result['backups'] = backups
|
result['backups'] = backups
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
@@ -1391,35 +1472,26 @@ def restore_pve_notification_cfg():
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
files_to_restore = {
|
|
||||||
'/etc/pve': '/etc/pve/notifications.cfg',
|
|
||||||
'/etc/pve/priv': '/etc/pve/priv/notifications.cfg',
|
|
||||||
}
|
|
||||||
|
|
||||||
restored = []
|
restored = []
|
||||||
errors = []
|
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:
|
if candidates:
|
||||||
candidates.sort(
|
# Content only: the cluster filesystem sets its own permissions.
|
||||||
key=lambda f: os.path.getmtime(os.path.join(search_dir, f)),
|
with open(candidates[0], 'rb') as source:
|
||||||
reverse=True,
|
content = source.read()
|
||||||
)
|
with open(target_path, 'wb') as target:
|
||||||
backup_path = os.path.join(search_dir, candidates[0])
|
target.write(content)
|
||||||
shutil.copy2(backup_path, target_path)
|
restored.append({'target': target_path, 'from_backup': candidates[0]})
|
||||||
restored.append({'target': target_path, 'from_backup': backup_path})
|
|
||||||
else:
|
else:
|
||||||
errors.append({'target': target_path, 'error': 'no_backup_found'})
|
errors.append({'target': target_path, 'error': 'no_backup_found'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -629,10 +629,22 @@ def _get_lxc_ip_info_cached(vmid):
|
|||||||
if vmid_int in _lxc_ip_cache:
|
if vmid_int in _lxc_ip_cache:
|
||||||
return _lxc_ip_cache[vmid_int]
|
return _lxc_ip_cache[vmid_int]
|
||||||
info = get_lxc_ip_from_lxc_info(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
|
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):
|
def _get_lxc_primary_ip_cached(vmid):
|
||||||
"""Return the primary IP for legacy callers of the LXC IP cache."""
|
"""Return the primary IP for legacy callers of the LXC IP cache."""
|
||||||
info = _get_lxc_ip_info_cached(vmid)
|
info = _get_lxc_ip_info_cached(vmid)
|
||||||
@@ -673,6 +685,27 @@ def _invalidate_lxc_ip(vmid):
|
|||||||
pass
|
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:
|
def _warmup_lxc_ip_cache() -> int:
|
||||||
"""Populate the LXC IP cache for every running CT at Monitor
|
"""Populate the LXC IP cache for every running CT at Monitor
|
||||||
startup. After this runs, /api/vms serves the IPs from memory
|
startup. After this runs, /api/vms serves the IPs from memory
|
||||||
@@ -700,7 +733,7 @@ def _warmup_lxc_ip_cache() -> int:
|
|||||||
continue
|
continue
|
||||||
if parts[1].lower() != 'running':
|
if parts[1].lower() != 'running':
|
||||||
continue
|
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
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@@ -793,6 +826,8 @@ def get_lxc_ip_from_lxc_info(vmid):
|
|||||||
else:
|
else:
|
||||||
# Real network IPs (192.168.x.x, 10.x.x.x, etc.)
|
# Real network IPs (192.168.x.x, 10.x.x.x, etc.)
|
||||||
real_ips.append(ip)
|
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)
|
isolated = _lxc_isolated_ips(vmid)
|
||||||
if isolated:
|
if isolated:
|
||||||
real_ips.sort(key=lambda ip: ip in 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'),
|
size_bytes=disk_info.get('size_bytes'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch observation counts AFTER registration so consolidated
|
# Mark disks no longer present as removed, including the one whose
|
||||||
# entries are already merged (ata8 -> sdh).
|
# connector now holds a different disk.
|
||||||
obs_counts = health_persistence.get_disks_observation_counts()
|
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():
|
for disk_name, disk_info in physical_disks.items():
|
||||||
# Attach observation count: try serial match first, then device name
|
disk_info['observations_count'] = health_persistence.count_disk_observations(
|
||||||
serial = disk_info.get('serial', '')
|
disk_name, 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()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -22844,6 +22875,7 @@ if __name__ == '__main__':
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] LXC IP warmup failed: {e}",
|
print(f"[ProxMenux] LXC IP warmup failed: {e}",
|
||||||
file=sys.stderr, flush=True)
|
file=sys.stderr, flush=True)
|
||||||
|
threading.Thread(target=_restore_lxc_dhcp_clients, daemon=True).start()
|
||||||
# Preload custom weblinks so the first Apps dashboard fetch
|
# Preload custom weblinks so the first Apps dashboard fetch
|
||||||
# is served straight from memory (0 disk I/O).
|
# is served straight from memory (0 disk I/O).
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -463,9 +463,8 @@ class HealthPersistence:
|
|||||||
# and looks "new" again on the next boot. Audit Tier 5 — Health stack.
|
# and looks "new" again on the next boot. Audit Tier 5 — Health stack.
|
||||||
#
|
#
|
||||||
# IMPORTANT: Only cleans the `errors` table (health monitor state).
|
# IMPORTANT: Only cleans the `errors` table (health monitor state).
|
||||||
# The `disk_observations` table is a PERMANENT historical record
|
# The `disk_observations` table is a PERMANENT historical record:
|
||||||
# and must NEVER be auto-modified on startup. Users dismiss
|
# nothing removes, hides or dismisses an observation.
|
||||||
# observations manually from the disk detail UI.
|
|
||||||
#
|
#
|
||||||
# Covers: disk I/O (smart_*, disk_*), VM/CT (vm_*, ct_*, vmct_*),
|
# Covers: disk I/O (smart_*, disk_*), VM/CT (vm_*, ct_*, vmct_*),
|
||||||
# and log errors (log_*) — all journal-sourced categories.
|
# and log errors (log_*) — all journal-sourced categories.
|
||||||
@@ -510,6 +509,30 @@ class HealthPersistence:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HealthPersistence] Startup cleanup warning: {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()
|
conn.close()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1437,12 +1460,8 @@ class HealthPersistence:
|
|||||||
# Clean up errors for resources that no longer exist (VMs/CTs deleted, disks removed)
|
# Clean up errors for resources that no longer exist (VMs/CTs deleted, disks removed)
|
||||||
self._cleanup_stale_resources()
|
self._cleanup_stale_resources()
|
||||||
|
|
||||||
# NOTE: cleanup_orphan_observations() is deliberately NOT invoked here.
|
# NOTE: nothing here touches `disk_observations`. That table is the
|
||||||
# Running it on the 5-minute auto-resolve cycle silently dismissed legitimate
|
# permanent history of each disk and no cycle removes or hides rows.
|
||||||
# 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).
|
|
||||||
|
|
||||||
def _cleanup_stale_resources(self):
|
def _cleanup_stale_resources(self):
|
||||||
"""Resolve errors for resources that no longer exist.
|
"""Resolve errors for resources that no longer exist.
|
||||||
@@ -2560,7 +2579,7 @@ class HealthPersistence:
|
|||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT id FROM disk_registry
|
SELECT id FROM disk_registry
|
||||||
WHERE device_name = ? AND serial != ''
|
WHERE device_name = ? AND serial != ''
|
||||||
ORDER BY last_seen DESC LIMIT 1
|
ORDER BY removed ASC, last_seen DESC LIMIT 1
|
||||||
''', (clean_dev,))
|
''', (clean_dev,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
@@ -2663,17 +2682,10 @@ class HealthPersistence:
|
|||||||
first_col = 'first_occurrence' if 'first_occurrence' in columns else 'first_seen'
|
first_col = 'first_occurrence' if 'first_occurrence' in columns else 'first_seen'
|
||||||
last_col = 'last_occurrence' if 'last_occurrence' in columns else 'last_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.
|
# The same observation seen again adds to its count and moves its
|
||||||
# IMPORTANT: Do NOT reset dismissed — if the user dismissed this observation,
|
# last date; a different one is a new row. Observations are never
|
||||||
# re-detecting the same journal entry must not un-dismiss it. BUT we DO
|
# dismissed nor hidden: the `dismissed` column stays in the table
|
||||||
# keep counting + updating last_occurrence even when dismissed, because the
|
# for databases of earlier versions and is not used.
|
||||||
# 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).
|
|
||||||
cursor.execute(f'''
|
cursor.execute(f'''
|
||||||
INSERT INTO disk_observations
|
INSERT INTO disk_observations
|
||||||
(disk_registry_id, {type_col}, error_signature, {first_col},
|
(disk_registry_id, {type_col}, error_signature, {first_col},
|
||||||
@@ -2691,13 +2703,90 @@ class HealthPersistence:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HealthPersistence] Error recording disk observation: {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,
|
def get_disk_observations(self, device_name: Optional[str] = None,
|
||||||
serial: Optional[str] = None) -> List[Dict[str, Any]]:
|
serial: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""Get active (non-dismissed) observations for one disk or all disks.
|
"""The observations of one disk, or of every disk.
|
||||||
|
|
||||||
For USB disks that may have multiple registry entries (one with serial,
|
The history of a disk is permanent and an observation cannot be
|
||||||
one without), this searches ALL registry entries matching the device_name
|
dismissed: each different one is listed, with the number of times it
|
||||||
to ensure observations are found regardless of which entry recorded them.
|
was seen.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
@@ -2712,48 +2801,29 @@ class HealthPersistence:
|
|||||||
last_col = 'last_occurrence' if 'last_occurrence' in columns else 'last_seen'
|
last_col = 'last_occurrence' if 'last_occurrence' in columns else 'last_seen'
|
||||||
|
|
||||||
if device_name or serial:
|
if device_name or serial:
|
||||||
clean_dev = (device_name or '').replace('/dev/', '')
|
scope = self._disk_observation_scope(cursor, device_name, serial)
|
||||||
|
if scope is None:
|
||||||
# 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:
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return []
|
return []
|
||||||
|
where, params = scope
|
||||||
# Query observations for ALL matching registry entries
|
|
||||||
placeholders = ','.join('?' * len(all_ids))
|
|
||||||
cursor.execute(f'''
|
cursor.execute(f'''
|
||||||
SELECT o.id, o.{type_col}, o.error_signature,
|
SELECT o.id, o.{type_col}, o.error_signature,
|
||||||
o.{first_col}, o.{last_col},
|
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
|
d.device_name, d.serial, d.model
|
||||||
FROM disk_observations o
|
FROM disk_observations o
|
||||||
JOIN disk_registry d ON o.disk_registry_id = d.id
|
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
|
ORDER BY o.{last_col} DESC
|
||||||
''', all_ids)
|
''', params)
|
||||||
else:
|
else:
|
||||||
cursor.execute(f'''
|
cursor.execute(f'''
|
||||||
SELECT o.id, o.{type_col}, o.error_signature,
|
SELECT o.id, o.{type_col}, o.error_signature,
|
||||||
o.{first_col}, o.{last_col},
|
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
|
d.device_name, d.serial, d.model
|
||||||
FROM disk_observations o
|
FROM disk_observations o
|
||||||
JOIN disk_registry d ON o.disk_registry_id = d.id
|
JOIN disk_registry d ON o.disk_registry_id = d.id
|
||||||
WHERE o.dismissed = 0
|
|
||||||
ORDER BY o.{last_col} DESC
|
ORDER BY o.{last_col} DESC
|
||||||
''')
|
''')
|
||||||
|
|
||||||
@@ -2769,10 +2839,9 @@ class HealthPersistence:
|
|||||||
'occurrence_count': r[5],
|
'occurrence_count': r[5],
|
||||||
'raw_message': r[6] or '',
|
'raw_message': r[6] or '',
|
||||||
'severity': r[7],
|
'severity': r[7],
|
||||||
'dismissed': bool(r[8]),
|
'device_name': r[8],
|
||||||
'device_name': r[9],
|
'serial': r[9],
|
||||||
'serial': r[10],
|
'model': r[10],
|
||||||
'model': r[11],
|
|
||||||
} for r in rows]
|
} for r in rows]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HealthPersistence] Error getting observations: {e}")
|
print(f"[HealthPersistence] Error getting observations: {e}")
|
||||||
@@ -2794,7 +2863,6 @@ class HealthPersistence:
|
|||||||
SELECT DISTINCT dr.device_name, dr.serial
|
SELECT DISTINCT dr.device_name, dr.serial
|
||||||
FROM disk_observations o
|
FROM disk_observations o
|
||||||
JOIN disk_registry dr ON o.disk_registry_id = dr.id
|
JOIN disk_registry dr ON o.disk_registry_id = dr.id
|
||||||
WHERE o.dismissed = 0
|
|
||||||
''')
|
''')
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
return [{'device_name': r[0], 'serial': r[1] or ''} for r in rows]
|
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}")
|
print(f"[HealthPersistence] get_all_observed_devices failed: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_disks_observation_counts(self) -> Dict[str, int]:
|
def mark_removed_disks(self, active_device_names: List[str],
|
||||||
"""Return {device_name: count} of active observations per disk.
|
active_serials: Optional[Dict[str, str]] = None):
|
||||||
|
"""Mark disks not in active_device_names as removed. With
|
||||||
Groups by serial when available to consolidate counts across device name changes
|
`active_serials` ({device name: serial of the disk there now}), a disk
|
||||||
(e.g., ata8 -> sdh). Also includes serial-keyed entries for cross-device matching.
|
whose name was taken by a different one is marked removed as well."""
|
||||||
"""
|
|
||||||
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."""
|
|
||||||
try:
|
try:
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
@@ -2930,71 +2885,19 @@ class HealthPersistence:
|
|||||||
UPDATE disk_registry SET removed = 1
|
UPDATE disk_registry SET removed = 1
|
||||||
WHERE device_name NOT IN ({placeholders}) AND removed = 0
|
WHERE device_name NOT IN ({placeholders}) AND removed = 0
|
||||||
''', active_device_names)
|
''', 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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HealthPersistence] Error marking removed disks: {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/<name> 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 ──
|
# ── Remote Storage Exclusions Methods ──
|
||||||
|
|
||||||
# Types considered "remote" and eligible for exclusion
|
# Types considered "remote" and eligible for exclusion
|
||||||
|
|||||||
@@ -5596,7 +5596,11 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
# While an update or recreation runs, the record is the copy taken before
|
# 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.
|
# 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
|
return None
|
||||||
template = record.get("template") or {}
|
template = record.get("template") or {}
|
||||||
contract = template.get("container_contract") or {}
|
contract = template.get("container_contract") or {}
|
||||||
|
|||||||
@@ -1141,6 +1141,11 @@ class NotificationManager:
|
|||||||
wh_result = setup_pve_webhook_core()
|
wh_result = setup_pve_webhook_core()
|
||||||
if wh_result.get('configured'):
|
if wh_result.get('configured'):
|
||||||
print("[NotificationManager] PVE webhook configured OK.")
|
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'):
|
elif wh_result.get('error'):
|
||||||
print(f"[NotificationManager] PVE webhook warning: {wh_result['error']}")
|
print(f"[NotificationManager] PVE webhook warning: {wh_result['error']}")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -2285,6 +2290,18 @@ class NotificationManager:
|
|||||||
'auth_fail',
|
'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):
|
def _reset_cooldowns_on_start(self):
|
||||||
"""Clear DB rows in notification_last_sent for the curated set of
|
"""Clear DB rows in notification_last_sent for the curated set of
|
||||||
event types listed in `_EVENT_TYPES_RESET_ON_START`.
|
event types listed in `_EVENT_TYPES_RESET_ON_START`.
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ def main():
|
|||||||
print(f" First: {obs['first_occurrence']}")
|
print(f" First: {obs['first_occurrence']}")
|
||||||
print(f" Last: {obs['last_occurrence']}")
|
print(f" Last: {obs['last_occurrence']}")
|
||||||
print(f" Count: {obs['occurrence_count']}")
|
print(f" Count: {obs['occurrence_count']}")
|
||||||
print(f" Dismissed: {obs['dismissed']}")
|
|
||||||
print()
|
print()
|
||||||
else:
|
else:
|
||||||
print(" No observations found!\n")
|
print(" No observations found!\n")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -89,5 +89,47 @@ class LxcVmIpInventoryTests(unittest.TestCase):
|
|||||||
self.assertNotIn("ip", rows[0])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ class CatalogFixture(unittest.TestCase):
|
|||||||
item.start()
|
item.start()
|
||||||
self.addCleanup(item.stop)
|
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').mkdir(parents=True)
|
||||||
(self.root / 'instances/190/oci-compose.json').write_text(json.dumps({
|
(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'}},
|
'observed': {'image': {'manifest_digest': 'sha256:' + 'b1' * 32, 'architecture': 'amd64'}},
|
||||||
'template': {'id': template_id, 'catalog_ui': {'title': 'Frigate'},
|
'template': {'id': template_id, 'catalog_ui': {'title': 'Frigate'},
|
||||||
'first_run': {'endpoints': endpoints},
|
'first_run': {'endpoints': endpoints},
|
||||||
@@ -58,6 +58,12 @@ class OciEndpointTests(CatalogFixture):
|
|||||||
])
|
])
|
||||||
self.assertEqual(meta['endpoint_port'], 5000)
|
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):
|
def test_single_endpoint_and_unknown_template(self):
|
||||||
meta = self.install([ENDPOINTS[0]], template_id='image-other')
|
meta = self.install([ENDPOINTS[0]], template_id='image-other')
|
||||||
self.assertEqual(meta['endpoints'], [
|
self.assertEqual(meta['endpoints'], [
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -623,7 +623,7 @@ _proxmenux_rewrite_monitor_unit_for_apprun() {
|
|||||||
cat > "$MONITOR_SERVICE_FILE" << EOF
|
cat > "$MONITOR_SERVICE_FILE" << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=ProxMenux Monitor - Web Dashboard
|
Description=ProxMenux Monitor - Web Dashboard
|
||||||
After=network.target
|
After=network.target pve-cluster.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
@@ -655,7 +655,7 @@ create_monitor_service() {
|
|||||||
cat > "$MONITOR_SERVICE_FILE" << EOF
|
cat > "$MONITOR_SERVICE_FILE" << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=ProxMenux Monitor - Web Dashboard
|
Description=ProxMenux Monitor - Web Dashboard
|
||||||
After=network.target
|
After=network.target pve-cluster.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ update_monitor_service() {
|
|||||||
cat > "$MONITOR_SERVICE_FILE" << EOF
|
cat > "$MONITOR_SERVICE_FILE" << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=ProxMenux Monitor - Web Dashboard (Beta)
|
Description=ProxMenux Monitor - Web Dashboard (Beta)
|
||||||
After=network.target
|
After=network.target pve-cluster.service
|
||||||
Before=shutdown.target reboot.target halt.target
|
Before=shutdown.target reboot.target halt.target
|
||||||
Conflicts=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
|
cat > "$MONITOR_SERVICE_FILE" << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=ProxMenux Monitor - Web Dashboard (Beta)
|
Description=ProxMenux Monitor - Web Dashboard (Beta)
|
||||||
After=network.target
|
After=network.target pve-cluster.service
|
||||||
Before=shutdown.target reboot.target halt.target
|
Before=shutdown.target reboot.target halt.target
|
||||||
Conflicts=shutdown.target reboot.target halt.target
|
Conflicts=shutdown.target reboot.target halt.target
|
||||||
|
|
||||||
|
|||||||
+22
-9
@@ -390,7 +390,7 @@
|
|||||||
"Application": "Aplicación",
|
"Application": "Aplicación",
|
||||||
"Application Specific": "Específico de la aplicación",
|
"Application Specific": "Específico de la aplicación",
|
||||||
"Application registered on this host:": "Aplicación registrada en este host:",
|
"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 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 suite: one independent LXC per selected application": "Suite de aplicación: un LXC independiente por aplicación seleccionada",
|
||||||
"Application:": "Aplicación:",
|
"Application:": "Aplicación:",
|
||||||
@@ -570,7 +570,7 @@
|
|||||||
"Backup kept in": "Backup conservado en",
|
"Backup kept in": "Backup conservado en",
|
||||||
"Backup location": "Ubicación del backup",
|
"Backup location": "Ubicación del backup",
|
||||||
"Backup metadata": "Metadatos de 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 newer kernel:": "Backup de un kernel más nuevo:",
|
||||||
"Backup on older kernel:": "Backup de un kernel anterior:",
|
"Backup on older kernel:": "Backup de un kernel anterior:",
|
||||||
"Backup origin metadata:": "Metadatos de origen del backup:",
|
"Backup origin metadata:": "Metadatos de origen del backup:",
|
||||||
@@ -760,6 +760,7 @@
|
|||||||
"Changes prepared": "Cambios preparados",
|
"Changes prepared": "Cambios preparados",
|
||||||
"Changes will apply after reboot.": "Los cambios se aplicarán después del reinicio.",
|
"Changes will apply after reboot.": "Los cambios se aplicarán después del reinicio.",
|
||||||
"Changing Release Channel": "Cambio de canal de release",
|
"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 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 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",
|
"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)",
|
"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",
|
"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 & Downloads": "Archivos y descargas",
|
||||||
|
"Files & Sharing": "Files & Sharing",
|
||||||
"Files volume size in GB": "Tamaño del volumen de archivos en GB",
|
"Files volume size in GB": "Tamaño del volumen de archivos en GB",
|
||||||
"Filesystem": "Sistema de archivos",
|
"Filesystem": "Sistema de archivos",
|
||||||
"Filesystem Tools Required": "Herramientas del sistema de archivos necesarias",
|
"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 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 public Proxmox repository enabled": "Repositorio público gratuito de Proxmox habilitado",
|
||||||
"Free space OK:": "Espacio libre Aceptar:",
|
"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 up disk space": "Liberar espacio en disco",
|
||||||
"Free:": "Libre:",
|
"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).",
|
"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 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)",
|
"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": "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",
|
"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 '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",
|
"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 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 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 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 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 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.",
|
"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 or part of the description of the application": "Nombre o parte de la descripción de la aplicación",
|
||||||
"Name:": "Nombre:",
|
"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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
|
"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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
|
||||||
|
"Needed:": "Necesario:",
|
||||||
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "No se encontraron /etc/kernel/cmdline ni /etc/default/grub.",
|
"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",
|
"Nesting feature enabled": "Función de anidamiento habilitada",
|
||||||
"NetBIOS Service: RUNNING": "Servicio NetBIOS: EN EJECUCIÓN",
|
"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 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 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 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 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 password provided.": "No se proporcionó contraseña.",
|
||||||
"No paths available": "No hay rutas disponibles",
|
"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 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": "Actualmente no montado",
|
||||||
"Not currently mounted — skipping umount.": "Actualmente no montado: omitiendo el desmontaje.",
|
"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": "No encontrado",
|
||||||
"Not found in the OCI archive:": "No se encuentra en el archivo OCI:",
|
"Not found in the OCI archive:": "No se encuentra en el archivo OCI:",
|
||||||
"Not imported:": "No importado:",
|
"Not imported:": "No importado:",
|
||||||
@@ -5123,7 +5128,7 @@
|
|||||||
"Restore source location": "Ubicación del origen de la restauración",
|
"Restore source location": "Ubicación del origen de la restauración",
|
||||||
"Restored OCI applications": "Aplicaciones OCI restauradas",
|
"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 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 /bin/gzip": "Restaurado original /bin/gzip",
|
||||||
"Restored original /etc/vzdump.conf from .bak": "Restaurado /etc/vzdump.conf original desde .bak",
|
"Restored original /etc/vzdump.conf from .bak": "Restaurado /etc/vzdump.conf original desde .bak",
|
||||||
"Restored:": "Restaurado:",
|
"Restored:": "Restaurado:",
|
||||||
@@ -5138,7 +5143,7 @@
|
|||||||
"Restoring the backup": "Restaurar el backup",
|
"Restoring the backup": "Restaurar el backup",
|
||||||
"Restoring the original rpcbind service state...": "Restaurando el estado original del servicio rpcbind...",
|
"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 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 previous state of the stack...": "Restaurando el estado anterior de la pila...",
|
||||||
"Restoring the stack records...": "Restaurando los registros 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:",
|
"Results will be saved automatically to:": "Los resultados se guardarán automáticamente en:",
|
||||||
@@ -5849,7 +5854,7 @@
|
|||||||
"Stop uploading?": "¿Dejar de subir?",
|
"Stop uploading?": "¿Dejar de subir?",
|
||||||
"Stopped": "Detenido",
|
"Stopped": "Detenido",
|
||||||
"Stopped and disabled": "Detenido y deshabilitado",
|
"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 Coral kernel modules...": "Deteniendo los módulos del kernel de Coral...",
|
||||||
"Stopping LXC": "Detener LXC",
|
"Stopping LXC": "Detener LXC",
|
||||||
"Stopping NFS services...": "Deteniendo los servicios NFS...",
|
"Stopping NFS services...": "Deteniendo los servicios NFS...",
|
||||||
@@ -5888,6 +5893,7 @@
|
|||||||
"Storage for the Immich library": "Almacenamiento para la biblioteca Immich",
|
"Storage for the Immich library": "Almacenamiento para la biblioteca Immich",
|
||||||
"Storage for the Nextcloud data": "Almacenamiento para los datos Nextcloud",
|
"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 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 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 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",
|
"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 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 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 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 application was removed": "La aplicación se ha eliminado",
|
||||||
"The archive could not be extracted.": "No se pudo extraer el archivo.",
|
"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 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 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 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 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 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 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 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 storage does not exist:": "El almacenamiento de backups no existe:",
|
||||||
"The backup was altered; recovery blocked": "El backup fue alterado; la recuperación bloqueada",
|
"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 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 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 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 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",
|
"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 Nextcloud adapter": "La startup difiere del adaptador Nextcloud declarado",
|
||||||
"The startup differs from the declared adapter": "La startup difiere del adaptador 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 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 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 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.",
|
"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 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 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 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 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 is encrypted.": "Este backup está cifrado.",
|
||||||
"This backup was taken on kernel": "Este backup se realizó en el kernel",
|
"This backup was taken on kernel": "Este backup se realizó en el kernel",
|
||||||
@@ -6912,7 +6925,7 @@
|
|||||||
"Updated:": "Actualizado:",
|
"Updated:": "Actualizado:",
|
||||||
"Updates all Proxmox and Debian packages": "Actualiza todos los paquetes de Proxmox y Debian",
|
"Updates all Proxmox and Debian packages": "Actualiza todos los paquetes de Proxmox y Debian",
|
||||||
"Updates and Packages Commands": "Comandos de actualizaciones y paquetes",
|
"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 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.",
|
"Updates file is empty or unreadable.": "El archivo de actualizaciones está vacío o es ilegible.",
|
||||||
"Updating": "Actualizando",
|
"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",
|
"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 Home Assistant OS...": "Esperando a Home Assistant OS...",
|
||||||
"Waiting for the FUSE mount:": "Esperando el montaje FUSE:",
|
"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 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 network address...": "Esperando la dirección de la red...",
|
||||||
"Waiting for the password of the application...": "Esperando la contraseña de la aplicación...",
|
"Waiting for the password of the application...": "Esperando la contraseña de la aplicación...",
|
||||||
|
|||||||
+25
-15
@@ -408,27 +408,37 @@ compatibility need separate review, not silent removal of these requirements.
|
|||||||
|
|
||||||
### HAOS One native OCI profile
|
### HAOS One native OCI profile
|
||||||
|
|
||||||
The community image `qweritos/haos-one:latest` is installable with the laboratory
|
The community image `ghcr.io/sevenrats/haos:latest`, derived from HAOS One, is
|
||||||
adaptation: unprivileged LXC, `ostype=unmanaged`, `nesting=1`, `keyctl=1`, and a
|
installable with the laboratory adaptation: unprivileged LXC, `ostype=unmanaged`,
|
||||||
managed `/mnt/data` volume included in Proxmox backups (32 GB default, 16 GB
|
`nesting=1`, `keyctl=1`, and a managed `/mnt/data` volume included in Proxmox
|
||||||
minimum). The image's entrypoint, command and stop signal remain imported from
|
backups (32 GB default, 16 GB minimum). The image's entrypoint, command and stop
|
||||||
OCI metadata. No Docker daemon or compatibility proxy is installed by ProxMenux;
|
signal remain imported from OCI metadata. No Docker daemon or compatibility proxy
|
||||||
the nested runtime belongs to the image itself.
|
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
|
The installer asks for explicit acknowledgement of the experimental profile and
|
||||||
its inner AppArmor limitations. First boot may pull several images. A bounded
|
its inner AppArmor limitations. First boot may pull several images. A bounded
|
||||||
20-minute check reports progress and requires healthy/supported Supervisor, the
|
20-minute check reports progress and requires healthy/supported Supervisor, the
|
||||||
real Core container rather than the landing page, running CLI/DNS/audio/multicast/
|
real Core container rather than the landing page, running CLI/DNS/audio/multicast/
|
||||||
Observer containers, and working Core and Observer HTTP endpoints. The final
|
Observer containers, and working Core and Observer HTTP endpoints. The recipe sets
|
||||||
Core URL is detected on port 80 or 8123 instead of assumed. A first-boot failure
|
`SETUP_PORT=8123`, so Core answers on port 8123 during onboarding and afterwards;
|
||||||
preserves the LXC, data and root-only console log for diagnosis; it never reports
|
the check probes 8123 first and then 80. A first-boot failure preserves the LXC,
|
||||||
success. If starting is declined, no unverified URL is printed.
|
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 upstream Compose file asks Docker to restart the container, so the watchdog
|
||||||
The historical lab evidence stays in the template; it is not a guarantee for
|
is proposed by default: a shutdown asked from Home Assistant itself is followed
|
||||||
new releases. Full backup restore, outer-image replacement, USB and multicast
|
by a new start, as it is under Docker. A reboot asked from Home Assistant is
|
||||||
discovery still need explicit tests. See the upstream project:
|
handled by Proxmox, and the DHCP address of the container is given back by
|
||||||
https://github.com/qweritos/haos-one
|
`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
|
### Native Compose resource limits
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->
|
<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->
|
||||||
|
|
||||||
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.
|
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 |
|
| Qbittorrent | Files & Downloads |
|
||||||
| Rclone WebUI | Backup & Recovery |
|
| Rclone WebUI | Backup & Recovery |
|
||||||
| Real-Debrid Torrent Client | Files & Downloads |
|
| Real-Debrid Torrent Client | Files & Downloads |
|
||||||
|
| ShareCoveX | Files & Sharing |
|
||||||
| SnapOtter | Media & Streaming |
|
| SnapOtter | Media & Streaming |
|
||||||
| Tandoor Recipes | Productivity & Workflows |
|
| Tandoor Recipes | Productivity & Workflows |
|
||||||
| Thelounge | Communication & Community |
|
| Thelounge | Communication & Community |
|
||||||
|
|||||||
+138
-142
@@ -11,12 +11,12 @@
|
|||||||
"en_US": "Community single-container Home Assistant OS image"
|
"en_US": "Community single-container Home Assistant OS image"
|
||||||
},
|
},
|
||||||
"description": {
|
"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": "smarthome",
|
||||||
"category_label": "IoT & Smart Home",
|
"category_label": "IoT & Smart Home",
|
||||||
"author": "qweritos",
|
"author": "sevenrats",
|
||||||
"developer": "qweritos",
|
"developer": "sevenrats",
|
||||||
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp",
|
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp",
|
||||||
"thumbnail": null,
|
"thumbnail": null,
|
||||||
"screenshots": [],
|
"screenshots": [],
|
||||||
@@ -26,42 +26,42 @@
|
|||||||
],
|
],
|
||||||
"launch": {
|
"launch": {
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"port": 80,
|
"port": 8123,
|
||||||
"path": "/"
|
"path": "/"
|
||||||
},
|
},
|
||||||
"website": "https://github.com/qweritos/haos-one",
|
"website": "https://github.com/sevenrats/docker-haos",
|
||||||
"documentation": "https://github.com/qweritos/haos-one#readme",
|
"documentation": "https://github.com/sevenrats/docker-haos#readme",
|
||||||
"repository": "https://github.com/qweritos/haos-one",
|
"repository": "https://github.com/sevenrats/docker-haos",
|
||||||
"tips": [
|
"tips": [
|
||||||
"This is a third-party community image and is not affiliated with Home Assistant.",
|
"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.",
|
"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.",
|
"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 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.",
|
"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. Full restore and external image update pending testing."
|
"/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": [],
|
"mini_changelog": [],
|
||||||
"display_version": null,
|
"display_version": null,
|
||||||
"updated_at": "2026-08-20"
|
"updated_at": "2026-09-23"
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"provider": "qweritos",
|
"provider": "sevenrats",
|
||||||
"repository": "https://github.com/qweritos/haos-one",
|
"repository": "https://github.com/sevenrats/docker-haos",
|
||||||
"revision": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede",
|
"revision": "e7fe7f6728b84763b76658ab5a9669f50cb029b0",
|
||||||
"image_repository_url": "https://hub.docker.com/r/qweritos/haos-one",
|
"image_repository_url": "https://github.com/sevenrats/docker-haos/pkgs/container/haos",
|
||||||
"readme_pushed_at": "2026-08-20T12:29:50Z",
|
"readme_pushed_at": "2026-09-23T15:30:10Z",
|
||||||
"compose_sha256": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede",
|
"compose_sha256": "4301a2454ca1a5ff58d596dc1f2f99eba52ab77a307f394d81e6d5f66f824111",
|
||||||
"generated_at": "2026-09-12T15:54:10+00:00",
|
"generated_at": "2026-10-07T00:00:00+00:00",
|
||||||
"default_branch": "master"
|
"default_branch": "master"
|
||||||
},
|
},
|
||||||
"container_contract": {
|
"container_contract": {
|
||||||
"service_name": "haos-one",
|
"service_name": "haos-one",
|
||||||
"container_name": "haos-one",
|
"container_name": "haos-one",
|
||||||
"image": {
|
"image": {
|
||||||
"reference": "qweritos/haos-one:latest",
|
"reference": "ghcr.io/sevenrats/haos:latest",
|
||||||
"registry": "docker.io",
|
"registry": "ghcr.io",
|
||||||
"repository": "qweritos/haos-one",
|
"repository": "sevenrats/haos",
|
||||||
"tag": "latest",
|
"tag": "latest",
|
||||||
"digest": null,
|
"digest": null,
|
||||||
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
|
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"example": "1",
|
"example": "1",
|
||||||
"required": true,
|
"required": true,
|
||||||
"sensitive": false,
|
"sensitive": false,
|
||||||
"source": "docker-compose",
|
"source": "image-default",
|
||||||
"prompt_user": false
|
"prompt_user": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,16 +80,31 @@
|
|||||||
"example": "auto",
|
"example": "auto",
|
||||||
"required": true,
|
"required": true,
|
||||||
"sensitive": false,
|
"sensitive": false,
|
||||||
"source": "docker-compose",
|
"source": "image-default",
|
||||||
"prompt_user": false
|
"prompt_user": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "DEV",
|
"name": "SETUP_PORT",
|
||||||
"example": "0",
|
"example": "8123",
|
||||||
"required": true,
|
"required": true,
|
||||||
"sensitive": false,
|
"sensitive": false,
|
||||||
"source": "docker-compose",
|
"source": "image-default",
|
||||||
"prompt_user": false
|
"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": [
|
"volumes": [
|
||||||
@@ -111,8 +126,8 @@
|
|||||||
],
|
],
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"container_port": 80,
|
"container_port": 8123,
|
||||||
"published_example": 80,
|
"published_example": 8123,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"required": true,
|
"required": true,
|
||||||
"proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
|
"proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
|
||||||
@@ -126,9 +141,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"related_services": [],
|
"related_services": [],
|
||||||
"restart": null,
|
"restart": "unless-stopped",
|
||||||
"stop_grace_period": null,
|
"stop_grace_period": "2m",
|
||||||
"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"
|
"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": {
|
"compose_stack": {
|
||||||
"project_name": "haos-one",
|
"project_name": "haos-one",
|
||||||
@@ -139,7 +154,7 @@
|
|||||||
"services": [
|
"services": [
|
||||||
{
|
{
|
||||||
"name": "haos-one",
|
"name": "haos-one",
|
||||||
"image": "qweritos/haos-one:latest",
|
"image": "ghcr.io/sevenrats/haos:latest",
|
||||||
"is_main": true,
|
"is_main": true,
|
||||||
"role": "frontend",
|
"role": "frontend",
|
||||||
"vmid_offset": 0,
|
"vmid_offset": 0,
|
||||||
@@ -147,15 +162,19 @@
|
|||||||
"frontend_network": true,
|
"frontend_network": true,
|
||||||
"private_network": false,
|
"private_network": false,
|
||||||
"compose": {
|
"compose": {
|
||||||
"image": "qweritos/haos-one:latest",
|
"image": "ghcr.io/sevenrats/haos:latest",
|
||||||
"privileged": true,
|
"privileged": true,
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"stop_grace_period": "2m",
|
||||||
"environment": {
|
"environment": {
|
||||||
"USE_DUMMY_NETWORKMANAGER": "1",
|
"USE_DUMMY_NETWORKMANAGER": "1",
|
||||||
"USE_UDEV_SHIM": "auto",
|
"USE_UDEV_SHIM": "auto",
|
||||||
"DEV": "0"
|
"SETUP_PORT": "8123",
|
||||||
|
"ENABLE_IPV6": "1",
|
||||||
|
"TZ": "Europe/Madrid"
|
||||||
},
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
"80:80",
|
"8123:8123",
|
||||||
"4357:4357"
|
"4357:4357"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
@@ -230,9 +249,9 @@
|
|||||||
{
|
{
|
||||||
"label": "Home Assistant",
|
"label": "Home Assistant",
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"port": 80,
|
"port": 8123,
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"source": "validated-current-core-profile"
|
"source": "image-setup-port"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Home Assistant Observer",
|
"label": "Home Assistant Observer",
|
||||||
@@ -269,7 +288,7 @@
|
|||||||
"nesting=1",
|
"nesting=1",
|
||||||
"keyctl=1"
|
"keyctl=1"
|
||||||
],
|
],
|
||||||
"shutdown_timeout_seconds": 60
|
"shutdown_timeout_seconds": 120
|
||||||
},
|
},
|
||||||
"image_metadata_policy": {
|
"image_metadata_policy": {
|
||||||
"entrypoint": "import-from-oci-image",
|
"entrypoint": "import-from-oci-image",
|
||||||
@@ -290,7 +309,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nested-runtime-features",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"reason": "Older builds fail to start Core and plug-ins with kernel.domainname permission denied.",
|
||||||
"behavioral_impact": "Uses the project-provided compatibility behavior unchanged.",
|
"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": {
|
"laboratory_contract": {
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"proxmox_min_version": "9.2",
|
"proxmox_min_version": "9.2",
|
||||||
"validated_proxmox_version": "9.2.11",
|
"validated_proxmox_version": "9.2.20",
|
||||||
"commands": [
|
"commands": [
|
||||||
"pct",
|
"pct",
|
||||||
"pvesm",
|
"pvesm",
|
||||||
@@ -358,7 +385,9 @@
|
|||||||
"environment_defaults": {
|
"environment_defaults": {
|
||||||
"USE_DUMMY_NETWORKMANAGER": "1",
|
"USE_DUMMY_NETWORKMANAGER": "1",
|
||||||
"USE_UDEV_SHIM": "auto",
|
"USE_UDEV_SHIM": "auto",
|
||||||
"SETUP_PORT": null,
|
"SETUP_PORT": "8123",
|
||||||
|
"ENABLE_IPV6": "1",
|
||||||
|
"TZ": "UTC",
|
||||||
"DEV": "0"
|
"DEV": "0"
|
||||||
},
|
},
|
||||||
"preserve_without_override": [
|
"preserve_without_override": [
|
||||||
@@ -428,7 +457,7 @@
|
|||||||
"type": "device-list",
|
"type": "device-list",
|
||||||
"required": false,
|
"required": false,
|
||||||
"default": [],
|
"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": {
|
"deployment": {
|
||||||
@@ -457,9 +486,9 @@
|
|||||||
],
|
],
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"port": 80,
|
"port": 8123,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"purpose": "validated-home-assistant-web-for-core-2026.9.1"
|
"purpose": "home-assistant-web-fixed-by-SETUP_PORT"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"port": 4357,
|
"port": 4357,
|
||||||
@@ -469,10 +498,10 @@
|
|||||||
],
|
],
|
||||||
"conditional_ports": [
|
"conditional_ports": [
|
||||||
{
|
{
|
||||||
"port": 8123,
|
"port": 80,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"condition": "older-core-or-landing-page-stage-or-explicit-SETUP_PORT",
|
"condition": "SETUP_PORT-emptied-on-core-2026.8-or-newer",
|
||||||
"warning": "Do not assume port 8123 remains active after onboarding on Home Assistant 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",
|
"data_role": "persistent-user-and-supervisor-state",
|
||||||
"restart_test": {
|
"restart_test": {
|
||||||
"status": "passed",
|
"status": "passed",
|
||||||
"method": "write-marker-shutdown-start-verify-hash-and-services",
|
"method": "write-marker-reboot-shutdown-start-verify-hash-and-services",
|
||||||
"marker_sha256": "6c3762cede4f86dd5eeae16eff1067e188e9b47646f48ccc268c077711bffbd0",
|
"marker_sha256": "ddcc5557ff607ffc6ef0cdc54484f213c28f65b5ebde1c02cd8eefd2fa6b7c5e",
|
||||||
"volume_before": "local-lvm:vm-128-disk-1",
|
"volume_before": "local-lvm:vm-121-disk-1",
|
||||||
"volume_after": "local-lvm:vm-128-disk-1",
|
"volume_after": "local-lvm:vm-121-disk-1",
|
||||||
"home_assistant_after_restart": "passed-http-200",
|
"home_assistant_after_restart": "passed-http-port-8123",
|
||||||
"inner_containers_after_restart": "passed-all-running"
|
"inner_containers_after_restart": "passed-all-running"
|
||||||
},
|
},
|
||||||
"native_backup": {
|
"native_backup": {
|
||||||
"expected": "included-by-mp0-backup-1",
|
"expected": "included-by-mp0-backup-1",
|
||||||
"full-vzdump-restore-test": "pending"
|
"full-vzdump-restore-test": "passed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"installation_steps": [
|
"installation_steps": [
|
||||||
@@ -516,7 +545,7 @@
|
|||||||
"Attach a host-managed network interface to the selected bridge.",
|
"Attach a host-managed network interface to the selected bridge.",
|
||||||
"Preserve the imported /entrypoint.sh /sbin/init command and SIGRTMIN+3 stop signal.",
|
"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.",
|
"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.",
|
"Require Supervisor healthy=true and supported=true before reporting success.",
|
||||||
"Verify Core, CLI, DNS, audio, multicast and observer containers are running.",
|
"Verify Core, CLI, DNS, audio, multicast and observer containers are running.",
|
||||||
"Report non-blocking HAOS resolution issues separately."
|
"Report non-blocking HAOS resolution issues separately."
|
||||||
@@ -526,8 +555,8 @@
|
|||||||
"name": "home-assistant-web",
|
"name": "home-assistant-web",
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"candidate_urls": [
|
"candidate_urls": [
|
||||||
"http://${container_ip}/",
|
"http://${container_ip}:8123/",
|
||||||
"http://${container_ip}:8123/"
|
"http://${container_ip}/"
|
||||||
],
|
],
|
||||||
"expected_status": 200,
|
"expected_status": 200,
|
||||||
"startup_grace_seconds": 300
|
"startup_grace_seconds": 300
|
||||||
@@ -551,14 +580,14 @@
|
|||||||
"update_strategy": {
|
"update_strategy": {
|
||||||
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
|
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
|
||||||
"outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data",
|
"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": [
|
"required_before_update": [
|
||||||
"create-and-download-a-full-home-assistant-backup",
|
"create-and-download-a-full-home-assistant-backup",
|
||||||
"create-a-native-proxmox-backup-including-mp0",
|
"create-a-native-proxmox-backup-including-mp0",
|
||||||
"record-current-image-digest",
|
"record-current-image-digest",
|
||||||
"verify-new-image-contains-required-compatibility-rules"
|
"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": {
|
"security": {
|
||||||
"image_is_official_home_assistant": false,
|
"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."
|
"Do not expose Home Assistant or Observer directly to the Internet without an authenticated reverse proxy and normal Home Assistant hardening."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"incompatible_builds": [
|
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"notes": [
|
"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.",
|
"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.",
|
"The direct Proxmox OCI path removes one extra Docker layer compared with a Proxmox LXC plus Docker plus this image deployment.",
|
||||||
"Proxmox privileged OCI import failed with setgid(0) Invalid argument; the validated profile is unprivileged and functional.",
|
"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.",
|
"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.",
|
"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.",
|
"A clean first boot used approximately 4.7G under /mnt/data; 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.",
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"references": {
|
||||||
"project": "https://github.com/qweritos/haos-one",
|
"project": "https://github.com/sevenrats/docker-haos",
|
||||||
"docker_image": "https://hub.docker.com/r/qweritos/haos-one",
|
"docker_image": "https://github.com/sevenrats/docker-haos/pkgs/container/haos",
|
||||||
"dockerfile": "https://github.com/qweritos/haos-one/blob/master/Dockerfile",
|
"dockerfile": "https://github.com/sevenrats/docker-haos/blob/master/Dockerfile",
|
||||||
"compatibility_documentation": "https://github.com/qweritos/haos-one/blob/master/docs/haos-one-compat.md",
|
"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",
|
"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": {
|
"installer_profile": {
|
||||||
@@ -634,22 +656,25 @@
|
|||||||
"volumes"
|
"volumes"
|
||||||
],
|
],
|
||||||
"untranslated_blockers": [],
|
"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": {
|
"validation": {
|
||||||
"schema": "passed-at-generation",
|
"schema": "passed-at-generation",
|
||||||
"validated_architecture": "amd64",
|
"validated_architecture": "amd64",
|
||||||
"arm64": "supported-upstream-not-yet-validated-in-proxmenux-laboratory",
|
"arm64": "published-upstream-not-yet-validated-in-proxmenux-laboratory",
|
||||||
"validated_profile": {
|
"validated_profile": {
|
||||||
"id": "pve55-haos-one-native-oci",
|
"id": "pve55-haos-native-oci",
|
||||||
"validated_on": "2026-09-08",
|
"validated_on": "2026-10-07",
|
||||||
"validation_status": "passed-functional-restart-persistence-with-known-nonblocking-issues",
|
"validation_status": "passed-install-restart-persistence-with-known-nonblocking-issues",
|
||||||
"proxmox_version": "9.2.11",
|
"proxmox_version": "9.2.20",
|
||||||
"container_id": 128,
|
"kernel": "7.0.14-19-pve",
|
||||||
"haos_version": "18.2",
|
"container_id": 121,
|
||||||
"home_assistant_core": "2026.9.1",
|
"image_reference": "ghcr.io/sevenrats/haos:latest",
|
||||||
"supervisor": "2026.09.0",
|
"image_digest": "sha256:5b499b4782e7547fdce746ebd9c37b0de802626f4db7b80b24660da2f3442ba7",
|
||||||
"matter_server": "9.2.0",
|
"image_created": "2026-09-23",
|
||||||
|
"haos_version": "18.3",
|
||||||
|
"home_assistant_core": "2026.9.4",
|
||||||
|
"supervisor": "2026.09.3",
|
||||||
"resources": {
|
"resources": {
|
||||||
"cores": 4,
|
"cores": 4,
|
||||||
"memory_mb": 4096,
|
"memory_mb": 4096,
|
||||||
@@ -658,29 +683,33 @@
|
|||||||
"data": "local-lvm:32G,mp=/mnt/data,backup=1"
|
"data": "local-lvm:32G,mp=/mnt/data,backup=1"
|
||||||
},
|
},
|
||||||
"observed_after_first_boot": {
|
"observed_after_first_boot": {
|
||||||
"data_used": "approximately-8.6G",
|
"data_used": "approximately-4.7G",
|
||||||
"memory_used_bytes": 1368289280,
|
"first_boot_seconds": 90
|
||||||
"rootfs_used_bytes": 624910336
|
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
"installer_default_mode": "passed",
|
||||||
"oci_import": "passed-with-ostype-unmanaged",
|
"oci_import": "passed-with-ostype-unmanaged",
|
||||||
"image_entrypoint": "passed-/entrypoint.sh-/sbin/init",
|
"image_entrypoint": "passed-/entrypoint.sh-/sbin/init",
|
||||||
"halt_signal": "passed-SIGRTMIN+3",
|
"halt_signal": "passed-SIGRTMIN+3",
|
||||||
"nested_docker": "passed-overlayfs",
|
"unprivileged": "passed",
|
||||||
"compatibility_proxy": "passed",
|
"home_assistant_web": "passed-http-port-8123",
|
||||||
"home_assistant_web": "passed-http-200-port-80",
|
"port_80": "closed",
|
||||||
"observer": "passed-http-200-port-4357",
|
"observer": "passed-http-200-port-4357",
|
||||||
"supervisor_healthy": true,
|
"supervisor_healthy": true,
|
||||||
"supervisor_supported": true,
|
"supervisor_supported": true,
|
||||||
"core_running": true,
|
"core_running": true,
|
||||||
"plugins_running": true,
|
"plugins_running": true,
|
||||||
"matter_addon_running": true,
|
"notes_link_port": 8123,
|
||||||
"restart_persistence": "passed",
|
"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",
|
"native_volume_backup_flag": "passed-mp0-backup-1",
|
||||||
"full_vzdump_restore": "pending",
|
"full_vzdump_restore": "passed-recovery-of-an-interrupted-modification",
|
||||||
"outer_image_upgrade": "pending",
|
"outer_image_upgrade": "pending-no-newer-image-published",
|
||||||
"usb_passthrough": "pending",
|
"multicast_discovery": "pending-functional-device-test",
|
||||||
"multicast_discovery": "pending-functional-device-test"
|
"modification": "passed-cores-changed-data-kept-watchdog-kept"
|
||||||
},
|
},
|
||||||
"known_nonblocking_resolution_issues": [
|
"known_nonblocking_resolution_issues": [
|
||||||
"haos-mglru.service-failed",
|
"haos-mglru.service-failed",
|
||||||
@@ -690,43 +719,10 @@
|
|||||||
"dummy-networkmanager-may-report-ipv4-or-dns-diagnostics"
|
"dummy-networkmanager-may-report-ipv4-or-dns-diagnostics"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"source_profile": "haos-one-oci.json",
|
"service_health": "passed-observed-2026-10-07",
|
||||||
"service_health": "passed-observed-2026-09-14",
|
"restart_persistence": "passed-2026-10-07",
|
||||||
"restart_persistence": "passed-2026-09-14",
|
"backup_restore": "passed-2026-10-07",
|
||||||
"backup_restore": "pending",
|
"update_preserves_data": "passed-by-modification-2026-10-07"
|
||||||
"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."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/mnt/data",
|
"update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/mnt/data",
|
||||||
@@ -741,14 +737,14 @@
|
|||||||
"validated_workflows": {
|
"validated_workflows": {
|
||||||
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
|
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
|
||||||
"outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data",
|
"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": [
|
"required_before_update": [
|
||||||
"create-and-download-a-full-home-assistant-backup",
|
"create-and-download-a-full-home-assistant-backup",
|
||||||
"create-a-native-proxmox-backup-including-mp0",
|
"create-a-native-proxmox-backup-including-mp0",
|
||||||
"record-current-image-digest",
|
"record-current-image-digest",
|
||||||
"verify-new-image-contains-required-compatibility-rules"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,7 +382,10 @@
|
|||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"port": 5055,
|
"port": 5055,
|
||||||
"path": "/api/v1/settings/public",
|
"path": "/api/v1/settings/public",
|
||||||
"timeout_seconds": 360
|
"timeout_seconds": 360,
|
||||||
|
"request_timeout_seconds": 5,
|
||||||
|
"verify_tls": false,
|
||||||
|
"stability_seconds": 0
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"options": {
|
"options": {
|
||||||
@@ -391,7 +394,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adaptations": [
|
"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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"network": "Network & Firewall",
|
"network": "Network & Firewall",
|
||||||
"documents": "Documents & Notes",
|
"documents": "Documents & Notes",
|
||||||
"downloads": "Files & Downloads",
|
"downloads": "Files & Downloads",
|
||||||
|
"sharing": "Files & Sharing",
|
||||||
"arr": "*Arr Suite",
|
"arr": "*Arr Suite",
|
||||||
"smarthome": "IoT & Smart Home",
|
"smarthome": "IoT & Smart Home",
|
||||||
"monitoring": "Monitoring & Analytics",
|
"monitoring": "Monitoring & Analytics",
|
||||||
@@ -156,6 +157,7 @@
|
|||||||
"scummvm": "gaming",
|
"scummvm": "gaming",
|
||||||
"sealskin": "desktop",
|
"sealskin": "desktop",
|
||||||
"shadps4": "gaming",
|
"shadps4": "gaming",
|
||||||
|
"sharecovex": "sharing",
|
||||||
"shotcut": "creative",
|
"shotcut": "creative",
|
||||||
"sickgear": "arr",
|
"sickgear": "arr",
|
||||||
"signal": "communication",
|
"signal": "communication",
|
||||||
|
|||||||
+138
-142
@@ -11,12 +11,12 @@
|
|||||||
"en_US": "Community single-container Home Assistant OS image"
|
"en_US": "Community single-container Home Assistant OS image"
|
||||||
},
|
},
|
||||||
"description": {
|
"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": "smarthome",
|
||||||
"category_label": "IoT & Smart Home",
|
"category_label": "IoT & Smart Home",
|
||||||
"author": "qweritos",
|
"author": "sevenrats",
|
||||||
"developer": "qweritos",
|
"developer": "sevenrats",
|
||||||
"icon": null,
|
"icon": null,
|
||||||
"thumbnail": null,
|
"thumbnail": null,
|
||||||
"screenshots": [],
|
"screenshots": [],
|
||||||
@@ -26,42 +26,42 @@
|
|||||||
],
|
],
|
||||||
"launch": {
|
"launch": {
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"port": 80,
|
"port": 8123,
|
||||||
"path": "/"
|
"path": "/"
|
||||||
},
|
},
|
||||||
"website": "https://github.com/qweritos/haos-one",
|
"website": "https://github.com/sevenrats/docker-haos",
|
||||||
"documentation": "https://github.com/qweritos/haos-one#readme",
|
"documentation": "https://github.com/sevenrats/docker-haos#readme",
|
||||||
"repository": "https://github.com/qweritos/haos-one",
|
"repository": "https://github.com/sevenrats/docker-haos",
|
||||||
"tips": [
|
"tips": [
|
||||||
"This is a third-party community image and is not affiliated with Home Assistant.",
|
"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.",
|
"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.",
|
"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 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.",
|
"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. Full restore and external image update pending testing."
|
"/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": [],
|
"mini_changelog": [],
|
||||||
"display_version": null,
|
"display_version": null,
|
||||||
"updated_at": "2026-08-20"
|
"updated_at": "2026-09-23"
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"provider": "qweritos",
|
"provider": "sevenrats",
|
||||||
"repository": "https://github.com/qweritos/haos-one",
|
"repository": "https://github.com/sevenrats/docker-haos",
|
||||||
"revision": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede",
|
"revision": "e7fe7f6728b84763b76658ab5a9669f50cb029b0",
|
||||||
"image_repository_url": "https://hub.docker.com/r/qweritos/haos-one",
|
"image_repository_url": "https://github.com/sevenrats/docker-haos/pkgs/container/haos",
|
||||||
"readme_pushed_at": "2026-08-20T12:29:50Z",
|
"readme_pushed_at": "2026-09-23T15:30:10Z",
|
||||||
"compose_sha256": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede",
|
"compose_sha256": "4301a2454ca1a5ff58d596dc1f2f99eba52ab77a307f394d81e6d5f66f824111",
|
||||||
"generated_at": "2026-09-12T15:54:10+00:00",
|
"generated_at": "2026-10-07T00:00:00+00:00",
|
||||||
"default_branch": "master"
|
"default_branch": "master"
|
||||||
},
|
},
|
||||||
"container_contract": {
|
"container_contract": {
|
||||||
"service_name": "haos-one",
|
"service_name": "haos-one",
|
||||||
"container_name": "haos-one",
|
"container_name": "haos-one",
|
||||||
"image": {
|
"image": {
|
||||||
"reference": "qweritos/haos-one:latest",
|
"reference": "ghcr.io/sevenrats/haos:latest",
|
||||||
"registry": "docker.io",
|
"registry": "ghcr.io",
|
||||||
"repository": "qweritos/haos-one",
|
"repository": "sevenrats/haos",
|
||||||
"tag": "latest",
|
"tag": "latest",
|
||||||
"digest": null,
|
"digest": null,
|
||||||
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
|
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"example": "1",
|
"example": "1",
|
||||||
"required": true,
|
"required": true,
|
||||||
"sensitive": false,
|
"sensitive": false,
|
||||||
"source": "docker-compose",
|
"source": "image-default",
|
||||||
"prompt_user": false
|
"prompt_user": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,16 +80,31 @@
|
|||||||
"example": "auto",
|
"example": "auto",
|
||||||
"required": true,
|
"required": true,
|
||||||
"sensitive": false,
|
"sensitive": false,
|
||||||
"source": "docker-compose",
|
"source": "image-default",
|
||||||
"prompt_user": false
|
"prompt_user": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "DEV",
|
"name": "SETUP_PORT",
|
||||||
"example": "0",
|
"example": "8123",
|
||||||
"required": true,
|
"required": true,
|
||||||
"sensitive": false,
|
"sensitive": false,
|
||||||
"source": "docker-compose",
|
"source": "image-default",
|
||||||
"prompt_user": false
|
"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": [
|
"volumes": [
|
||||||
@@ -111,8 +126,8 @@
|
|||||||
],
|
],
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"container_port": 80,
|
"container_port": 8123,
|
||||||
"published_example": 80,
|
"published_example": 8123,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"required": true,
|
"required": true,
|
||||||
"proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
|
"proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
|
||||||
@@ -126,9 +141,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"related_services": [],
|
"related_services": [],
|
||||||
"restart": null,
|
"restart": "unless-stopped",
|
||||||
"stop_grace_period": null,
|
"stop_grace_period": "2m",
|
||||||
"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"
|
"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": {
|
"compose_stack": {
|
||||||
"project_name": "haos-one",
|
"project_name": "haos-one",
|
||||||
@@ -139,7 +154,7 @@
|
|||||||
"services": [
|
"services": [
|
||||||
{
|
{
|
||||||
"name": "haos-one",
|
"name": "haos-one",
|
||||||
"image": "qweritos/haos-one:latest",
|
"image": "ghcr.io/sevenrats/haos:latest",
|
||||||
"is_main": true,
|
"is_main": true,
|
||||||
"role": "frontend",
|
"role": "frontend",
|
||||||
"vmid_offset": 0,
|
"vmid_offset": 0,
|
||||||
@@ -147,15 +162,19 @@
|
|||||||
"frontend_network": true,
|
"frontend_network": true,
|
||||||
"private_network": false,
|
"private_network": false,
|
||||||
"compose": {
|
"compose": {
|
||||||
"image": "qweritos/haos-one:latest",
|
"image": "ghcr.io/sevenrats/haos:latest",
|
||||||
"privileged": true,
|
"privileged": true,
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"stop_grace_period": "2m",
|
||||||
"environment": {
|
"environment": {
|
||||||
"USE_DUMMY_NETWORKMANAGER": "1",
|
"USE_DUMMY_NETWORKMANAGER": "1",
|
||||||
"USE_UDEV_SHIM": "auto",
|
"USE_UDEV_SHIM": "auto",
|
||||||
"DEV": "0"
|
"SETUP_PORT": "8123",
|
||||||
|
"ENABLE_IPV6": "1",
|
||||||
|
"TZ": "Europe/Madrid"
|
||||||
},
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
"80:80",
|
"8123:8123",
|
||||||
"4357:4357"
|
"4357:4357"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
@@ -230,9 +249,9 @@
|
|||||||
{
|
{
|
||||||
"label": "Home Assistant",
|
"label": "Home Assistant",
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"port": 80,
|
"port": 8123,
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"source": "validated-current-core-profile"
|
"source": "image-setup-port"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Home Assistant Observer",
|
"label": "Home Assistant Observer",
|
||||||
@@ -269,7 +288,7 @@
|
|||||||
"nesting=1",
|
"nesting=1",
|
||||||
"keyctl=1"
|
"keyctl=1"
|
||||||
],
|
],
|
||||||
"shutdown_timeout_seconds": 60
|
"shutdown_timeout_seconds": 120
|
||||||
},
|
},
|
||||||
"image_metadata_policy": {
|
"image_metadata_policy": {
|
||||||
"entrypoint": "import-from-oci-image",
|
"entrypoint": "import-from-oci-image",
|
||||||
@@ -290,7 +309,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nested-runtime-features",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"reason": "Older builds fail to start Core and plug-ins with kernel.domainname permission denied.",
|
||||||
"behavioral_impact": "Uses the project-provided compatibility behavior unchanged.",
|
"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": {
|
"laboratory_contract": {
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"proxmox_min_version": "9.2",
|
"proxmox_min_version": "9.2",
|
||||||
"validated_proxmox_version": "9.2.11",
|
"validated_proxmox_version": "9.2.20",
|
||||||
"commands": [
|
"commands": [
|
||||||
"pct",
|
"pct",
|
||||||
"pvesm",
|
"pvesm",
|
||||||
@@ -358,7 +385,9 @@
|
|||||||
"environment_defaults": {
|
"environment_defaults": {
|
||||||
"USE_DUMMY_NETWORKMANAGER": "1",
|
"USE_DUMMY_NETWORKMANAGER": "1",
|
||||||
"USE_UDEV_SHIM": "auto",
|
"USE_UDEV_SHIM": "auto",
|
||||||
"SETUP_PORT": null,
|
"SETUP_PORT": "8123",
|
||||||
|
"ENABLE_IPV6": "1",
|
||||||
|
"TZ": "UTC",
|
||||||
"DEV": "0"
|
"DEV": "0"
|
||||||
},
|
},
|
||||||
"preserve_without_override": [
|
"preserve_without_override": [
|
||||||
@@ -428,7 +457,7 @@
|
|||||||
"type": "device-list",
|
"type": "device-list",
|
||||||
"required": false,
|
"required": false,
|
||||||
"default": [],
|
"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": {
|
"deployment": {
|
||||||
@@ -457,9 +486,9 @@
|
|||||||
],
|
],
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"port": 80,
|
"port": 8123,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"purpose": "validated-home-assistant-web-for-core-2026.9.1"
|
"purpose": "home-assistant-web-fixed-by-SETUP_PORT"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"port": 4357,
|
"port": 4357,
|
||||||
@@ -469,10 +498,10 @@
|
|||||||
],
|
],
|
||||||
"conditional_ports": [
|
"conditional_ports": [
|
||||||
{
|
{
|
||||||
"port": 8123,
|
"port": 80,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"condition": "older-core-or-landing-page-stage-or-explicit-SETUP_PORT",
|
"condition": "SETUP_PORT-emptied-on-core-2026.8-or-newer",
|
||||||
"warning": "Do not assume port 8123 remains active after onboarding on Home Assistant 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",
|
"data_role": "persistent-user-and-supervisor-state",
|
||||||
"restart_test": {
|
"restart_test": {
|
||||||
"status": "passed",
|
"status": "passed",
|
||||||
"method": "write-marker-shutdown-start-verify-hash-and-services",
|
"method": "write-marker-reboot-shutdown-start-verify-hash-and-services",
|
||||||
"marker_sha256": "6c3762cede4f86dd5eeae16eff1067e188e9b47646f48ccc268c077711bffbd0",
|
"marker_sha256": "ddcc5557ff607ffc6ef0cdc54484f213c28f65b5ebde1c02cd8eefd2fa6b7c5e",
|
||||||
"volume_before": "local-lvm:vm-128-disk-1",
|
"volume_before": "local-lvm:vm-121-disk-1",
|
||||||
"volume_after": "local-lvm:vm-128-disk-1",
|
"volume_after": "local-lvm:vm-121-disk-1",
|
||||||
"home_assistant_after_restart": "passed-http-200",
|
"home_assistant_after_restart": "passed-http-port-8123",
|
||||||
"inner_containers_after_restart": "passed-all-running"
|
"inner_containers_after_restart": "passed-all-running"
|
||||||
},
|
},
|
||||||
"native_backup": {
|
"native_backup": {
|
||||||
"expected": "included-by-mp0-backup-1",
|
"expected": "included-by-mp0-backup-1",
|
||||||
"full-vzdump-restore-test": "pending"
|
"full-vzdump-restore-test": "passed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"installation_steps": [
|
"installation_steps": [
|
||||||
@@ -516,7 +545,7 @@
|
|||||||
"Attach a host-managed network interface to the selected bridge.",
|
"Attach a host-managed network interface to the selected bridge.",
|
||||||
"Preserve the imported /entrypoint.sh /sbin/init command and SIGRTMIN+3 stop signal.",
|
"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.",
|
"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.",
|
"Require Supervisor healthy=true and supported=true before reporting success.",
|
||||||
"Verify Core, CLI, DNS, audio, multicast and observer containers are running.",
|
"Verify Core, CLI, DNS, audio, multicast and observer containers are running.",
|
||||||
"Report non-blocking HAOS resolution issues separately."
|
"Report non-blocking HAOS resolution issues separately."
|
||||||
@@ -526,8 +555,8 @@
|
|||||||
"name": "home-assistant-web",
|
"name": "home-assistant-web",
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"candidate_urls": [
|
"candidate_urls": [
|
||||||
"http://${container_ip}/",
|
"http://${container_ip}:8123/",
|
||||||
"http://${container_ip}:8123/"
|
"http://${container_ip}/"
|
||||||
],
|
],
|
||||||
"expected_status": 200,
|
"expected_status": 200,
|
||||||
"startup_grace_seconds": 300
|
"startup_grace_seconds": 300
|
||||||
@@ -551,14 +580,14 @@
|
|||||||
"update_strategy": {
|
"update_strategy": {
|
||||||
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
|
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
|
||||||
"outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data",
|
"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": [
|
"required_before_update": [
|
||||||
"create-and-download-a-full-home-assistant-backup",
|
"create-and-download-a-full-home-assistant-backup",
|
||||||
"create-a-native-proxmox-backup-including-mp0",
|
"create-a-native-proxmox-backup-including-mp0",
|
||||||
"record-current-image-digest",
|
"record-current-image-digest",
|
||||||
"verify-new-image-contains-required-compatibility-rules"
|
"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": {
|
"security": {
|
||||||
"image_is_official_home_assistant": false,
|
"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."
|
"Do not expose Home Assistant or Observer directly to the Internet without an authenticated reverse proxy and normal Home Assistant hardening."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"incompatible_builds": [
|
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"notes": [
|
"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.",
|
"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.",
|
"The direct Proxmox OCI path removes one extra Docker layer compared with a Proxmox LXC plus Docker plus this image deployment.",
|
||||||
"Proxmox privileged OCI import failed with setgid(0) Invalid argument; the validated profile is unprivileged and functional.",
|
"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.",
|
"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.",
|
"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.",
|
"A clean first boot used approximately 4.7G under /mnt/data; 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.",
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"references": {
|
||||||
"project": "https://github.com/qweritos/haos-one",
|
"project": "https://github.com/sevenrats/docker-haos",
|
||||||
"docker_image": "https://hub.docker.com/r/qweritos/haos-one",
|
"docker_image": "https://github.com/sevenrats/docker-haos/pkgs/container/haos",
|
||||||
"dockerfile": "https://github.com/qweritos/haos-one/blob/master/Dockerfile",
|
"dockerfile": "https://github.com/sevenrats/docker-haos/blob/master/Dockerfile",
|
||||||
"compatibility_documentation": "https://github.com/qweritos/haos-one/blob/master/docs/haos-one-compat.md",
|
"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",
|
"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": {
|
"installer_profile": {
|
||||||
@@ -634,22 +656,25 @@
|
|||||||
"volumes"
|
"volumes"
|
||||||
],
|
],
|
||||||
"untranslated_blockers": [],
|
"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": {
|
"validation": {
|
||||||
"schema": "passed-at-generation",
|
"schema": "passed-at-generation",
|
||||||
"validated_architecture": "amd64",
|
"validated_architecture": "amd64",
|
||||||
"arm64": "supported-upstream-not-yet-validated-in-proxmenux-laboratory",
|
"arm64": "published-upstream-not-yet-validated-in-proxmenux-laboratory",
|
||||||
"validated_profile": {
|
"validated_profile": {
|
||||||
"id": "pve55-haos-one-native-oci",
|
"id": "pve55-haos-native-oci",
|
||||||
"validated_on": "2026-09-08",
|
"validated_on": "2026-10-07",
|
||||||
"validation_status": "passed-functional-restart-persistence-with-known-nonblocking-issues",
|
"validation_status": "passed-install-restart-persistence-with-known-nonblocking-issues",
|
||||||
"proxmox_version": "9.2.11",
|
"proxmox_version": "9.2.20",
|
||||||
"container_id": 128,
|
"kernel": "7.0.14-19-pve",
|
||||||
"haos_version": "18.2",
|
"container_id": 121,
|
||||||
"home_assistant_core": "2026.9.1",
|
"image_reference": "ghcr.io/sevenrats/haos:latest",
|
||||||
"supervisor": "2026.09.0",
|
"image_digest": "sha256:5b499b4782e7547fdce746ebd9c37b0de802626f4db7b80b24660da2f3442ba7",
|
||||||
"matter_server": "9.2.0",
|
"image_created": "2026-09-23",
|
||||||
|
"haos_version": "18.3",
|
||||||
|
"home_assistant_core": "2026.9.4",
|
||||||
|
"supervisor": "2026.09.3",
|
||||||
"resources": {
|
"resources": {
|
||||||
"cores": 4,
|
"cores": 4,
|
||||||
"memory_mb": 4096,
|
"memory_mb": 4096,
|
||||||
@@ -658,29 +683,33 @@
|
|||||||
"data": "local-lvm:32G,mp=/mnt/data,backup=1"
|
"data": "local-lvm:32G,mp=/mnt/data,backup=1"
|
||||||
},
|
},
|
||||||
"observed_after_first_boot": {
|
"observed_after_first_boot": {
|
||||||
"data_used": "approximately-8.6G",
|
"data_used": "approximately-4.7G",
|
||||||
"memory_used_bytes": 1368289280,
|
"first_boot_seconds": 90
|
||||||
"rootfs_used_bytes": 624910336
|
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
"installer_default_mode": "passed",
|
||||||
"oci_import": "passed-with-ostype-unmanaged",
|
"oci_import": "passed-with-ostype-unmanaged",
|
||||||
"image_entrypoint": "passed-/entrypoint.sh-/sbin/init",
|
"image_entrypoint": "passed-/entrypoint.sh-/sbin/init",
|
||||||
"halt_signal": "passed-SIGRTMIN+3",
|
"halt_signal": "passed-SIGRTMIN+3",
|
||||||
"nested_docker": "passed-overlayfs",
|
"unprivileged": "passed",
|
||||||
"compatibility_proxy": "passed",
|
"home_assistant_web": "passed-http-port-8123",
|
||||||
"home_assistant_web": "passed-http-200-port-80",
|
"port_80": "closed",
|
||||||
"observer": "passed-http-200-port-4357",
|
"observer": "passed-http-200-port-4357",
|
||||||
"supervisor_healthy": true,
|
"supervisor_healthy": true,
|
||||||
"supervisor_supported": true,
|
"supervisor_supported": true,
|
||||||
"core_running": true,
|
"core_running": true,
|
||||||
"plugins_running": true,
|
"plugins_running": true,
|
||||||
"matter_addon_running": true,
|
"notes_link_port": 8123,
|
||||||
"restart_persistence": "passed",
|
"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",
|
"native_volume_backup_flag": "passed-mp0-backup-1",
|
||||||
"full_vzdump_restore": "pending",
|
"full_vzdump_restore": "passed-recovery-of-an-interrupted-modification",
|
||||||
"outer_image_upgrade": "pending",
|
"outer_image_upgrade": "pending-no-newer-image-published",
|
||||||
"usb_passthrough": "pending",
|
"multicast_discovery": "pending-functional-device-test",
|
||||||
"multicast_discovery": "pending-functional-device-test"
|
"modification": "passed-cores-changed-data-kept-watchdog-kept"
|
||||||
},
|
},
|
||||||
"known_nonblocking_resolution_issues": [
|
"known_nonblocking_resolution_issues": [
|
||||||
"haos-mglru.service-failed",
|
"haos-mglru.service-failed",
|
||||||
@@ -690,43 +719,10 @@
|
|||||||
"dummy-networkmanager-may-report-ipv4-or-dns-diagnostics"
|
"dummy-networkmanager-may-report-ipv4-or-dns-diagnostics"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"source_profile": "haos-one-oci.json",
|
"service_health": "passed-observed-2026-10-07",
|
||||||
"service_health": "passed-observed-2026-09-14",
|
"restart_persistence": "passed-2026-10-07",
|
||||||
"restart_persistence": "passed-2026-09-14",
|
"backup_restore": "passed-2026-10-07",
|
||||||
"backup_restore": "pending",
|
"update_preserves_data": "passed-by-modification-2026-10-07"
|
||||||
"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."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/mnt/data",
|
"update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/mnt/data",
|
||||||
@@ -741,14 +737,14 @@
|
|||||||
"validated_workflows": {
|
"validated_workflows": {
|
||||||
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
|
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
|
||||||
"outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data",
|
"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": [
|
"required_before_update": [
|
||||||
"create-and-download-a-full-home-assistant-backup",
|
"create-and-download-a-full-home-assistant-backup",
|
||||||
"create-a-native-proxmox-backup-including-mp0",
|
"create-a-native-proxmox-backup-including-mp0",
|
||||||
"record-current-image-digest",
|
"record-current-image-digest",
|
||||||
"verify-new-image-contains-required-compatibility-rules"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,7 +382,10 @@
|
|||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"port": 5055,
|
"port": 5055,
|
||||||
"path": "/api/v1/settings/public",
|
"path": "/api/v1/settings/public",
|
||||||
"timeout_seconds": 360
|
"timeout_seconds": 360,
|
||||||
|
"request_timeout_seconds": 5,
|
||||||
|
"verify_tls": false,
|
||||||
|
"stability_seconds": 0
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"options": {
|
"options": {
|
||||||
@@ -391,7 +394,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adaptations": [
|
"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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-6
@@ -3927,19 +3927,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "haos-one",
|
"id": "haos-one",
|
||||||
"provider": "qweritos",
|
"provider": "sevenrats",
|
||||||
"template_family": "curated-profile",
|
"template_family": "curated-profile",
|
||||||
"title": "HAOS One",
|
"title": "HAOS One",
|
||||||
"repository": "https://github.com/qweritos/haos-one",
|
"repository": "https://github.com/sevenrats/docker-haos",
|
||||||
"description": "Community HAOS One image adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data.",
|
"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/qweritos/haos-one",
|
"website": "https://github.com/sevenrats/docker-haos",
|
||||||
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp",
|
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp",
|
||||||
"architectures": [
|
"architectures": [
|
||||||
"amd64",
|
"amd64",
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"updated_at": "2026-08-20",
|
"updated_at": "2026-09-23",
|
||||||
"main_image": "qweritos/haos-one:latest",
|
"main_image": "ghcr.io/sevenrats/haos:latest",
|
||||||
"category": "smarthome",
|
"category": "smarthome",
|
||||||
"category_label": "IoT & Smart Home",
|
"category_label": "IoT & Smart Home",
|
||||||
"replaces_discovered_ids": [],
|
"replaces_discovered_ids": [],
|
||||||
@@ -9765,6 +9765,38 @@
|
|||||||
"hidden": false,
|
"hidden": false,
|
||||||
"template_id": "linuxserver-shadps4"
|
"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",
|
"id": "shotcut",
|
||||||
"provider": "linuxserver.io",
|
"provider": "linuxserver.io",
|
||||||
|
|||||||
@@ -117,6 +117,9 @@
|
|||||||
"rdtclient": {
|
"rdtclient": {
|
||||||
"status": "laboratory-validated"
|
"status": "laboratory-validated"
|
||||||
},
|
},
|
||||||
|
"sharecovex": {
|
||||||
|
"status": "laboratory-validated"
|
||||||
|
},
|
||||||
"snapotter": {
|
"snapotter": {
|
||||||
"status": "laboratory-validated"
|
"status": "laboratory-validated"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ def note(text):
|
|||||||
"""Detail for the run log; without one, for stderr."""
|
"""Detail for the run log; without one, for stderr."""
|
||||||
try:
|
try:
|
||||||
if OCI_LOG:
|
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)
|
log(OCI_LOG, text)
|
||||||
return
|
return
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -110,7 +113,7 @@ def probe(vmid, ip, remaining):
|
|||||||
raise Pending(translate('Supervisor does not confirm healthy and supported yet'))
|
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()))):
|
if not http_ready(f'http://{ip}:4357/', max(0.1, min(5, remaining()))):
|
||||||
raise Pending(translate('Observer is not responding on port 4357'))
|
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()))):
|
if http_ready(f'http://{ip}:{port}/', max(0.1, min(5, remaining()))):
|
||||||
return [{'label': 'Home Assistant', 'url': f'http://{ip}:{port}/'},
|
return [{'label': 'Home Assistant', 'url': f'http://{ip}:{port}/'},
|
||||||
{'label': 'Home Assistant Observer', 'url': f'http://{ip}:4357/'}]
|
{'label': 'Home Assistant Observer', 'url': f'http://{ip}:4357/'}]
|
||||||
|
|||||||
@@ -103,6 +103,24 @@ def persist_instances(deployment, services, primary_id=None):
|
|||||||
oci_instances.publish_stack(oci_instances.ROOT, None, template, deployment, services)
|
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):
|
def run(*args, capture=True, timeout=180):
|
||||||
argv = list(map(str,args))
|
argv = list(map(str,args))
|
||||||
if capture:
|
if capture:
|
||||||
@@ -811,6 +829,7 @@ def main():
|
|||||||
fcntl.flock(lock,fcntl.LOCK_UN)
|
fcntl.flock(lock,fcntl.LOCK_UN)
|
||||||
result = finish_independent_suite(services,subnet)
|
result = finish_independent_suite(services,subnet)
|
||||||
persist_instances(deployment, services)
|
persist_instances(deployment, services)
|
||||||
|
refresh_notes(services)
|
||||||
result.update(completion_notes=list(deployment.get('completion_notes',[])), log=LOG)
|
result.update(completion_notes=list(deployment.get('completion_notes',[])), log=LOG)
|
||||||
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
|
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
|
||||||
return
|
return
|
||||||
@@ -852,6 +871,7 @@ def main():
|
|||||||
msg_ok(translate('LAN address applied to the application URLs'))
|
msg_ok(translate('LAN address applied to the application URLs'))
|
||||||
result=results[primary['name']]
|
result=results[primary['name']]
|
||||||
persist_instances(deployment, services, primary_id)
|
persist_instances(deployment, services, primary_id)
|
||||||
|
refresh_notes(services)
|
||||||
# The credentials of the main service come from its own installation.
|
# 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},
|
result.update(vmid=primary_id,ip=lan,stack_vmids={s['name']:s['vmid'] for s in services},
|
||||||
urls=[{'label':'Web UI','url':public_url}],
|
urls=[{'label':'Web UI','url':public_url}],
|
||||||
|
|||||||
+28
-12
@@ -745,6 +745,21 @@ apply_post_start_configuration() {
|
|||||||
esac
|
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.
|
# $1: optional result label shown before the address.
|
||||||
detect_container_ipv4() {
|
detect_container_ipv4() {
|
||||||
local attempt addresses label=${1:-}
|
local attempt addresses label=${1:-}
|
||||||
@@ -755,10 +770,12 @@ detect_container_ipv4() {
|
|||||||
fi
|
fi
|
||||||
IP=""
|
IP=""
|
||||||
for attempt in $(seq 1 15); do
|
for attempt in $(seq 1 15); do
|
||||||
IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
|
if ! IP=$(own_ipv4); then
|
||||||
if [[ -z $IP ]]; then
|
IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
|
||||||
addresses=$(pct exec "$VMID" -- hostname -I 2>/dev/null || true)
|
if [[ -z $IP ]]; then
|
||||||
IP=$(tr ' ' '\n' <<<"$addresses" | grep -m1 -E '^[0-9]+\.' || true)
|
addresses=$(pct exec "$VMID" -- hostname -I 2>/dev/null || true)
|
||||||
|
IP=$(tr ' ' '\n' <<<"$addresses" | grep -m1 -E '^[0-9]+\.' || true)
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
[[ -n $IP ]] && break
|
[[ -n $IP ]] && break
|
||||||
msg_progress "$(translate "Waiting for the network address...") $((attempt * 2))/30 s"
|
msg_progress "$(translate "Waiting for the network address...") $((attempt * 2))/30 s"
|
||||||
@@ -1705,7 +1722,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
SYSCTL_COUNT=$(jq '.security.sysctls? // [] | length' "$DEPLOYMENT_FILE")
|
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"
|
SYSCTL_INCLUDE="/etc/pve/proxmenux/${VMID}.sysctls"
|
||||||
mkdir -p /etc/pve/proxmenux || die "$(translate "Could not create the ProxMenux folder in /etc/pve")"
|
mkdir -p /etc/pve/proxmenux || die "$(translate "Could not create the ProxMenux folder in /etc/pve")"
|
||||||
SYSCTL_TEMP=$(mktemp)
|
SYSCTL_TEMP=$(mktemp)
|
||||||
@@ -1718,12 +1736,14 @@ if (( SYSCTL_COUNT > 0 )); then
|
|||||||
printf 'lxc.sysctl.%s = %s\n' "$SYSCTL_NAME" "$SYSCTL_VALUE" >>"$SYSCTL_TEMP"
|
printf 'lxc.sysctl.%s = %s\n' "$SYSCTL_NAME" "$SYSCTL_VALUE" >>"$SYSCTL_TEMP"
|
||||||
oci_log "Network sysctl prepared: ${SYSCTL_NAME}=${SYSCTL_VALUE}"
|
oci_log "Network sysctl prepared: ${SYSCTL_NAME}=${SYSCTL_VALUE}"
|
||||||
done < <(jq -r '.security.sysctls[]? | [.name,.value] | @tsv' "$DEPLOYMENT_FILE")
|
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"
|
[[ ! -L $SYSCTL_INCLUDE ]] || die "$(translate "The sysctl include is a link:") $SYSCTL_INCLUDE"
|
||||||
# pmxcfs assigns its own permissions and rejects chmod.
|
# pmxcfs assigns its own permissions and rejects chmod.
|
||||||
cat "$SYSCTL_TEMP" >"$SYSCTL_INCLUDE"
|
cat "$SYSCTL_TEMP" >"$SYSCTL_INCLUDE"
|
||||||
rm -f "$SYSCTL_TEMP"
|
rm -f "$SYSCTL_TEMP"
|
||||||
set_lxc_directive "lxc.include" "$SYSCTL_INCLUDE"
|
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
|
fi
|
||||||
|
|
||||||
REQUIRED_CAPABILITIES=$(jq -r '.security.required_capabilities? // [] | join(",")' "$DEPLOYMENT_FILE")
|
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"
|
msg_ok "$(translate "Compose capabilities validated in the LXC user namespace:") $REQUIRED_CAPABILITIES"
|
||||||
fi
|
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")
|
APPARMOR_PROFILE=$(jq -r '.security.options.apparmor_profile? // empty' "$DEPLOYMENT_FILE")
|
||||||
SECCOMP_PROFILE=$(jq -r '.security.options.seccomp_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")
|
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
|
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")"
|
[[ -z $REQUIRED_CAPABILITIES ]] || die "$(translate "Capabilities cannot be kept and all dropped at the same time")"
|
||||||
set_lxc_directive "lxc.cap.drop" ""
|
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_PORT=$(jq -er '.port' <<<"$STARTUP_HEALTHCHECK")
|
||||||
HC_PATH=$(jq -er '.path' <<<"$STARTUP_HEALTHCHECK")
|
HC_PATH=$(jq -er '.path' <<<"$STARTUP_HEALTHCHECK")
|
||||||
HC_TIMEOUT=$(jq -er '.timeout_seconds' <<<"$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_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_SCHEME == http || $HC_SCHEME == https ]] || die "$(translate "Invalid healthcheck scheme")"
|
||||||
[[ $HC_PORT =~ ^[0-9]+$ && $HC_PORT -ge 1 && $HC_PORT -le 65535 ]] \
|
[[ $HC_PORT =~ ^[0-9]+$ && $HC_PORT -ge 1 && $HC_PORT -le 65535 ]] \
|
||||||
|| die "$(translate "Invalid healthcheck port")"
|
|| die "$(translate "Invalid healthcheck port")"
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Marks each start of an OCI container in its console log, so the log can be
|
# Run by the lxc.hook.pre-start of an OCI container. Marks each start in its
|
||||||
# read from the last start. Run by the container's lxc.hook.pre-start.
|
# 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
|
case "$1" in
|
||||||
''|*[!0-9]*) exit 0 ;;
|
''|*[!0-9]*) exit 0 ;;
|
||||||
esac
|
esac
|
||||||
printf '\n=== ProxMenux: container started %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
|
printf '\n=== ProxMenux: container started %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
|
||||||
>> "/var/log/proxmenux/oci/$1.console.log" 2>/dev/null
|
>> "/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
|
exit 0
|
||||||
|
|||||||
@@ -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 <vmid> | --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())
|
||||||
@@ -34,9 +34,13 @@ def snapshot(source, allow_missing=False):
|
|||||||
'mode': stat.S_IMODE(info.st_mode)}
|
'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)
|
valid_path(source)
|
||||||
value = snapshot(source, allow_missing)
|
value = snapshot(source, allow_missing)
|
||||||
|
if declared:
|
||||||
|
return dict(value, declared=True)
|
||||||
protected = ('/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/dev', '/proc', '/sys', '/run')
|
protected = ('/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/dev', '/proc', '/sys', '/run')
|
||||||
protected += tuple(str(Path(p).resolve()) for p in protected)
|
protected += tuple(str(Path(p).resolve()) for p in protected)
|
||||||
resolved = value['resolved_path']
|
resolved = value['resolved_path']
|
||||||
@@ -52,7 +56,8 @@ def same_source(a, b):
|
|||||||
|
|
||||||
def verify_sources(expected):
|
def verify_sources(expected):
|
||||||
for source, previous in expected.items():
|
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):
|
if not same_source(previous, current):
|
||||||
raise ValueError(f"{translate('The operation was stopped because a shared directory changed its identity:')} {source}")
|
raise ValueError(f"{translate('The operation was stopped because a shared directory changed its identity:')} {source}")
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ def resolve_candidate(reference, architecture):
|
|||||||
# version inside it moved.
|
# version inside it moved.
|
||||||
return {'manifest_digest': digest, 'registry_digest': registry_digest,
|
return {'manifest_digest': digest, 'registry_digest': registry_digest,
|
||||||
'layers': [layer['digest'] for layer in json.loads(raw).get('layers', [])],
|
'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')}
|
'defaults': defaults, 'version': version, 'created': config.get('created')}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import oci_host_mounts as host_mounts
|
|||||||
import oci_accelerators as gpu_devices
|
import oci_accelerators as gpu_devices
|
||||||
import oci_runtime_settings as runtime_settings
|
import oci_runtime_settings as runtime_settings
|
||||||
import oci_image_cache as image_cache
|
import oci_image_cache as image_cache
|
||||||
|
import oci_work_backup as work_backup
|
||||||
import oci_ui
|
import oci_ui
|
||||||
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2
|
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.
|
# The hook that marks each start in that log, set the same way.
|
||||||
'lxc.hook.pre-start'}
|
'lxc.hook.pre-start'}
|
||||||
# Their output is data (and may hold saved secrets); it is never logged.
|
# 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'))
|
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
|
||||||
ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]')
|
ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]')
|
||||||
SPINNER_FRAME = re.compile('^ ?[' + ''.join(oci_ui.FRAMES) + ']')
|
SPINNER_FRAME = re.compile('^ ?[' + ''.join(oci_ui.FRAMES) + ']')
|
||||||
@@ -316,13 +319,49 @@ def mounts(config):
|
|||||||
return result
|
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):
|
def effective_healthcheck(template):
|
||||||
"""The template's own health check or, when it has none, one derived from
|
"""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."""
|
its web address: any HTTP answer below 500 means the application is up."""
|
||||||
profile = template.get('proxmox', {}).get('installer_profile', {})
|
profile = template.get('proxmox', {}).get('installer_profile', {})
|
||||||
check = profile.get('startup_healthcheck')
|
check = profile.get('startup_healthcheck')
|
||||||
if check or profile.get('haos_healthcheck') or profile.get('host_monitor'):
|
if check:
|
||||||
return 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', []):
|
for endpoint in template.get('first_run', {}).get('endpoints', []):
|
||||||
path = str(endpoint.get('path') or '/')
|
path = str(endpoint.get('path') or '/')
|
||||||
if (endpoint.get('scheme') in ('http', 'https') and str(endpoint.get('port', '')).isdigit()
|
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):
|
def with_default_healthcheck(contract):
|
||||||
check = effective_healthcheck(contract['template'])
|
check = effective_healthcheck(contract['template'])
|
||||||
if check:
|
if check and check.get('type') != 'haos':
|
||||||
contract['template'].setdefault('proxmox', {}).setdefault('installer_profile', {})['startup_healthcheck'] = check
|
contract['template'].setdefault('proxmox', {}).setdefault('installer_profile', {})['startup_healthcheck'] = check
|
||||||
return contract
|
return contract
|
||||||
|
|
||||||
@@ -365,6 +404,13 @@ def candidate_contract(record, operation, proposal=None):
|
|||||||
result = copy.deepcopy(record)
|
result = copy.deepcopy(record)
|
||||||
result.update(template=copy.deepcopy(candidate['template']),
|
result.update(template=copy.deepcopy(candidate['template']),
|
||||||
deployment=copy.deepcopy(candidate['deployment']))
|
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)
|
return with_default_healthcheck(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -435,23 +481,32 @@ def preflight(record, candidate, config, coordinated=None):
|
|||||||
runtime_keys.add('lxc.mount.entry')
|
runtime_keys.add('lxc.mount.entry')
|
||||||
if deployment.get('security', {}).get('sysctls'):
|
if deployment.get('security', {}).get('sysctls'):
|
||||||
runtime_keys.add('lxc.include')
|
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'])
|
runtime_settings.check(config, deployment, record['vmid'])
|
||||||
coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS)
|
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)}:
|
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'))
|
raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile'))
|
||||||
for plan in (deployment, desired):
|
unprivileged = deployment.get('security', {}).get('unprivileged', True) is not False
|
||||||
security = plan.get('security', {})
|
if (desired.get('security', {}).get('unprivileged', True) is not False) != unprivileged \
|
||||||
if (security.get('unprivileged') is not True
|
or desired.get('host_monitor') != deployment.get('host_monitor'):
|
||||||
or security.get('options') or plan.get('host_monitor')
|
# The owners of the data belong to one mode, and a monitor of the
|
||||||
or plan.get('resources', {}).get('rlimits')):
|
# host has no network of its own to keep.
|
||||||
raise ValueError(translate('Updates are not available yet in this beta for applications that use '
|
raise ValueError(translate('Changing between a privileged and an unprivileged container, or the host monitor mode, requires installing the application again'))
|
||||||
'a privileged container or advanced LXC settings'))
|
for template, plan in ((record['template'], deployment), (candidate['template'], desired)):
|
||||||
runtime_settings.sysctl_content(plan)
|
runtime_settings.sysctl_content(plan)
|
||||||
runtime_settings.tmpfs_lines(plan)
|
runtime_settings.tmpfs_lines(plan)
|
||||||
gpu_devices.planned(plan)
|
gpu_devices.planned(plan)
|
||||||
paths = []
|
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', []):
|
for mount in plan.get('mounts', []):
|
||||||
target = host_mounts.valid_path(mount['container_path'])
|
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('/') + '/')
|
if (any(target == p or target.startswith(p.rstrip('/') + '/')
|
||||||
or p.startswith(target.rstrip('/') + '/') for p in paths)):
|
or p.startswith(target.rstrip('/') + '/') for p in paths)):
|
||||||
raise ValueError(translate('Mount paths must not overlap'))
|
raise ValueError(translate('Mount paths must not overlap'))
|
||||||
@@ -465,15 +520,18 @@ def preflight(record, candidate, config, coordinated=None):
|
|||||||
else:
|
else:
|
||||||
raise ValueError(translate('Unsupported mount type'))
|
raise ValueError(translate('Unsupported mount type'))
|
||||||
paths.append(target)
|
paths.append(target)
|
||||||
if cfg.get('unprivileged') != '1' or ':' not in cfg.get('rootfs', ''):
|
if cfg.get('unprivileged', '0') != ('1' if unprivileged else '0') or ':' not in cfg.get('rootfs', ''):
|
||||||
raise ValueError(translate('A managed rootfs and an unprivileged container are required'))
|
raise ValueError(translate('The container disks do not match the saved record'))
|
||||||
if desired['rootfs'] != deployment['rootfs']:
|
if desired['rootfs'] != deployment['rootfs']:
|
||||||
raise ValueError(translate('Changing the rootfs or its storage requires a separate migration'))
|
raise ValueError(translate('Changing the rootfs or its storage requires a separate migration'))
|
||||||
actual = mounts(config)
|
actual = mounts(config)
|
||||||
gpu_devices.check(config, deployment)
|
gpu_devices.check(config, deployment)
|
||||||
gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment)
|
gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment)
|
||||||
old = {m['container_path']: m for m in deployment.get('mounts', [])}
|
recorded_files = {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)}
|
||||||
new = {m['container_path']: m for m in desired.get('mounts', [])}
|
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):
|
if set(actual) != set(old):
|
||||||
raise ValueError(translate('The container disks do not match the saved record'))
|
raise ValueError(translate('The container disks do not match the saved record'))
|
||||||
for target, mount in actual.items():
|
for target, mount in actual.items():
|
||||||
@@ -500,24 +558,31 @@ def preflight(record, candidate, config, coordinated=None):
|
|||||||
check = effective_healthcheck(candidate['template'])
|
check = effective_healthcheck(candidate['template'])
|
||||||
if not check and not coordinated:
|
if not check and not coordinated:
|
||||||
raise ValueError(translate('Updates are not available yet for this application in this beta'))
|
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:
|
if not mac:
|
||||||
raise ValueError(translate('The MAC address of the container cannot be kept'))
|
raise ValueError(translate('The MAC address of the container cannot be kept'))
|
||||||
return cfg, actual, mac
|
return cfg, actual, mac
|
||||||
|
|
||||||
|
|
||||||
def freeze_host_sources(record, candidate, acknowledge_external_data):
|
def freeze_host_sources(record, candidate, acknowledge_external_data, files=()):
|
||||||
old = {m['source'] for m in record['deployment'].get('mounts', []) if m['type'] == 'host-bind'}
|
# A single file mounted from the host holds no data of the application:
|
||||||
wanted = {m['source']: m for m in candidate['deployment'].get('mounts', []) if m['type'] == 'host-bind'}
|
# the installer mounts it again, and nothing of it is pinned.
|
||||||
if (old or wanted) and not acknowledge_external_data:
|
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'))
|
raise ValueError(translate('Host data is not restored by the backup; confirm it with --acknowledge-external-data'))
|
||||||
baseline = record['observed'].get('host_bind_sources', {})
|
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():
|
for source, previous in baseline.items():
|
||||||
if source in original and not host_mounts.same_source(previous, original[source]):
|
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'))
|
raise ValueError(translate('A shared source does not match its recorded identity'))
|
||||||
desired = {p: original[p] if p in original else
|
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()}
|
for p, m in wanted.items()}
|
||||||
if old or wanted:
|
if old or wanted:
|
||||||
log('host directories: not included in the backup and not reverted by a recovery')
|
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):
|
def check_runtime_mounts(config, deployment):
|
||||||
runtime_settings.check(config, deployment, deployment['vmid'])
|
runtime_settings.check(config, deployment, deployment['vmid'])
|
||||||
actual = mounts(config)
|
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():
|
if actual.keys() != declared.keys():
|
||||||
raise ValueError(translate('The mounts of the new container do not match the proposal'))
|
raise ValueError(translate('The mounts of the new container do not match the proposal'))
|
||||||
for target, value in actual.items():
|
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'):
|
or record['installation_id'] != state['record']['installation_id'] or record['status'] != 'updating'):
|
||||||
raise ValueError(translate('Mount not authorized by the operation'))
|
raise ValueError(translate('Mount not authorized by the operation'))
|
||||||
expected = state['desired_host_sources'][source]
|
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))
|
if ((expected['exists'] and not host_mounts.same_source(expected, current))
|
||||||
or current['resolved_path'] != expected['resolved_path']):
|
or current['resolved_path'] != expected['resolved_path']):
|
||||||
raise ValueError(translate('The host directory changed before it was mounted'))
|
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'))
|
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):
|
def healthcheck(vmid, template):
|
||||||
check = effective_healthcheck(template)
|
check = effective_healthcheck(template)
|
||||||
if not check:
|
if not check:
|
||||||
return None
|
return None
|
||||||
|
if check.get('type') == 'haos':
|
||||||
|
return haos_healthcheck(vmid, int(check['timeout_seconds']))
|
||||||
if check.get('type') == 'running':
|
if check.get('type') == 'running':
|
||||||
stability = int(check.get('stability_seconds', 20))
|
stability = int(check.get('stability_seconds', 20))
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
@@ -603,10 +709,12 @@ def healthcheck(vmid, template):
|
|||||||
or not 0 <= stability < timeout <= 3600):
|
or not 0 <= stability < timeout <= 3600):
|
||||||
raise ValueError(translate('Invalid health check'))
|
raise ValueError(translate('Invalid health check'))
|
||||||
started, stable_since = time.monotonic(), None
|
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:
|
while time.monotonic() - started < timeout:
|
||||||
if run('pct', 'status', str(vmid)).strip() != b'status: running':
|
if run('pct', 'status', str(vmid)).strip() != b'status: running':
|
||||||
raise ValueError(translate('The restored service stopped; the recovery is not confirmed'))
|
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)
|
ip = next((a for a in addresses if re.fullmatch(r'[0-9]+(?:\.[0-9]+){3}', a)), None)
|
||||||
ok = False
|
ok = False
|
||||||
if ip:
|
if ip:
|
||||||
@@ -820,16 +928,6 @@ def backup_size(vmid):
|
|||||||
return total
|
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):
|
def prune_backups(root, vmid):
|
||||||
"""The backups of closed operations are removed once the container works
|
"""The backups of closed operations are removed once the container works
|
||||||
with its new image; their journal and log stay."""
|
with its new image; their journal and log stay."""
|
||||||
@@ -843,9 +941,7 @@ def prune_backups(root, vmid):
|
|||||||
continue
|
continue
|
||||||
if phase not in ('committed', 'rolled-back'):
|
if phase not in ('committed', 'rolled-back'):
|
||||||
continue
|
continue
|
||||||
for backup in (directory / 'backup').glob('vzdump-lxc-*'):
|
work_backup.clear(directory)
|
||||||
if backup.is_file() and not backup.is_symlink():
|
|
||||||
backup.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def check_archive(archive):
|
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))
|
before = run('pct', 'config', str(vmid))
|
||||||
cfg, actual, mac = preflight(record, candidate, before, coordinated)
|
cfg, actual, mac = preflight(record, candidate, before, coordinated)
|
||||||
description = original_description({'record': record, 'before_config': before.decode()})
|
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'])
|
original_gpu = gpu_devices.planned(record['deployment'])
|
||||||
desired_gpu = gpu_devices.planned(candidate['deployment'])
|
desired_gpu = gpu_devices.planned(candidate['deployment'])
|
||||||
resources = json.loads(run('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json'))
|
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'])):
|
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'))
|
raise ValueError(translate('The new image requires additional persistent paths; use Modify'))
|
||||||
directory = instances.location(root, vmid).parent / 'transactions' / uuid.uuid4().hex
|
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)
|
private_directory(directory)
|
||||||
open_log(directory)
|
open_log(directory)
|
||||||
if not coordinated:
|
if work != directory:
|
||||||
require_backup_space(directory, [vmid])
|
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'
|
journal = directory / 'transaction.json'
|
||||||
runtime_deployment = copy.deepcopy(candidate['deployment'])
|
runtime_deployment = copy.deepcopy(candidate['deployment'])
|
||||||
runtime_deployment['network']['mac_address'] = mac
|
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'])
|
run('zstd', '-t', backup['archive'])
|
||||||
state.update(backup=backup['archive'], backup_sha256=backup['sha256'])
|
state.update(backup=backup['archive'], backup_sha256=backup['sha256'])
|
||||||
else:
|
else:
|
||||||
backup_dir = directory / 'backup'
|
backup_dir = work / 'backup'
|
||||||
private_directory(backup_dir)
|
private_directory(backup_dir)
|
||||||
backup = verified_backup(vmid, backup_dir, backup_compression,
|
backup = verified_backup(vmid, backup_dir, backup_compression,
|
||||||
translate('The backup could not be identified; the image is not replaced'), show)
|
translate('The backup could not be identified; the image is not replaced'), show)
|
||||||
@@ -1195,6 +1299,8 @@ def recover(root, journal):
|
|||||||
if show:
|
if show:
|
||||||
msg_ok(translate('Recovery completed. The container had not been modified yet.'))
|
msg_ok(translate('Recovery completed. The container had not been modified yet.'))
|
||||||
return
|
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']:
|
if filehash(backup) != state['backup_sha256']:
|
||||||
raise ValueError(translate('The backup was altered; recovery blocked'))
|
raise ValueError(translate('The backup was altered; recovery blocked'))
|
||||||
run('gzip' if state.get('backup_compression') == 'gzip' else 'zstd', '-t', backup)
|
run('gzip' if state.get('backup_compression') == 'gzip' else 'zstd', '-t', backup)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
import oci_image_cache as image_cache
|
import oci_image_cache as image_cache
|
||||||
|
import oci_work_backup as work_backup
|
||||||
import oci_instances as instances
|
import oci_instances as instances
|
||||||
from oci_installation_state import parse_config
|
from oci_installation_state import parse_config
|
||||||
import oci_console
|
import oci_console
|
||||||
@@ -119,9 +120,11 @@ def _unlink(path):
|
|||||||
|
|
||||||
def remove_host_state(vmid):
|
def remove_host_state(vmid):
|
||||||
"""What the installation kept on the host for a container that is gone:
|
"""What the installation kept on the host for a container that is gone:
|
||||||
its sysctl include, the Rclone mount hookscript and the views it
|
its sysctl include, its seccomp profile, the Rclone mount hookscript and
|
||||||
published, and its registration in the App tab of ProxMenux Monitor."""
|
the views it published, and its registration in the App tab of ProxMenux
|
||||||
for include in (runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid)):
|
Monitor."""
|
||||||
|
for include in (runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid),
|
||||||
|
runtime_settings.seccomp_path(vmid)):
|
||||||
_unlink(include)
|
_unlink(include)
|
||||||
hook = SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh'
|
hook = SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh'
|
||||||
if hook.is_file() and not hook.is_symlink():
|
if hook.is_file() and not hook.is_symlink():
|
||||||
@@ -179,7 +182,7 @@ def release_shared_host_files(hookscripts):
|
|||||||
def _leftovers(vmid):
|
def _leftovers(vmid):
|
||||||
"""Whether anything of the container is still on the host."""
|
"""Whether anything of the container is still on the host."""
|
||||||
paths = [runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid),
|
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',
|
CLUSTER_RECORDS / f'{int(vmid)}.json',
|
||||||
*oci_console.LOG_DIR.glob(f'{int(vmid)}.console.log*')]
|
*oci_console.LOG_DIR.glob(f'{int(vmid)}.console.log*')]
|
||||||
return any(path.exists() for path in paths)
|
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()
|
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'),
|
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.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.*'),
|
(oci_console.LOG_DIR, r'([0-9]+)\.console\.log.*'),
|
||||||
(SNIPPETS, r'proxmenux-rclone-([0-9]+)-fuse-hook\.sh'),
|
(SNIPPETS, r'proxmenux-rclone-([0-9]+)-fuse-hook\.sh'),
|
||||||
(CLUSTER_RECORDS, r'([0-9]+)\.json')):
|
(CLUSTER_RECORDS, r'([0-9]+)\.json')):
|
||||||
@@ -347,6 +351,7 @@ def remove(root, vmid):
|
|||||||
lifecycle.unlink()
|
lifecycle.unlink()
|
||||||
for member in members:
|
for member in members:
|
||||||
directory = instances.location(root, member).parent
|
directory = instances.location(root, member).parent
|
||||||
|
work_backup.forget(root, member)
|
||||||
if directory.is_dir() and not directory.is_symlink():
|
if directory.is_dir() and not directory.is_symlink():
|
||||||
shutil.rmtree(directory)
|
shutil.rmtree(directory)
|
||||||
msg_ok(translate('Saved record removed'))
|
msg_ok(translate('Saved record removed'))
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ def sysctl_content(deployment):
|
|||||||
raise ValueError(translate('Invalid or duplicated network sysctl'))
|
raise ValueError(translate('Invalid or duplicated network sysctl'))
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
result.append(f'lxc.sysctl.{name} = {value}\n')
|
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)
|
return ''.join(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +61,28 @@ def legacy_include_path(vmid):
|
|||||||
return Path(f'/etc/pve/lxc/{int(vmid)}.proxmenux-sysctls')
|
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):
|
def check(config, deployment, vmid):
|
||||||
expected = tmpfs_lines(deployment)
|
expected = tmpfs_lines(deployment)
|
||||||
lines = config.decode().splitlines()
|
lines = config.decode().splitlines()
|
||||||
@@ -65,6 +90,10 @@ def check(config, deployment, vmid):
|
|||||||
if sorted(actual) != sorted(expected):
|
if sorted(actual) != sorted(expected):
|
||||||
raise ValueError(translate('The tmpfs mounts of the container differ from the saved record'))
|
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: ')]
|
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)
|
content = sysctl_content(deployment)
|
||||||
allowed = [[str(include_path(vmid))], [str(legacy_include_path(vmid))]] if content else [[]]
|
allowed = [[str(include_path(vmid))], [str(legacy_include_path(vmid))]] if content else [[]]
|
||||||
if includes not in allowed:
|
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'))
|
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):
|
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))
|
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)
|
return b''.join(line for line in config.splitlines(keepends=True)
|
||||||
if not line.startswith(b'lxc.include: ') and not (
|
if not line.startswith(b'lxc.include: ') and not (
|
||||||
line.startswith(b'lxc.mount.entry: ')
|
line.startswith(b'lxc.mount.entry: ') and own(line)))
|
||||||
and line.decode().strip().split(': ',1)[1] in declared))
|
|
||||||
|
|
||||||
|
|
||||||
def check_recovery(config, state):
|
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'))
|
raise ValueError(translate('A tmpfs mount is not part of the journal; recovery blocked'))
|
||||||
if line.startswith('lxc.include: '):
|
if line.startswith('lxc.include: '):
|
||||||
path = Path(line.split(': ', 1)[1])
|
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'])):
|
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'))
|
raise ValueError(translate('An include is not part of the journal; recovery blocked'))
|
||||||
contents = {sysctl_content(plan) for plan in plans} - {''}
|
contents = {sysctl_content(plan) for plan in plans} - {''}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import oci_instance_transaction as member_tx
|
|||||||
import oci_stack_plan
|
import oci_stack_plan
|
||||||
import oci_stack_transaction as stack_tx
|
import oci_stack_transaction as stack_tx
|
||||||
import oci_stack_replay as replay
|
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_installation_state import image_from_archive, parse_config, private_directory, sha
|
||||||
from oci_update_current import resolve_archive
|
from oci_update_current import resolve_archive
|
||||||
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
|
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')]
|
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:
|
if spec.get('schema') != 1 or spec.get('dependencies') != expected:
|
||||||
raise ValueError(translate('The dependency hook and the stack recipe differ'))
|
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):
|
def is_running(self, vmid):
|
||||||
return instances.command('pct', 'status', str(vmid)).strip() == b'status: running'
|
return instances.command('pct', 'status', str(vmid)).strip() == b'status: running'
|
||||||
@@ -331,7 +342,13 @@ class NativeAdapter:
|
|||||||
|
|
||||||
def backup(self, vmid, identity):
|
def backup(self, vmid, identity):
|
||||||
self.validate(self.plan)
|
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)
|
private_directory(directory)
|
||||||
archive = member_tx.verified_backup(vmid, directory, 'zstd',
|
archive = member_tx.verified_backup(vmid, directory, 'zstd',
|
||||||
translate('The backup of a member could not be identified'))
|
translate('The backup of a member could not be identified'))
|
||||||
@@ -339,6 +356,8 @@ class NativeAdapter:
|
|||||||
|
|
||||||
def verify_backups(self, backups):
|
def verify_backups(self, backups):
|
||||||
for backup in backups.values():
|
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']:
|
if member_tx.filehash(backup['archive']) != backup['sha256']:
|
||||||
raise ValueError(translate('A backup was modified'))
|
raise ValueError(translate('A backup was modified'))
|
||||||
member_tx.run('zstd', '-t', backup['archive'])
|
member_tx.run('zstd', '-t', backup['archive'])
|
||||||
@@ -579,7 +598,7 @@ class NativeAdapter:
|
|||||||
self.release_stages(recovered=state['phase'] == 'rolled-back')
|
self.release_stages(recovered=state['phase'] == 'rolled-back')
|
||||||
if self.keep_backup and state['phase'] == 'committed':
|
if self.keep_backup and state['phase'] == 'committed':
|
||||||
import oci_keep_backup
|
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)
|
kept = oci_keep_backup.keep(backup, self.keep_backup)
|
||||||
if kept:
|
if kept:
|
||||||
msg_ok(f"{translate('Backup kept in')} {self.keep_backup}: {Path(kept).name}")
|
msg_ok(f"{translate('Backup kept in')} {self.keep_backup}: {Path(kept).name}")
|
||||||
@@ -621,9 +640,7 @@ class NativeAdapter:
|
|||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
continue
|
continue
|
||||||
if phase in stack_tx.TERMINAL:
|
if phase in stack_tx.TERMINAL:
|
||||||
for backup in directory.glob('backup-*/vzdump-lxc-*'):
|
work_backup.clear(directory)
|
||||||
if backup.is_file() and not backup.is_symlink():
|
|
||||||
backup.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
# The stack journal of this run, for the summary after a failure.
|
# The stack journal of this run, for the summary after a failure.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import tempfile
|
|||||||
|
|
||||||
import oci_instances as instances
|
import oci_instances as instances
|
||||||
import oci_instance_transaction as transaction
|
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_installation_state import image_from_archive, same_image
|
||||||
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2
|
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
|
return archive, digest
|
||||||
msg_warn(translate('The cached image is damaged; it will be downloaded again.'))
|
msg_warn(translate('The cached image is damaged; it will be downloaded again.'))
|
||||||
archive.unlink()
|
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
|
# A download can come back complete yet damaged when the connection drops
|
||||||
# and the transfer resumes; the integrity check catches it, and a second
|
# and the transfer resumes; the integrity check catches it, and a second
|
||||||
# download is what repairs it.
|
# 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)
|
transaction.preflight(record, desired, config)
|
||||||
msg_ok(translate('Container checked'))
|
msg_ok(translate('Container checked'))
|
||||||
current = record['observed']['image'] if operation == 'update' else None
|
current = record['observed']['image'] if operation == 'update' else None
|
||||||
archive, digest = resolve_archive(desired, config, current, lambda: transaction.require_backup_space(
|
archive, digest = resolve_archive(desired, config, current, lambda: work_backup.locate(
|
||||||
instances.location(instances.ROOT, vmid).parent, [vmid]))
|
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid))
|
||||||
if archive is None:
|
if archive is None:
|
||||||
msg_ok(translate('The image is already up to date; nothing was changed.'))
|
msg_ok(translate('The image is already up to date; nothing was changed.'))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -137,6 +137,62 @@ def _answers_from_recipe(ui) -> bool:
|
|||||||
return isinstance(ui, DefaultsUI)
|
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]]:
|
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
|
"""Extra paths for the application container of a multi-container
|
||||||
application. An advanced installation asks them; a default one does not."""
|
application. An advanced installation asks them; a default one does not."""
|
||||||
@@ -327,6 +383,9 @@ def build_deployment(
|
|||||||
if enabled:
|
if enabled:
|
||||||
selected_security_options.update(item.get("options", {}))
|
selected_security_options.update(item.get("options", {}))
|
||||||
security_relaxation_acknowledged = True
|
security_relaxation_acknowledged = True
|
||||||
|
if item.get("privileged"):
|
||||||
|
unprivileged = False
|
||||||
|
privileged_acknowledged = True
|
||||||
runtime_profile = installer_profile.get("runtime", {})
|
runtime_profile = installer_profile.get("runtime", {})
|
||||||
hostname_default = _hostname_default(runtime_profile.get("hostname")
|
hostname_default = _hostname_default(runtime_profile.get("hostname")
|
||||||
or template["container_contract"]["container_name"])
|
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:
|
if advanced:
|
||||||
mounts = ask_custom_mounts(ui, mounts, volume_storage)
|
mounts = ask_custom_mounts(ui, mounts, volume_storage)
|
||||||
|
|
||||||
@@ -522,6 +584,9 @@ def build_deployment(
|
|||||||
value = secrets.token_hex(int(generated.get("bytes", 16)))
|
value = secrets.token_hex(int(generated.get("bytes", 16)))
|
||||||
if not value and item["required"]:
|
if not value and item["required"]:
|
||||||
raise InstallError(f"{name}: {translate('a value is 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, ""):
|
elif not advanced and default not in (None, ""):
|
||||||
value = default
|
value = default
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -286,6 +286,44 @@ def set_watchdog(project, vmids, enabled):
|
|||||||
return True
|
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):
|
def _toggle_watchdog(project, ui, vmid, enabled):
|
||||||
"""Ask and change the watchdog of an application from its menu."""
|
"""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?')
|
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)
|
return _toggle_watchdog(project, ui, row['vmid'], watched)
|
||||||
if action == 'remove':
|
if action == 'remove':
|
||||||
return _remove(project, ui, row['vmid'])
|
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
|
import oci_instance_reconcile as reconcile
|
||||||
try:
|
try:
|
||||||
current_config = instances.command('pct', 'config', str(row['vmid']))
|
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
|
return False
|
||||||
import json
|
import json
|
||||||
members = json.loads(Path(pending).read_text())['plan']['members']
|
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)]
|
command = [sys.executable, str(project / 'remote/oci_stack_native.py'), str(primary_id)]
|
||||||
if pending:
|
if pending:
|
||||||
command.append('--recover')
|
command.append('--recover')
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ def edit_recreation(record, ui):
|
|||||||
resources = deployment['resources']
|
resources = deployment['resources']
|
||||||
resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores'])
|
resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores'])
|
||||||
resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128)
|
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
|
from .custom_mounts import ask_custom_mounts
|
||||||
deployment['mounts'] = ask_custom_mounts(
|
deployment['mounts'] = ask_custom_mounts(
|
||||||
ui, deployment['mounts'], deployment['rootfs']['storage'])
|
ui, deployment['mounts'], deployment['rootfs']['storage'])
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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() {
|
uninstall_proxmenux_monitor() {
|
||||||
|
|
||||||
# 1. Stop service if it is running
|
# 1. Stop service if it is running
|
||||||
@@ -81,6 +92,8 @@ uninstall_proxmenux_monitor() {
|
|||||||
echo " - Service is not running (ok)"
|
echo " - Service is not running (ok)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
remove_monitor_notification_target
|
||||||
|
|
||||||
# 2. Disable service if enabled
|
# 2. Disable service if enabled
|
||||||
if systemctl is-enabled --quiet "${MONITOR_SERVICE}"; then
|
if systemctl is-enabled --quiet "${MONITOR_SERVICE}"; then
|
||||||
echo " - Disabling service..."
|
echo " - Disabling service..."
|
||||||
@@ -213,7 +226,7 @@ normalize_stable_monitor_service() {
|
|||||||
cat > "$MONITOR_UNIT_FILE" << EOF
|
cat > "$MONITOR_UNIT_FILE" << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=ProxMenux Monitor - Web Dashboard
|
Description=ProxMenux Monitor - Web Dashboard
|
||||||
After=network.target
|
After=network.target pve-cluster.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
"<strong>Capacity</strong> — formatted human-readable.",
|
"<strong>Capacity</strong> — formatted human-readable.",
|
||||||
"<strong>Temperature</strong> — current °C, coloured by the disk-type threshold (NVMe runs warmer than SATA).",
|
"<strong>Temperature</strong> — current °C, coloured by the disk-type threshold (NVMe runs warmer than SATA).",
|
||||||
"<strong>SMART status</strong> — passed / failed / unknown.",
|
"<strong>SMART status</strong> — passed / failed / unknown.",
|
||||||
"<strong>Observations badge</strong> — when the permanent <code>disk_observations</code> history has un-dismissed entries for this disk, a blue badge with the count appears (e.g. <em>3 obs.</em>). Click the disk to drill in and review them.",
|
"<strong>Observations badge</strong> — when the permanent <code>disk_observations</code> history has entries for this disk, a blue badge with the count appears (e.g. <em>3 obs.</em>). Click the disk to drill in and review them.",
|
||||||
"<strong>Health badge</strong> — Healthy / Warning / Critical, derived from the SMART check + recent observations."
|
"<strong>Health badge</strong> — 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.",
|
"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",
|
"obsDedupTitle": "How dedup and re-notification work",
|
||||||
"obsDedupBody1": "Observations are deduplicated by their <strong>signature</strong> — a stable fingerprint of the error type, device and key fields of the kernel line. The same event repeating bumps the <code>occurrence_count</code> on the existing row rather than creating a new one. A <strong>different signature</strong> on the same disk creates a new observation and is treated as a new event for notification purposes.",
|
"obsDedupBody1": "Observations are deduplicated by their <strong>signature</strong> — a stable fingerprint of the error type, device and key fields of the kernel line. The same event repeating bumps the <code>occurrence_count</code> on the existing row rather than creating a new one. A <strong>different signature</strong> 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.",
|
"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",
|
"obsDismissTitle": "Permanent history vs active errors",
|
||||||
"obsDismissBody1": "Each row has a <strong>dismiss</strong> action. Dismissing an observation tells ProxMenux \"I've seen this, stop notifying me about it\". It does <strong>not</strong> 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\".",
|
"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 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 <link>Health Monitor</link> for the active-error side of the same picture."
|
"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 <link>Health Monitor</link> for the active-error side of the same picture."
|
||||||
},
|
},
|
||||||
"dataCollected": {
|
"dataCollected": {
|
||||||
"heading": "How the data is collected",
|
"heading": "How the data is collected",
|
||||||
|
|||||||
@@ -342,7 +342,7 @@
|
|||||||
"practiceBody": "The PVE setup writes the target as <code>http://127.0.0.1:8008</code>, 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 <code>https://<monitor-host>:<port>/api/notifications/webhook</code> and supply the <code>X-Webhook-Secret</code> header to use it.",
|
"practiceBody": "The PVE setup writes the target as <code>http://127.0.0.1:8008</code>, 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 <code>https://<monitor-host>:<port>/api/notifications/webhook</code> and supply the <code>X-Webhook-Secret</code> header to use it.",
|
||||||
"actionsIntro": "The Monitor manages this target through three actions on the Settings tab:",
|
"actionsIntro": "The Monitor manages this target through three actions on the Settings tab:",
|
||||||
"actionsItems": [
|
"actionsItems": [
|
||||||
"<strong>Setup</strong> — runs automatically when you enable Notifications. Creates the entry in <code>/etc/pve/notifications.cfg</code> after backing up the current file.",
|
"<strong>Setup</strong> — runs automatically when you enable Notifications. Creates the entry in <code>/etc/pve/notifications.cfg</code>. The file is written only when its content has to change, and a copy of the previous one is kept in <code>/usr/local/share/proxmenux/backups/pve-notifications/</code> (the three newest). Nothing is written while <code>/etc/pve</code> is not mounted: a Monitor that starts during a restart of <code>pve-cluster</code> registers the entry once it is back.",
|
||||||
"<strong>Cleanup</strong> — removes the entry. The previous backup of the file is kept.",
|
"<strong>Cleanup</strong> — removes the entry. The previous backup of the file is kept.",
|
||||||
"<strong>Read config</strong> — 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."
|
"<strong>Read config</strong> — 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."
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"items": [
|
"items": [
|
||||||
"<strong>Confirmation dialog.</strong> Yes/no — “Are you sure you want to uninstall ProxMenux?”",
|
"<strong>Confirmation dialog.</strong> Yes/no — “Are you sure you want to uninstall ProxMenux?”",
|
||||||
"<strong>Dependency removal checklist.</strong> Pre-selected list depends on install type (see table below). All boxes default to OFF — you opt in to removing each dependency.",
|
"<strong>Dependency removal checklist.</strong> Pre-selected list depends on install type (see table below). All boxes default to OFF — you opt in to removing each dependency.",
|
||||||
"<strong>Removal runs with a progress bar.</strong> Removes the venv (Translation install only), the <code>menu</code> launcher, the <code>/usr/local/share/proxmenux/</code> tree, the ProxMenux Monitor service if present, the dependencies you ticked, and finally restores the system file backups (see below).",
|
"<strong>Removal runs with a progress bar.</strong> Removes the venv (Translation install only), the <code>menu</code> launcher, the <code>/usr/local/share/proxmenux/</code> tree, the ProxMenux Monitor service if present together with the notification target it registered in Proxmox VE (<code>proxmenux-webhook</code> and its matcher), the dependencies you ticked, and finally restores the system file backups (see below).",
|
||||||
"<strong>Completion dialog.</strong> Lists removed dependencies and exits."
|
"<strong>Completion dialog.</strong> Lists removed dependencies and exits."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
"<strong>Capacidad</strong> — formateada de forma legible.",
|
"<strong>Capacidad</strong> — formateada de forma legible.",
|
||||||
"<strong>Temperatura</strong> — °C actuales, coloreada por el umbral de tipo de disco (NVMe corre más caliente que SATA).",
|
"<strong>Temperatura</strong> — °C actuales, coloreada por el umbral de tipo de disco (NVMe corre más caliente que SATA).",
|
||||||
"<strong>Estado SMART</strong> — passed / failed / unknown.",
|
"<strong>Estado SMART</strong> — passed / failed / unknown.",
|
||||||
"<strong>Insignia de observaciones</strong> — cuando el historial permanente <code>disk_observations</code> tiene entradas no descartadas para este disco, aparece una insignia azul con el recuento (p. ej. <em>3 obs.</em>). Pulsa el disco para entrar y revisarlas.",
|
"<strong>Insignia de observaciones</strong> — cuando el historial permanente <code>disk_observations</code> tiene entradas para este disco, aparece una insignia azul con el recuento (p. ej. <em>3 obs.</em>). Pulsa el disco para entrar y revisarlas.",
|
||||||
"<strong>Insignia de salud</strong> — Healthy / Warning / Critical, derivada del chequeo SMART + observaciones recientes."
|
"<strong>Insignia de salud</strong> — 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.",
|
"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",
|
"obsDedupTitle": "Cómo funcionan dedup y re-notificación",
|
||||||
"obsDedupBody1": "Las observaciones se deduplican por su <strong>signature</strong> — una huella estable del tipo de error, dispositivo y campos clave de la línea del kernel. El mismo evento repitiéndose sube el <code>occurrence_count</code> de la fila existente en lugar de crear una nueva. Una <strong>signature diferente</strong> en el mismo disco crea una nueva observación y se trata como un evento nuevo para efectos de notificación.",
|
"obsDedupBody1": "Las observaciones se deduplican por su <strong>signature</strong> — una huella estable del tipo de error, dispositivo y campos clave de la línea del kernel. El mismo evento repitiéndose sube el <code>occurrence_count</code> de la fila existente en lugar de crear una nueva. Una <strong>signature diferente</strong> 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.",
|
"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",
|
"obsDismissTitle": "Historial permanente vs errores activos",
|
||||||
"obsDismissBody1": "Cada fila tiene una acción <strong>dismiss</strong>. Descartar una observación le dice a ProxMenux \"ya lo he visto, deja de notificarme\". <strong>No</strong> 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í\".",
|
"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 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 <link>Monitor de salud</link> para el lado de error activo de la misma imagen."
|
"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 <link>Monitor de salud</link> para el lado de error activo de la misma imagen."
|
||||||
},
|
},
|
||||||
"dataCollected": {
|
"dataCollected": {
|
||||||
"heading": "Cómo se recopilan los datos",
|
"heading": "Cómo se recopilan los datos",
|
||||||
|
|||||||
@@ -342,7 +342,7 @@
|
|||||||
"practiceBody": "El setup de PVE escribe el destino como <code>http://127.0.0.1:8008</code>, 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 <code>https://<host-monitor>:<port>/api/notifications/webhook</code> y suministra la cabecera <code>X-Webhook-Secret</code> para usarlo.",
|
"practiceBody": "El setup de PVE escribe el destino como <code>http://127.0.0.1:8008</code>, 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 <code>https://<host-monitor>:<port>/api/notifications/webhook</code> y suministra la cabecera <code>X-Webhook-Secret</code> para usarlo.",
|
||||||
"actionsIntro": "El Monitor gestiona este destino a través de tres acciones en la pestaña Settings:",
|
"actionsIntro": "El Monitor gestiona este destino a través de tres acciones en la pestaña Settings:",
|
||||||
"actionsItems": [
|
"actionsItems": [
|
||||||
"<strong>Setup</strong> — corre automáticamente cuando activas Notifications. Crea la entrada en <code>/etc/pve/notifications.cfg</code> tras hacer backup del archivo actual.",
|
"<strong>Setup</strong> — corre automáticamente cuando activas Notifications. Crea la entrada en <code>/etc/pve/notifications.cfg</code>. El archivo solo se escribe cuando su contenido tiene que cambiar, y se guarda un backup del anterior en <code>/usr/local/share/proxmenux/backups/pve-notifications/</code> (los tres más recientes). No se escribe nada mientras <code>/etc/pve</code> no está montado: un Monitor que arranca durante un reinicio de <code>pve-cluster</code> registra la entrada cuando vuelve.",
|
||||||
"<strong>Cleanup</strong> — elimina la entrada. El backup previo del archivo se conserva.",
|
"<strong>Cleanup</strong> — elimina la entrada. El backup previo del archivo se conserva.",
|
||||||
"<strong>Read config</strong> — 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."
|
"<strong>Read config</strong> — 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."
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"items": [
|
"items": [
|
||||||
"<strong>Diálogo de confirmación.</strong> Sí/no — \"¿Seguro que quieres desinstalar ProxMenux?\"",
|
"<strong>Diálogo de confirmación.</strong> Sí/no — \"¿Seguro que quieres desinstalar ProxMenux?\"",
|
||||||
"<strong>Checklist de eliminación de dependencias.</strong> 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.",
|
"<strong>Checklist de eliminación de dependencias.</strong> 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.",
|
||||||
"<strong>La eliminación corre con una barra de progreso.</strong> Elimina el venv (solo instalación con traducción), el lanzador <code>menu</code>, el árbol <code>/usr/local/share/proxmenux/</code>, el servicio ProxMenux Monitor si está presente, las dependencias que marcaste y, por último, restaura los backups de archivos del sistema (mira abajo).",
|
"<strong>La eliminación corre con una barra de progreso.</strong> Elimina el venv (solo instalación con traducción), el lanzador <code>menu</code>, el árbol <code>/usr/local/share/proxmenux/</code>, el servicio ProxMenux Monitor si está presente junto con el destino de notificaciones que registró en Proxmox VE (<code>proxmenux-webhook</code> y su matcher), las dependencias que marcaste y, por último, restaura los backups de archivos del sistema (mira abajo).",
|
||||||
"<strong>Diálogo de finalización.</strong> Lista las dependencias eliminadas y sale."
|
"<strong>Diálogo de finalización.</strong> Lista las dependencias eliminadas y sale."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user