mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-09 06:56:37 +00:00
feat(oci): recover applications after a Proxmox reinstall, cluster records and AMD GPU profiles
The installation record of an OCI application travels with its container: a copy inside the container and another in /etc/pve, written together with the one kept on the host. A container restored on a newly installed Proxmox, restored with another ID or moved to another node of a cluster is recognised and registered again, with its private network, hookscript, Rclone mount, host firewall rule and NVIDIA runtime. The Monitor offers the same recovery from the Updates tab. AMD GPUs are offered by generation. A GPU the ROCm image supports takes the profile as it is; one of a supported family (Radeon 680M, 780M) is an experimental option that asks for confirmation and is never proposed; an older one is not offered. The GPU is checked with a real inference before the installation accepts it. Recreate changes what runs recognition in an installed Immich, between the CPU and a GPU of the host. Updates: - A failed update that is restored and checked removes its temporary container and the disks of the failed attempt. - Every container volume is part of the backups, so Jellyfin, Plex and Hugo update with their default installation. - An image published with a Docker-format manifest is recognised by its layers and build time and updates. - The Proxmox notes of a multi-container application link to its LAN address.
This commit is contained in:
@@ -3,13 +3,16 @@
|
||||
Read-only view of the installation record OCI manager Apps keeps for every
|
||||
container it created: whether the container is one, whether it belongs to a
|
||||
multi-container application, whether it uses host directories (which its
|
||||
backup does not revert) and whether an operation is pending. Nothing here
|
||||
backup does not revert), whether an operation is pending and whether it was
|
||||
restored from a backup and is not registered on this host yet. Nothing here
|
||||
changes the record or runs inside the container.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
|
||||
import oci_console_logs
|
||||
|
||||
@@ -26,6 +29,30 @@ def _record(vmid: int) -> dict | None:
|
||||
return record if isinstance(record, dict) else None
|
||||
|
||||
|
||||
def _installation(vmid: int) -> str | None:
|
||||
"""The installation the container says it belongs to: the mark OCI manager
|
||||
Apps leaves in its notes, which a backup keeps."""
|
||||
path = f"/etc/pve/nodes/{socket.gethostname().split('.', 1)[0]}/lxc/{int(vmid)}.conf"
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as handle:
|
||||
text = handle.read().split("\n[", 1)[0]
|
||||
except OSError:
|
||||
return None
|
||||
match = re.search(r"^#.*proxmenux-instance=([0-9a-f-]{36})(?![0-9a-f-])", text, re.MULTILINE)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _unrecoverable(vmid: int, installation: str) -> bool:
|
||||
"""A restored container that carries no copy of its record: the menu
|
||||
found nothing to recover it from and left it as an ordinary container."""
|
||||
try:
|
||||
with open(os.path.join(ROOT, ".unrecoverable.json"), encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return isinstance(value, dict) and value.get(str(int(vmid))) == installation
|
||||
|
||||
|
||||
def _host_dirs(record: dict) -> bool:
|
||||
mounts = (record.get("deployment") or {}).get("mounts") or []
|
||||
return any(isinstance(m, dict) and m.get("type") == "host-bind" for m in mounts)
|
||||
@@ -42,8 +69,14 @@ def info(vmid: int) -> dict:
|
||||
"members": [],
|
||||
"host_directories": False,
|
||||
"pending": False,
|
||||
"restored": False,
|
||||
}
|
||||
record = _record(vmid)
|
||||
installation = _installation(vmid)
|
||||
if installation and (record is None or record.get("installation_id") != installation):
|
||||
# Restored from a backup: the record stayed on the host it came from.
|
||||
result["restored"] = not _unrecoverable(vmid, installation)
|
||||
return result
|
||||
if record is None:
|
||||
return result
|
||||
primary_id = int((record.get("stack_member") or {}).get("primary_vmid") or vmid)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""A container restored from a backup carries the mark of its installation and
|
||||
has no record on this host: the modal says so instead of treating it as an
|
||||
ordinary container."""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import oci_instance_info
|
||||
|
||||
APP = "83e373f2-5ccc-4548-9b89-22d0956d1c77"
|
||||
OTHER = "488ed3cb-1145-477a-b90f-c46777d69fb2"
|
||||
|
||||
|
||||
class RestoredInstanceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
for target, value in (("ROOT", str(self.root)),):
|
||||
patcher = patch.object(oci_instance_info, target, value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
patcher = patch.object(oci_instance_info.oci_console_logs, "configured_log", return_value=None)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def record(self, vmid, identity):
|
||||
folder = self.root / str(vmid)
|
||||
folder.mkdir()
|
||||
(folder / "oci-compose.json").write_text(json.dumps(
|
||||
{"vmid": vmid, "installation_id": identity, "status": "installed", "deployment": {}}))
|
||||
|
||||
def info(self, vmid, installation):
|
||||
with patch.object(oci_instance_info, "_installation", return_value=installation):
|
||||
return oci_instance_info.info(vmid)
|
||||
|
||||
def test_a_marked_container_without_record_was_restored(self):
|
||||
result = self.info(119, APP)
|
||||
self.assertTrue(result["restored"])
|
||||
self.assertFalse(result["oci_instance"])
|
||||
|
||||
def test_the_record_of_another_installation_does_not_register_it(self):
|
||||
self.record(119, OTHER)
|
||||
result = self.info(119, APP)
|
||||
self.assertTrue(result["restored"])
|
||||
self.assertFalse(result["oci_instance"])
|
||||
|
||||
def test_a_container_the_menu_could_not_recover_is_an_ordinary_one(self):
|
||||
(self.root / ".unrecoverable.json").write_text(json.dumps({"119": APP}))
|
||||
self.assertFalse(self.info(119, APP)["restored"])
|
||||
self.assertTrue(self.info(119, OTHER)["restored"])
|
||||
|
||||
def test_a_registered_container_is_an_oci_instance(self):
|
||||
self.record(119, APP)
|
||||
result = self.info(119, APP)
|
||||
self.assertFalse(result["restored"])
|
||||
self.assertTrue(result["oci_instance"])
|
||||
|
||||
def test_an_ordinary_container_is_neither(self):
|
||||
result = self.info(100, None)
|
||||
self.assertFalse(result["restored"])
|
||||
self.assertFalse(result["oci_instance"])
|
||||
|
||||
def test_the_mark_is_read_from_the_notes_of_the_container(self):
|
||||
text = f"#<div>Tandoor</div>\n#<!-- proxmenux-instance={APP} -->\narch: amd64\n[snap]\n#proxmenux-instance={OTHER}\n"
|
||||
with patch("builtins.open", unittest.mock.mock_open(read_data=text)):
|
||||
self.assertEqual(oci_instance_info._installation(119), APP)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user