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:
MacRimi
2026-10-04 20:32:17 +02:00
parent 7e9f16fdb3
commit 1f9d2acef5
67 changed files with 4464 additions and 126 deletions
+81
View File
@@ -0,0 +1,81 @@
"""The record of an installation travels inside its container, so a backup
restored on another host still carries it."""
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_carried_record as carried
def bundle(**extra):
return {"schema_version": 1, "kind": carried.KIND, "node": "pve", "vmid": 120,
"record": {"vmid": 120, "installation_id": "488ed3cb-1145-477a-b90f-c46777d69fb2"}, **extra}
class CarriedRecordTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.rootfs = Path(tmp.name) / "rootfs"
self.rootfs.mkdir()
self.outside = Path(tmp.name) / "outside"
self.outside.mkdir()
self.owner = (os.getuid(), os.getgid())
def test_the_copy_is_private_and_read_back_as_written(self):
carried.write_copy(self.rootfs, bundle(), self.owner)
path = self.rootfs / carried.DIRECTORY / carried.NAME
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700)
self.assertEqual(carried.read_copy(self.rootfs), bundle())
self.assertEqual(os.listdir(path.parent), [carried.NAME])
def test_a_newer_record_replaces_the_copy(self):
carried.write_copy(self.rootfs, bundle(), self.owner)
carried.write_copy(self.rootfs, bundle(node="other"), self.owner)
self.assertEqual(carried.read_copy(self.rootfs)["node"], "other")
def test_a_link_left_in_place_of_the_folder_is_not_followed(self):
(self.rootfs / carried.DIRECTORY).symlink_to(self.outside)
with self.assertRaises(OSError):
carried.write_copy(self.rootfs, bundle(), self.owner)
self.assertEqual(os.listdir(self.outside), [])
(self.outside / carried.NAME).write_text(json.dumps(bundle()))
self.assertIsNone(carried.read_copy(self.rootfs))
def test_a_link_left_in_place_of_the_copy_is_not_followed(self):
(self.outside / "secret").write_text(json.dumps(bundle()))
(self.rootfs / carried.DIRECTORY).mkdir()
(self.rootfs / carried.DIRECTORY / carried.NAME).symlink_to(self.outside / "secret")
self.assertIsNone(carried.read_copy(self.rootfs))
def test_what_is_not_a_copy_is_not_read(self):
self.assertIsNone(carried.read_copy(self.rootfs))
folder = self.rootfs / carried.DIRECTORY
folder.mkdir()
for content in ("not json", json.dumps([1]), json.dumps(bundle(kind="other")),
json.dumps(bundle(schema_version=2)), json.dumps({**bundle(), "record": "text"})):
(folder / carried.NAME).write_text(content)
self.assertIsNone(carried.read_copy(self.rootfs), content)
def test_the_time_it_was_saved_does_not_make_a_copy_different(self):
self.assertEqual(carried.digest(bundle(saved_at="2026-10-03")), carried.digest(bundle(saved_at="2026-10-04")))
self.assertEqual(carried.digest(bundle(generation="a")), carried.digest(bundle(generation="b")))
self.assertNotEqual(carried.digest(bundle()), carried.digest(bundle(node="other")))
def test_the_owner_is_root_of_the_container(self):
self.assertEqual(carried.mapped_root("arch: amd64\nunprivileged: 1\n"), (100000, 100000))
self.assertEqual(carried.mapped_root("arch: amd64\n"), (0, 0))
custom = "unprivileged: 1\nlxc.idmap: u 0 200000 65536\nlxc.idmap: g 0 300000 65536\nlxc.idmap: u 1000 1000 1\n"
self.assertEqual(carried.mapped_root(custom), (200000, 300000))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,47 @@
"""A container volume is always part of the backups: an update restores its
application from one, and refuses a volume that would be left out."""
import json
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def volumes_without_backup(value, found=None):
"""Container paths a template would create as a container volume left out of the backups."""
found = [] if found is None else found
if isinstance(value, dict):
managed = value.get("managed_volume")
if (isinstance(managed, dict) and managed.get("backup") is not True
and "managed-volume" in (value.get("installation_choice") or [])):
found.append(value.get("container_path"))
if value.get("mode") == "managed-volume" and value.get("backup") is False:
found.append(value.get("container_path"))
for item in value.values():
volumes_without_backup(item, found)
elif isinstance(value, list):
for item in value:
volumes_without_backup(item, found)
return found
class ContainerVolumeBackupTests(unittest.TestCase):
def test_no_application_of_the_catalog_creates_a_volume_left_out_of_the_backups(self):
for folder in ("apps", "curated"):
for path in sorted((ROOT / "catalog" / folder).glob("*.json")):
template = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(volumes_without_backup(template), [], f"{folder}/{path.name}")
def test_a_converted_compose_backs_up_every_volume_it_creates(self):
from proxmenux_oci import converter
contract = converter._mount_contract(["/srv/app/config:/config", "/srv/app/cache:/cache",
"/srv/app/transcode:/transcode", "/srv/app/tmp:/tmp"], set())
self.assertEqual([item["container_path"] for item in contract], ["/config", "/cache", "/transcode", "/tmp"])
self.assertTrue(all(item["managed_volume"]["backup"] is True for item in contract))
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -53,3 +53,146 @@ class FrigateAmdProfileTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class FrigateAmdGenerationTests(unittest.TestCase):
"""The ROCm image of Frigate carries the kernels of RDNA2 and newer GPUs:
on an older one, such as the integrated Vega of a Ryzen 5000U, its
detector aborts, so the profile is not offered there."""
def profile(self):
profiles = Catalog(ROOT).compose("frigate")["proxmox"]["installer_profile"]["hardware_acceleration"]["profiles"]
return next(item for item in profiles if item["id"] == "rocm")
def usable(self, target):
from proxmenux_oci.installer import _profile_usable
found = {"intel": [], "amd": ["/dev/dri/renderD128"], "nvidia": False, "amd_gfx_target": target}
with patch("proxmenux_oci.installer.Path.is_char_device", return_value=True):
return _profile_usable(self.profile(), found)
def test_the_profile_is_offered_on_the_generations_the_image_supports(self):
self.assertIn(100300, self.profile()["amd_gfx_targets"])
self.assertTrue(self.usable(100300))
self.assertTrue(self.usable(110501))
self.assertFalse(self.usable(90012))
self.assertFalse(self.usable(100100))
# The other GPUs of a family it supports are offered as experimental.
self.assertTrue(self.usable(100305))
self.assertTrue(self.usable(110003))
# A driver that does not say the generation does not hide the profile.
self.assertTrue(self.usable(None))
def test_the_generation_is_read_from_the_compute_driver(self):
import tempfile
from proxmenux_oci import host
with tempfile.TemporaryDirectory() as directory:
nodes = Path(directory) / "sys/class/kfd/kfd/topology/nodes"
for index, value in enumerate((0, 90012)):
(nodes / str(index)).mkdir(parents=True)
(nodes / str(index) / "properties").write_text(f"simd_count 32\ngfx_target_version {value}\ndevice_id 5708\n")
self.assertEqual(host.amd_gfx_target(Path(directory)), 90012)
self.assertIsNone(host.amd_gfx_target(Path(directory) / "none"))
self.assertEqual([host.gfx_name(value) for value in (90012, 100300, 110501, 90010, 90402)],
["gfx90c", "gfx1030", "gfx1151", "gfx90a", "gfx942"])
def test_recognition_with_rocm_needs_a_supported_generation(self):
from proxmenux_oci import host
with patch("proxmenux_oci.host.Path.is_char_device", return_value=True), \
patch("proxmenux_oci.host.storages", return_value=[]):
for target, expected in ((90012, "generation"), (100100, "generation"), (100305, None),
(110003, None), (110501, None), (90010, None), (None, None)):
with patch("proxmenux_oci.host.amd_gfx_target", return_value=target):
self.assertEqual(host.rocm_blocker("local-lvm"), expected, target)
def test_how_each_generation_runs_rocm(self):
from proxmenux_oci import host
expected = {100300: ("native", None), 110501: ("native", None), 90010: ("native", None),
100305: ("experimental", "10.3.0"), 100302: ("experimental", "10.3.0"),
110003: ("experimental", "11.0.0"), 90012: (None, None), 100100: (None, None),
110502: (None, None), None: ("native", None)}
for target, (support, override) in expected.items():
self.assertEqual((host.rocm_support(target), host.rocm_override(target)), (support, override), target)
# An image with fewer kernels supports fewer generations.
self.assertIsNone(host.rocm_support(110003, (100300,)))
def test_the_gpu_is_named_as_its_owner_knows_it(self):
from proxmenux_oci import host
from types import SimpleNamespace
listed = ("35:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] "
"Rembrandt [Radeon 680M] [1002:1681] (rev c7)\n")
plain = "04:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Lucienne [1002:164c] (rev c1)\n"
for output, target, name in ((listed, 100305, "Radeon 680M (gfx1035)"), (plain, 90012, "Lucienne (gfx90c)"),
("", 100300, "AMD GPU (gfx1030)"), ("", None, "AMD GPU")):
with patch("proxmenux_oci.host.subprocess.run", return_value=SimpleNamespace(stdout=output)), \
patch("proxmenux_oci.host.amd_gfx_target", return_value=target):
self.assertEqual(host.amd_gpu_name(), name)
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@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.Path.is_char_device", return_value=True)
@patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Radeon 680M (gfx1035)")
class FrigateExperimentalRocmTests(unittest.TestCase):
"""On a GPU ROCm does not support officially the profile is offered as
experimental, explained before the larger image is downloaded, and never
taken without a yes."""
WARNING = "ROCm does not support this GPU"
class UI(RecordingUI):
def __init__(self, answers, accept):
super().__init__(answers)
self.labels, self.messages, self.accept, self.asked_warning = {}, [], accept, 0
def choose(self, text, options, default=None):
self.labels[text] = dict(options)
return super().choose(text, options, default)
def confirm(self, text, default=False):
if FrigateExperimentalRocmTests.WARNING in text:
self.asked_warning += 1
return self.accept
return default
def message(self, text, title=None):
self.messages.append(text)
def build(self, target, answers, accept=True):
found = {"intel": [], "amd": ["/dev/dri/renderD128"], "nvidia": False, "amd_gfx_target": target}
ui = self.UI(answers, accept)
with patch("proxmenux_oci.installer.host.gpus", return_value=found):
plan = build_deployment(Catalog(ROOT).compose("frigate"), ui, DEFAULT_MODE)
return ui, plan
def override(self, plan):
return [item["value"] for item in plan["environment"] if item["name"] == "HSA_OVERRIDE_GFX_VERSION"]
def test_the_profile_is_marked_and_confirmed_and_sets_the_generation(self, *_):
ui, plan = self.build(100305, {PROMPT: "rocm"})
self.assertIn("experimental on this GPU", ui.labels[PROMPT]["rocm"])
self.assertEqual(ui.asked_warning, 1)
self.assertEqual(plan["hardware_profile"], "rocm")
self.assertEqual(self.override(plan), ["10.3.0"])
def test_a_supported_gpu_has_no_mark_and_no_question(self, *_):
ui, plan = self.build(100300, {PROMPT: "rocm"})
self.assertNotIn("experimental", ui.labels[PROMPT]["rocm"])
self.assertEqual((ui.asked_warning, self.override(plan)), (0, []))
def test_without_a_yes_the_menu_is_asked_again(self, *_):
answers = iter(["rocm", "vaapi"])
found = {"intel": [], "amd": ["/dev/dri/renderD128"], "nvidia": False, "amd_gfx_target": 100305}
ui = self.UI({}, False)
ui.choose = lambda text, options, default=None: next(answers) if text == PROMPT else default
with patch("proxmenux_oci.installer.host.gpus", return_value=found):
plan = build_deployment(Catalog(ROOT).compose("frigate"), ui, DEFAULT_MODE)
self.assertEqual(plan["hardware_profile"], "vaapi")
self.assertEqual(self.override(plan), [])
def test_a_gpu_the_image_cannot_use_is_not_offered_and_the_reason_is_said(self, *_):
with patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Lucienne (gfx90c)"):
ui, plan = self.build(90012, {})
self.assertNotIn("rocm", ui.labels[PROMPT])
self.assertTrue(any("Lucienne (gfx90c)" in message and "not offered" in message for message in ui.messages))
+48 -5
View File
@@ -91,14 +91,57 @@ class ImmichAccelerationTests(unittest.TestCase):
self.assertIn("No usable GPU", ui.messages[0])
self.assertEqual(result, ("cpu", "cpu"), mode)
def test_an_amd_host_that_cannot_run_rocm_says_so_and_recognises_on_the_cpu(self, *_):
for blocker, text in (("kfd", "/dev/kfd"), ("space", "40 GB")):
with patch("proxmenux_oci.installer.host.rocm_blocker", return_value=blocker):
ui, result, _ = self.plan(AMD, DEFAULT_MODE, "amd")
def test_an_amd_host_that_cannot_run_rocm_offers_video_only_and_says_why(self, *_):
for blocker, text in (("kfd", "/dev/kfd"), ("space", "40 GB"), ("generation", "no support for the AMD GPU")):
with patch("proxmenux_oci.installer.host.rocm_blocker", return_value=blocker), \
patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Lucienne (gfx90c)"):
ui, result, _ = self.plan(AMD, DEFAULT_MODE)
self.assertEqual(ui.options[PROMPT], ["cpu", "amd-video"], blocker)
self.assertEqual(ui.defaults[PROMPT], "amd-video", blocker)
self.assertEqual(result, ("vaapi", "cpu"), blocker)
self.assertTrue(any(text in message and "Recognition runs on the CPU." in message
self.assertTrue(any(text in message and "Recognition is not offered" in message
for message in ui.messages), blocker)
def test_a_gpu_rocm_supports_is_proposed_whole(self, *_):
for target in (100300, 110501, None):
ui, result, plan = self.plan(dict(AMD, amd_gfx_target=target), DEFAULT_MODE)
self.assertEqual(ui.defaults[PROMPT], "amd", target)
self.assertEqual(result, ("vaapi", "rocm"), target)
self.assertIsNone(plan["machine_learning"]["gfx_override"], target)
def test_a_gpu_rocm_does_not_support_officially_is_offered_as_experimental(self, *_):
gpus = dict(AMD, amd_gfx_target=100305)
with patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Radeon 680M (gfx1035)"):
# Never the proposal: left alone, recognition stays on the CPU.
ui, result, _ = self.plan(gpus, DEFAULT_MODE)
self.assertEqual(ui.options[PROMPT], ["cpu", "amd", "amd-video", "amd-ml"])
self.assertEqual(ui.defaults[PROMPT], "amd-video")
self.assertEqual(result, ("vaapi", "cpu"))
# Chosen and confirmed, it is installed with the generation of its family.
ui = OptionsUI({PROMPT: "amd"})
ui.confirm = lambda text, default=False: True if "ROCm does not support this GPU" in text else default
with patch("proxmenux_oci.installer.host.gpus", return_value=gpus):
plan = build_deployment(self.template, ui, DEFAULT_MODE)
self.assertEqual(plan["machine_learning"]["acceleration"], "rocm")
self.assertEqual(plan["machine_learning"]["gfx_override"], "10.3.0")
# Declined, the menu is asked again.
answers = iter(["amd", "amd-video"])
ui = OptionsUI()
ui.choose = lambda text, options, default=None: next(answers) if text == PROMPT else default
with patch("proxmenux_oci.installer.host.gpus", return_value=gpus):
plan = build_deployment(self.template, ui, DEFAULT_MODE)
self.assertEqual(plan["machine_learning"]["acceleration"], "cpu")
self.assertEqual(plan["video_transcoding"]["acceleration"], "vaapi")
def test_the_780m_family_is_presented_as_its_generation(self, *_):
gpus = dict(AMD, amd_gfx_target=110003)
ui = OptionsUI({PROMPT: "amd-ml"})
ui.confirm = lambda text, default=False: True if "ROCm does not support this GPU" in text else default
with patch("proxmenux_oci.installer.host.gpus", return_value=gpus), \
patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Radeon 780M (gfx1103)"):
plan = build_deployment(self.template, ui, DEFAULT_MODE)
self.assertEqual(plan["machine_learning"]["gfx_override"], "11.0.0")
def test_machine_learning_gets_four_cores_and_at_least_four_gigabytes(self, *_):
_, _, plan = self.plan(BOTH, DEFAULT_MODE, "cpu")
self.assertEqual(plan["machine_learning"]["resources"]["cores"], 4)
+288
View File
@@ -0,0 +1,288 @@
"""The recognition of an installed Immich moves between the CPU and a GPU of
the host without reinstalling: the machine learning container takes the image
and the devices of the new choice."""
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"))
sys.path.insert(0, str(ROOT / "src"))
import oci_immich_recognition as recognition
import oci_rocm_check
CONFIG = """arch: amd64
cores: 4
dev0: path=/dev/dri/renderD128,gid=993,mode=0660
dev1: path=/dev/kfd,gid=993,mode=0660
memory: 8192
lxc.environment.runtime: IMMICH_PORT=3003
lxc.environment.runtime: HSA_OVERRIDE_GFX_VERSION=10.3.0
lxc.environment.runtime: HSA_USE_SVM=0
lxc.environment: NVIDIA_VISIBLE_DEVICES=all
lxc.hook.mount: /usr/local/lib/proxmenux/oci/nvidia-mount-abc.sh
lxc.signal.halt: SIGTERM
[snapshot]
arch: amd64
lxc.environment.runtime: HSA_USE_SVM=0
"""
class ConfigurationTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.path = Path(tmp.name) / "101.conf"
self.path.write_text(CONFIG)
patcher = patch.object(recognition, "conf", return_value=self.path)
patcher.start()
self.addCleanup(patcher.stop)
def test_what_the_previous_choice_left_is_taken_away(self):
deleted = []
with patch.object(recognition.oci_stack_modify, "entries",
return_value={"dev0": "path=/dev/dri/renderD128,gid=993", "dev1": "path=/dev/kfd,gid=993",
"dev2": "path=/dev/ttyUSB0,gid=20"}), \
patch.object(recognition, "run", side_effect=lambda *command: deleted.append(command[-1])):
recognition.strip_gpu(101)
self.assertEqual(deleted, ["dev0", "dev1"])
text = self.path.read_text()
current, _, snapshot = text.partition("\n[")
for gone in ("HSA_OVERRIDE", "HSA_USE_SVM", "NVIDIA_VISIBLE_DEVICES", "nvidia-mount"):
self.assertNotIn(gone, current)
self.assertIn("lxc.environment.runtime: IMMICH_PORT=3003\n", current)
self.assertIn("lxc.signal.halt: SIGTERM\n", current)
# A snapshot is history: it is not rewritten.
self.assertEqual(snapshot, CONFIG.partition("\n[")[2])
def test_new_settings_go_before_the_snapshots(self):
recognition.append_lines(101, ["lxc.environment.runtime: HSA_OVERRIDE_GFX_VERSION=11.0.0"])
current, _, snapshot = self.path.read_text().partition("\n[")
self.assertTrue(current.endswith("lxc.environment.runtime: HSA_OVERRIDE_GFX_VERSION=11.0.0\n"))
self.assertEqual(snapshot, CONFIG.partition("\n[")[2])
class RecordTests(unittest.TestCase):
RECORD = {"deployment": {"machine_learning": {
"acceleration": "openvino", "render_device": "/dev/dri/renderD128", "model_cache_size_gb": 8,
"resources": {"cores": 4, "memory_mb": 8192, "swap_mb": 1024, "cpu_allocation": "quota"}}}}
def test_the_record_says_what_runs_recognition_and_with_what_resources(self):
cpu = recognition.recorded(self.RECORD, "cpu", None, None)
self.assertEqual((cpu["acceleration"], cpu["render_device"], cpu["gfx_override"]), ("cpu", None, None))
self.assertEqual((cpu["resources"]["memory_mb"], cpu["resources"]["cpu_allocation"]), (4096, "cpuset"))
self.assertEqual((cpu["model_cache_size_gb"], cpu["resources"]["swap_mb"]), (8, 1024))
rocm = recognition.recorded(self.RECORD, "rocm", "/dev/dri/renderD129", "10.3.0")
self.assertEqual((rocm["acceleration"], rocm["render_device"], rocm["gfx_override"]),
("rocm", "/dev/dri/renderD129", "10.3.0"))
self.assertEqual(rocm["resources"]["memory_mb"], 8192)
self.assertEqual(recognition.recorded(self.RECORD, "openvino", "/dev/dri/renderD128", None)["resources"]
["cpu_allocation"], "quota")
# The record it was read from is not changed.
self.assertEqual(self.RECORD["deployment"]["machine_learning"]["acceleration"], "openvino")
def test_the_record_declares_the_variables_of_the_new_choice(self):
base = [{"name": "IMMICH_PORT", "value": "3003"}]
names = lambda found: [(entry["name"], entry["value"]) for entry in found]
nvidia = recognition.declared(base, "cuda", None)
self.assertEqual(names(nvidia), [("IMMICH_PORT", "3003"), ("NVIDIA_DRIVER_CAPABILITIES", "compute,utility")])
amd = recognition.declared(nvidia, "rocm", "10.3.0")
self.assertEqual(names(amd), [("IMMICH_PORT", "3003"), ("HSA_OVERRIDE_GFX_VERSION", "10.3.0"),
("HSA_USE_SVM", "0")])
self.assertEqual(names(recognition.declared(nvidia, "rocm", None)), names(base))
self.assertEqual(names(recognition.declared(amd, "cpu", None)), names(base))
self.assertEqual(names(recognition.declared(amd, "openvino", None)), names(base))
def test_the_record_declares_the_resources_of_the_new_choice(self):
intel = {"cores": 4, "memory_mb": 8192, "swap_mb": 1024, "cpu_allocation": "quota"}
self.assertEqual(recognition.resources(intel, "cpu"), {"cores": 4, "memory_mb": 4096, "swap_mb": 1024})
self.assertEqual(recognition.resources(intel, "cuda"), {"cores": 4, "memory_mb": 8192, "swap_mb": 1024})
self.assertEqual(recognition.resources(recognition.resources(intel, "cpu"), "openvino"), intel)
self.assertEqual(intel["cpu_allocation"], "quota")
def test_a_record_rebuilt_by_an_update_describes_the_new_container(self):
def record(**plan):
return {"vmid": 101, "template": {"proxmox": {"installer_profile": {"id": "ml", "cpu_allocation": "quota"}}},
"deployment": {"environment": [], "rootfs": {"storage": "local-lvm", "size_gb": 12},
"resources": {"cores": 4, "memory_mb": 8192, "cpu_allocation": "quota"}, **plan}}
with patch.object(recognition, "settings", return_value={"rootfs": "local-lvm:vm-101-disk-0,size=40G"}):
rebuilt = record()
recognition.declare(rebuilt, "rocm", "11.0.0")
self.assertEqual(rebuilt["deployment"]["rootfs"]["size_gb"], 40)
self.assertEqual(rebuilt["deployment"]["resources"], {"cores": 4, "memory_mb": 8192})
self.assertEqual(rebuilt["template"]["proxmox"]["installer_profile"], {"id": "ml"})
self.assertEqual([entry["name"] for entry in rebuilt["deployment"]["environment"]],
["HSA_OVERRIDE_GFX_VERSION", "HSA_USE_SVM"])
recognition.declare(rebuilt, "openvino", None)
self.assertEqual(rebuilt["template"]["proxmox"]["installer_profile"]["cpu_allocation"], "quota")
self.assertEqual(rebuilt["deployment"]["environment"], [])
# A record still described by its native configuration is read from it.
native = record(native_config="arch: amd64")
recognition.declare(native, "rocm", "11.0.0")
self.assertEqual(native, record(native_config="arch: amd64"))
def test_a_choice_this_host_cannot_serve_is_refused_before_anything_changes(self):
with self.assertRaises(ValueError):
recognition.validate("vulkan", None, None)
with self.assertRaises(ValueError):
recognition.validate("rocm", "/dev/dri/renderD999", None)
with self.assertRaises(ValueError):
recognition.validate("openvino", "/etc/passwd", None)
with self.assertRaises(ValueError):
recognition.validate("cpu", None, "10.3.0")
recognition.validate("cpu", None, None)
def test_only_an_immich_of_proxmenux_is_changed(self):
def member(vmid, adapter, role):
return {"vmid": vmid, "deployment": {"replay_profile": {"adapter": adapter, "role": role}}}
def records(roles, adapter="install_immich_stack.sh"):
found = {vmid: member(vmid, adapter, role) for vmid, role in roles.items()}
found[100]["stack"] = {"members": [{"vmid": vmid} for vmid in roles]}
return found
immich = records({100: "server", 101: "machine-learning", 102: "database", 103: "valkey"})
with patch.object(recognition.instances, "read", side_effect=lambda root, vmid: immich[vmid]):
_, found = recognition.members(Path("/nonexistent"), 100)
self.assertEqual(found["machine-learning"]["vmid"], 101)
for other in (records({100: "application", 101: "database"}, "install_tandoor_stack.sh"),
records({100: "server", 101: "machine-learning"})):
with patch.object(recognition.instances, "read", side_effect=lambda root, vmid: other[vmid]):
with self.assertRaises(ValueError):
recognition.members(Path("/nonexistent"), 100)
class RevertTests(unittest.TestCase):
def test_the_previous_choice_is_what_the_record_says(self):
record = {"deployment": {"machine_learning": {"acceleration": "rocm", "render_device": "/dev/dri/renderD128",
"gfx_override": "10.3.0"}}}
self.assertEqual(recognition.chosen(record), ("rocm", "/dev/dri/renderD128", "10.3.0"))
self.assertEqual(recognition.chosen({"deployment": {}}), ("cpu", None, None))
def test_a_failed_change_puts_the_previous_choice_back_the_way_it_was_given(self):
calls = []
with patch.object(recognition.oci_stack_modify, "is_running", return_value=True), \
patch.object(recognition, "stop", side_effect=lambda vmid: calls.append(("stop", vmid))), \
patch.object(recognition, "apply", side_effect=lambda *arguments: calls.append(("apply",) + arguments[2:])), \
patch.object(recognition.subprocess, "run", side_effect=lambda command, **_: calls.append(tuple(command[:3]))):
recognition.revert(Path("/nonexistent"), 100, 101, {"cpu": "image"}, ("cpu", None, None), True)
self.assertEqual(calls, [("stop", 101), ("apply", 101, {"cpu": "image"}, "cpu", None, None),
("pct", "start", "101")])
def test_a_revert_that_fails_does_not_hide_the_first_error(self):
with patch.object(recognition.oci_stack_modify, "is_running", return_value=False), \
patch.object(recognition, "apply", side_effect=RuntimeError("pct set: locked")), \
patch.object(recognition, "msg_warn") as warned, \
patch.object(recognition.subprocess, "run") as started:
recognition.revert(Path("/nonexistent"), 100, 101, {}, ("cpu", None, None), True)
self.assertIn("pct set: locked", warned.call_args[0][0])
started.assert_not_called()
class RocmProbeTests(unittest.TestCase):
def test_the_probe_runs_with_what_the_container_tells_rocm(self):
config = ("arch: amd64\nlxc.environment.runtime: PATH=/opt/venv/bin\n"
"lxc.environment.runtime: HSA_OVERRIDE_GFX_VERSION=10.3.0\nlxc.environment.runtime: HSA_USE_SVM=0\n"
"lxc.environment.runtime: HSA_BAD=1; reboot\n")
self.assertEqual(oci_rocm_check.variables(config), ["HSA_OVERRIDE_GFX_VERSION=10.3.0", "HSA_USE_SVM=0"])
self.assertEqual(oci_rocm_check.variables("arch: amd64\n"), [])
listed = "arch: amd64\nenv: PATH=/opt/venv/bin\0HSA_OVERRIDE_GFX_VERSION=11.0.0\0HSA_USE_SVM=0\0HOME=/root\n"
self.assertEqual(oci_rocm_check.variables(listed), ["HSA_OVERRIDE_GFX_VERSION=11.0.0", "HSA_USE_SVM=0"])
def protobuf_fields(data):
"""The fields of a protobuf message as (number, value) pairs."""
position = 0
def varint():
nonlocal position
value = shift = 0
while True:
byte = data[position]
position += 1
value |= (byte & 0x7F) << shift
shift += 7
if not byte & 0x80:
return value
while position < len(data):
key = varint()
number, wire = key >> 3, key & 7
if wire == 0:
yield number, varint()
elif wire == 2:
size = varint()
yield number, data[position:position + size]
position += size
else:
position += 4 if wire == 5 else 8
def packed_numbers(data):
numbers, value, shift = [], 0, 0
for byte in data:
value |= (byte & 0x7F) << shift
shift += 7
if not byte & 0x80:
numbers.append(value)
value = shift = 0
return numbers
class RocmProbeModelTests(unittest.TestCase):
def test_the_embedded_model_has_layers_that_fit_each_other(self):
import base64
model = base64.b64decode(oci_rocm_check.MODEL, validate=True)
graph = next(value for number, value in protobuf_fields(model) if number == 7)
shapes = {}
for number, tensor in protobuf_fields(graph):
if number != 5:
continue
dims, name = [], None
for field, value in protobuf_fields(tensor):
if field == 1:
dims += packed_numbers(value) if isinstance(value, bytes) else [value]
elif field == 8:
name = value.decode()
shapes[name] = dims
# The dense layer takes one value per channel of the convolution.
self.assertEqual(shapes["w"], [4, 3, 3, 3])
self.assertEqual(shapes["fw"], [shapes["w"][0], 2])
self.assertEqual((shapes["b"], shapes["fb"]), ([4], [2]))
class ChoicesTests(unittest.TestCase):
def choices(self, found, blocker=None):
from proxmenux_oci import stack_recreation
with patch("proxmenux_oci.host.gpus", return_value=found), \
patch("proxmenux_oci.host.rocm_blocker", return_value=blocker):
return stack_recreation.recognition_choices("local-lvm")
def test_the_choices_are_what_the_host_has(self):
node = "/dev/dri/renderD128"
tags = lambda found, blocker=None: [tag for tag, _, _ in self.choices(found, blocker)]
self.assertEqual(tags({"intel": [], "amd": [], "nvidia": False}), ["cpu"])
self.assertEqual(tags({"intel": [node], "amd": [], "nvidia": True}), ["cpu", "openvino", "cuda"])
self.assertEqual(tags({"intel": [], "amd": [node], "nvidia": False, "amd_gfx_target": 100300}), ["cpu", "rocm"])
self.assertEqual(tags({"intel": [], "amd": [node], "nvidia": False, "amd_gfx_target": 90012}, "generation"), ["cpu"])
def test_an_amd_gpu_rocm_does_not_support_officially_is_marked(self):
found = {"intel": [], "amd": ["/dev/dri/renderD128"], "nvidia": False, "amd_gfx_target": 100305}
tag, label, details = self.choices(found)[-1]
self.assertEqual(tag, "rocm")
self.assertIn("experimental", label)
self.assertEqual((details["experimental"], details["override"], details["render"]),
(True, "10.3.0", "/dev/dri/renderD128"))
native = self.choices(dict(found, amd_gfx_target=110501))[-1]
self.assertEqual((native[2]["experimental"], native[2]["override"]), (False, None))
self.assertNotIn("experimental", native[1])
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -32,6 +32,8 @@ class RemovalCleanupTests(unittest.TestCase):
self.snippets.mkdir()
self.apps = self.root / "apps"
self.apps.mkdir()
self.records = self.root / "cluster-records"
self.records.mkdir()
self.cluster = self.root / "proxmenux"
self.cluster.mkdir()
self.legacy = self.root / "legacy"
@@ -41,6 +43,7 @@ class RemovalCleanupTests(unittest.TestCase):
patch.object(oci_remove, "CLUSTER_NODES", self.nodes),
patch.object(oci_remove, "SNIPPETS", self.snippets),
patch.object(oci_remove, "MONITOR_APPS", self.apps),
patch.object(oci_remove, "CLUSTER_RECORDS", self.records),
patch.object(oci_remove, "HOST_MONITOR_INCLUDES", self.host_monitor),
patch.object(runtime_settings, "include_path", lambda vmid: self.cluster / f"{vmid}.sysctls"),
patch.object(runtime_settings, "legacy_include_path", lambda vmid: self.legacy / f"{vmid}.proxmenux-sysctls"),
@@ -67,6 +70,12 @@ class RemovalCleanupTests(unittest.TestCase):
self.assertFalse((self.apps / "113.json").exists())
self.assertEqual(json.loads((self.apps / ".oci-dismissed.json").read_text()), {"112": "b"})
def test_the_copy_of_the_record_in_the_cluster_goes_with_the_container(self):
(self.records / "120.json").write_text("{}")
(self.records / "121.json").write_text("{}")
oci_remove.remove_host_state(120)
self.assertEqual([path.name for path in self.records.iterdir()], ["121.json"])
def test_a_published_view_with_content_is_kept(self):
shared = self.root / "shared"
(shared / "rw/drive").mkdir(parents=True)
+550
View File
@@ -0,0 +1,550 @@
"""A container restored from a backup is found, checked against what this
host has, and registered again only when its whole application can be."""
import copy
import ipaddress
import os
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_carried_record as carried
import oci_instances as instances
import oci_restore_recovery as recovery
APP = "83e373f2-5ccc-4548-9b89-22d0956d1c77"
DB = "488ed3cb-1145-477a-b90f-c46777d69fb2"
def saved(identity, hostname, extra=""):
return f"#<div>Tandoor</div>\n#<!-- proxmenux-instance={identity} -->\narch: amd64\nhostname: {hostname}\n{extra}"
def config(identity, extra=""):
return (f"arch: amd64\ndescription: <!-- proxmenux-instance={identity} -->\nhostname: app\nmemory: 2048\n"
f"rootfs: local-lvm:vm-119-disk-0,size=8G\nunprivileged: 1\n{extra}")
CONSOLE = ("lxc.console.logfile: /var/log/proxmenux/oci/{0}.console.log\n"
"lxc.hook.pre-start: /bin/sh -c 'mkdir -p /var/log/proxmenux/oci; "
"test -x /usr/local/share/proxmenux/oci/engine/remote/oci_console_mark.sh "
"&& /usr/local/share/proxmenux/oci/engine/remote/oci_console_mark.sh {0}; exit 0'\n")
def record(vmid, identity, **extra):
return {"schema_version": 1, "vmid": vmid, "installation_id": identity, "status": "installed",
"template": {"catalog_ui": {"title": {"en_US": "Tandoor Recipes"}}},
"deployment": {"vmid": vmid, "hostname": "app", "rootfs": {"storage": "local-lvm", "size_gb": 8}},
"observed": {"config": config(identity), "config_sha256": "old", "api_config_sha256": "old",
"image": {"manifest_digest": "sha256:1"}, "archive_path": "/gone.tar",
"resolved_registry_digest": "sha256:1"}, **extra}
def entry(vmid, identity, value, text=None):
return {"vmid": vmid, "installation_id": identity, "hostname": f"ct{vmid}", "problem": None,
"config": text if text is not None else config(identity),
"copy": {"schema_version": 1, "kind": carried.KIND, "node": "old", "vmid": vmid, "record": value}}
class DiscoveryTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
def register(self, vmid, identity):
with patch.object(instances, "private_directory", lambda path: path.mkdir(parents=True, exist_ok=True)):
instances.write(instances.location(self.root, vmid), record(vmid, identity))
def pending(self, guests):
with patch.object(recovery, "local_guests", return_value=guests):
return [row["vmid"] for row in recovery.pending(self.root)]
def test_the_mark_is_read_from_the_saved_configuration(self):
self.assertEqual(recovery.marker(saved(APP, "app")), APP)
self.assertIsNone(recovery.marker("arch: amd64\nhostname: plain\n"))
def test_a_marked_container_without_record_waits_for_recovery(self):
guests = {119: saved(APP, "app"), 120: saved(DB, "db"), 100: "arch: amd64\nhostname: plain\n"}
self.assertEqual(self.pending(guests), [119, 120])
self.register(119, APP)
self.assertEqual(self.pending(guests), [120])
def test_the_record_of_another_installation_does_not_count(self):
self.register(119, DB)
self.assertEqual(self.pending({119: saved(APP, "app")}), [119])
def test_a_container_that_carries_no_record_is_not_offered_again(self):
guests = {119: saved(APP, "app"), 120: saved(DB, "db")}
with patch.object(instances, "private_directory", lambda path: path.mkdir(parents=True, exist_ok=True)), \
patch.object(recovery, "local_guests", return_value=guests):
recovery.set_unrecoverable(self.root, [{"vmid": 119, "installation_id": APP}])
self.assertEqual(self.pending(guests), [120])
# Another installation restored on that ID is a new case.
self.assertEqual(self.pending({119: saved(DB, "other")}), [119])
def test_a_clone_of_a_registered_container_is_left_alone(self):
self.register(119, APP)
self.assertEqual(self.pending({119: saved(APP, "app"), 130: saved(APP, "copy")}), [])
class ApplicationTests(unittest.TestCase):
def stack(self):
database = record(120, DB, stack_member={"stack_id": APP, "primary_vmid": 119, "name": "database"})
application = record(119, APP, stack_member={"stack_id": APP, "primary_vmid": 119, "name": "application"})
application["stack"] = {"id": APP, "template": {"catalog_ui": {"title": {"en_US": "Tandoor Recipes"}}},
"members": [copy.deepcopy(application), copy.deepcopy(database)]}
return application, database
def plans(self, found, guests, known=None):
with patch.object(recovery, "local_guests", return_value=guests), \
patch.object(recovery, "registered_identities", return_value=known or {}):
return recovery.applications(Path("/nonexistent"), found)
def test_the_containers_of_one_application_are_recovered_together(self):
application, database = self.stack()
plans = self.plans([entry(120, DB, database), entry(119, APP, application)],
{119: saved(APP, "app"), 120: saved(DB, "db")})
self.assertEqual(len(plans), 1)
self.assertEqual((plans[0]["title"], plans[0]["primary"], plans[0]["blockers"]), ("Tandoor Recipes", 119, []))
self.assertEqual([m["state"] for m in plans[0]["members"]], ["restored", "restored"])
def test_an_application_with_a_container_missing_is_not_recovered(self):
application, _ = self.stack()
plans = self.plans([entry(119, APP, application)], {119: saved(APP, "app")})
self.assertEqual(len(plans[0]["blockers"]), 1)
self.assertIn("CT 120 (database)", plans[0]["blockers"][0])
def test_containers_restored_with_other_ids_keep_their_application(self):
application, database = self.stack()
application["stack"]["deployment"] = {"base_vmid": 119, "services": [
{"vmid": 119, "name": "application"},
{"vmid": 120, "name": "database", "healthcheck": {"timeout_seconds": 120}}]}
application["deployment"]["create_arguments"] = ["119", "local:vztmpl/app.tar"]
application["observed"]["config"] = config(APP, CONSOLE.format(119))
found = [entry(140, APP, application), entry(135, DB, database)]
found[0]["copy"]["stack_contract"] = {"schema": 1, "dependencies": [
{"vmid": 120, "label": "PostgreSQL", "healthcheck": {"type": "running", "timeout_seconds": 120}}]}
plans = self.plans(found, {140: saved(APP, "app"), 135: saved(DB, "db")})
plan = plans[0]
self.assertEqual((plan["blockers"], plan["renumbered"], plan["primary"]), ([], {119: 140, 120: 135}, 140))
self.assertEqual([m["vmid"] for m in plan["members"]], [140, 135])
main, other = (e["copy"]["record"] for e in plan["restored"])
self.assertEqual((main["vmid"], main["deployment"]["vmid"], main["stack_member"]["primary_vmid"]), (140, 140, 140))
self.assertEqual((other["vmid"], other["stack_member"]["primary_vmid"]), (135, 140))
self.assertEqual([m["vmid"] for m in main["stack"]["members"]], [140, 135])
self.assertEqual(main["stack"]["deployment"]["base_vmid"], 140)
services = main["stack"]["deployment"]["services"]
self.assertEqual([s["vmid"] for s in services], [140, 135])
# A number that is not an ID stays: 120 seconds is not CT 120.
self.assertEqual(services[1]["healthcheck"]["timeout_seconds"], 120)
contract = plan["restored"][0]["copy"]["stack_contract"]["dependencies"][0]
self.assertEqual((contract["vmid"], contract["healthcheck"]["timeout_seconds"]), (135, 120))
self.assertEqual(main["deployment"]["create_arguments"][0], "140")
self.assertIn("/var/log/proxmenux/oci/140.console.log", main["observed"]["config"])
self.assertIn("oci_console_mark.sh 140; exit 0", main["observed"]["config"])
self.assertNotIn("119", main["observed"]["config"].replace("vm-119-disk", ""))
self.assertEqual(plan["restored"][0]["recorded"], 119)
def test_the_lines_of_a_configuration_that_carry_the_id(self):
text = config(APP, CONSOLE.format(119) + "lxc.include: /etc/pve/proxmenux/119.sysctls\nmemory: 119\n")
result = recovery.renumbered_lines(text, 119, 140)
self.assertIn("lxc.console.logfile: /var/log/proxmenux/oci/140.console.log\n", result)
self.assertIn("oci_console_mark.sh 140; exit 0'\n", result)
self.assertIn("lxc.include: /etc/pve/proxmenux/140.sysctls\n", result)
# Proxmox renames the volumes; anything else that happens to read 119 stays.
self.assertIn("rootfs: local-lvm:vm-119-disk-0,size=8G\n", result)
self.assertIn("memory: 119\n", result)
self.assertTrue(result.endswith("\n"))
legacy = recovery.renumbered_lines("lxc.include: /etc/pve/lxc/119.proxmenux-sysctls\n", 119, 140)
self.assertEqual(legacy, "lxc.include: /etc/pve/proxmenux/140.sysctls\n")
def test_a_container_without_its_copy_stops_the_recovery(self):
broken = dict(entry(119, APP, record(119, APP)), copy=None, problem="no copy")
plans = self.plans([broken], {119: saved(APP, "app")})
self.assertEqual(plans[0]["blockers"], ["CT 119: no copy"])
class NetworkTests(unittest.TestCase):
def check(self, text, links=("lo", "vmbr0"), addresses=None, routes=(), defined=None, guests=None):
plan = {"restored": [entry(119, APP, record(119, APP), text)], "members": [{"vmid": 119}],
"blockers": [], "notes": [], "bridges": {}}
with patch.object(recovery, "links", return_value=set(links)), \
patch.object(recovery, "host_addresses", return_value=addresses or {}), \
patch.object(recovery, "routed_networks", return_value=list(routes)), \
patch.object(recovery, "defined_network", return_value=defined), \
patch.object(recovery, "local_guests", return_value=guests or {}):
recovery.check_networks(plan)
return plan
PRIVATE = config(APP, "net0: name=eth0,bridge=vmbr0,ip=dhcp\nnet1: name=eth1,bridge=vmbr11,ip=10.77.1.40/24\n")
def test_a_missing_private_network_is_created_with_the_same_subnet(self):
plan = self.check(self.PRIVATE)
self.assertEqual(plan["blockers"], [])
self.assertEqual(plan["bridges"], {"vmbr11": ipaddress.ip_network("10.77.1.0/24")})
def test_a_private_network_already_here_is_left_as_it_is(self):
plan = self.check(self.PRIVATE, links=("vmbr0", "vmbr11"),
addresses={"vmbr11": [ipaddress.ip_interface("10.77.1.1/24")]})
self.assertEqual((plan["blockers"], plan["bridges"]), ([], {}))
def test_the_subnet_in_use_on_this_host_cancels_the_recovery(self):
plan = self.check(self.PRIVATE, links=("vmbr0", "vmbr20"),
addresses={"vmbr20": [ipaddress.ip_interface("10.77.1.1/24")]})
self.assertEqual(len(plan["blockers"]), 1)
self.assertIn("10.77.1.0/24", plan["blockers"][0])
routed = self.check(self.PRIVATE, routes=[("vmbr0", ipaddress.ip_network("10.0.0.0/8"))])
self.assertEqual(len(routed["blockers"]), 1)
def test_the_bridge_name_used_for_another_network_cancels_the_recovery(self):
plan = self.check(self.PRIVATE, links=("vmbr0", "vmbr11"),
addresses={"vmbr11": [ipaddress.ip_interface("192.168.5.1/24")]})
self.assertEqual(len(plan["blockers"]), 1)
defined = self.check(self.PRIVATE, defined=ipaddress.ip_network("192.168.5.0/24"))
self.assertEqual(len(defined["blockers"]), 1)
def test_an_address_taken_by_another_container_cancels_the_recovery(self):
plan = self.check(self.PRIVATE, guests={200: "net0: name=eth0,bridge=vmbr30,ip=10.77.1.40/24\n"})
self.assertTrue(any("10.77.1.40" in line for line in plan["blockers"]))
def test_a_missing_bridge_of_the_local_network_is_not_invented(self):
plan = self.check(config(APP, "net0: name=eth0,bridge=vmbr5,ip=dhcp\n"))
self.assertEqual(len(plan["blockers"]), 1)
self.assertEqual(plan["bridges"], {})
class RecordTests(unittest.TestCase):
def test_what_a_restore_changes(self):
related = recovery.restore_related
self.assertTrue(related("rootfs", "local-lvm:vm-119-disk-0,size=8G", "tank:subvol-119-disk-0,size=8G"))
self.assertTrue(related("mp0", "local-lvm:vm-119-disk-1,mp=/data,backup=1,size=2G",
"tank:subvol-119-disk-1,mp=/data,backup=1,size=2G"))
self.assertFalse(related("mp0", "local-lvm:vm-119-disk-1,mp=/data,backup=1,size=2G",
"local-lvm:vm-119-disk-1,mp=/other,backup=1,size=2G"))
self.assertFalse(related("mp0", "/srv/a,mp=/data", "/srv/b,mp=/data"))
self.assertTrue(related("net0", "name=eth0,bridge=vmbr0,hwaddr=AA:AA,ip=dhcp", "name=eth0,bridge=vmbr0,hwaddr=BB:BB,ip=dhcp"))
self.assertFalse(related("net0", "name=eth0,bridge=vmbr0,ip=dhcp", "name=eth0,bridge=vmbr1,ip=dhcp"))
self.assertTrue(related("hookscript", "local:snippets/a.sh", "nas:snippets/a.sh"))
self.assertTrue(related("dev0", "path=/dev/dri/renderD128", None))
self.assertTrue(related("mp1", "/srv/media,mp=/media", None))
self.assertFalse(related("mp1", "local-lvm:vm-119-disk-1,mp=/data,backup=1,size=2G", None))
self.assertFalse(related("memory", "2048", "4096"))
self.assertFalse(related("mp2", None, "/etc,mp=/host"))
def standalone(self, text, value=None):
value = value or record(119, APP)
observed = {"config": text, "config_sha256": "new", "api_config_sha256": "new"}
with patch.object(instances, "observe", return_value=observed) as observe, \
patch.object(recovery, "capture_sources", return_value={}), \
patch.object(recovery, "capture_gpu_devices", return_value={}):
return recovery.standalone_record(entry(119, APP, value, text)), observe
def test_volumes_on_another_storage_become_the_reference(self):
text = config(APP).replace("local-lvm:vm-119-disk-0", "tank:subvol-119-disk-0")
result, observe = self.standalone(text)
observe.assert_called_once()
self.assertEqual(result["observed"]["config"], text)
self.assertEqual(result["deployment"]["rootfs"]["storage"], "tank")
def test_a_change_made_outside_stays_a_difference(self):
text = config(APP).replace("local-lvm:vm-119-disk-0", "tank:subvol-119-disk-0").replace("memory: 2048", "memory: 4096")
result, observe = self.standalone(text)
observe.assert_not_called()
self.assertIn("memory: 2048", result["observed"]["config"])
self.assertIn("rootfs: tank:subvol-119-disk-0,size=8G", result["observed"]["config"])
self.assertNotIn("api_config_sha256", result["observed"])
self.assertEqual(result["deployment"]["rootfs"]["storage"], "tank")
def test_a_single_container_restored_with_another_id_is_registered_under_it(self):
value = record(119, APP)
value["observed"]["config"] = config(APP, CONSOLE.format(119))
found = entry(141, APP, value, config(APP, CONSOLE.format(141)).replace("vm-119-disk-0", "vm-141-disk-0"))
with patch.object(recovery, "local_guests", return_value={141: saved(APP, "app")}), \
patch.object(recovery, "registered_identities", return_value={}):
plan = recovery.applications(Path("/nonexistent"), [found])[0]
self.assertEqual((plan["blockers"], plan["renumbered"]), ([], {119: 141}))
observed = {"config": found["config"], "config_sha256": "new"}
with patch.object(instances, "observe", return_value=observed) as observe:
result = recovery.standalone_record(plan["restored"][0])
observe.assert_called_once()
self.assertEqual((result["vmid"], result["deployment"]["vmid"]), (141, 141))
def test_the_copy_cannot_add_a_host_directory_or_a_device(self):
value = record(119, APP)
value["deployment"]["mounts"] = [
{"type": "host-bind", "source": "/etc", "container_path": "/host"},
{"type": "host-bind", "source": "/srv/media", "container_path": "/media"},
{"type": "managed-volume", "source": "local-lvm", "container_path": "/data"}]
value["deployment"]["devices"] = [{"kind": "character-device", "host_path": "/dev/kvm"},
{"kind": "character-device", "host_path": "/dev/dri/renderD128"}]
extra = "mp0: /srv/media,mp=/media,backup=0\nmp1: tank:subvol-119-disk-1,mp=/data,backup=1,size=2G\ndev0: path=/dev/dri/renderD128,gid=104\n"
value["observed"]["config"] = config(APP, extra.replace("tank:subvol", "local-lvm:vm"))
result, _ = self.standalone(config(APP, extra), value)
self.assertEqual([m["source"] for m in result["deployment"]["mounts"]], ["/srv/media", "tank"])
self.assertEqual([d["host_path"] for d in result["deployment"]["devices"]], ["/dev/dri/renderD128"])
def test_the_start_order_must_name_containers_of_the_application(self):
contract = {"schema": 1, "stack": "tandoor", "dependencies": [
{"vmid": 120, "label": "PostgreSQL",
"healthcheck": {"type": "exec", "timeout_seconds": 120, "argv": ["pg_isready"]}}]}
self.assertTrue(recovery.valid_contract(contract, {120}))
self.assertFalse(recovery.valid_contract(contract, {121}))
self.assertFalse(recovery.valid_contract(None, {120}))
http = copy.deepcopy(contract)
http["dependencies"][0]["healthcheck"] = {"type": "http", "timeout_seconds": 60, "url": "http://10.77.1.41:8080/health"}
self.assertTrue(recovery.valid_contract(http, {120}))
http["dependencies"][0]["healthcheck"]["url"] = "http://192.168.0.1/admin"
self.assertFalse(recovery.valid_contract(http, {120}))
class RcloneMountTests(unittest.TestCase):
MOUNT = {"mount_name": "drive", "shared_mount_root": "/mnt/oci-shared/remotes",
"shared_mount_read_only_root": "/mnt/oci-shared/remotes-ro", "shared_mount_root_parent": "/mnt/oci-shared"}
def test_a_mount_inside_its_common_root_is_published_again(self):
self.assertEqual(recovery.rclone_mount(dict(self.MOUNT, extra="ignored")), self.MOUNT)
def test_a_mount_the_host_cannot_publish_safely_is_left_to_the_user(self):
for change in ({"mount_name": "../etc"}, {"mount_name": "a b"}, {"shared_mount_root": "/srv/other"},
{"shared_mount_root_parent": "/etc", "shared_mount_root": "/etc/remotes",
"shared_mount_read_only_root": "/etc/remotes-ro"},
{"shared_mount_root": "/mnt/oci-shared/../../etc"}, {"shared_mount_root_parent": "/"},
{"shared_mount_read_only_root": 5}):
self.assertIsNone(recovery.rclone_mount({**self.MOUNT, **change}), change)
self.assertIsNone(recovery.rclone_mount(None))
def test_the_hookscript_of_the_mount_follows_the_new_id(self):
text = "hookscript: local:snippets/proxmenux-rclone-119-fuse-hook.sh\nmemory: 119\n"
self.assertEqual(recovery.renumbered_lines(text, 119, 143),
"hookscript: local:snippets/proxmenux-rclone-143-fuse-hook.sh\nmemory: 119\n")
other = "hookscript: local:snippets/proxmenux-stack-dependencies.sh\n"
self.assertEqual(recovery.renumbered_lines(other, 119, 143), other)
def test_the_parameters_are_read_from_the_hookscript_of_the_host(self):
hook = ("inside=/data/mounts/drive\npublished=/mnt/oci-shared/remotes/drive\n"
"published_ro=/mnt/oci-shared/remotes-ro/drive\n if ! mountpoint -q /mnt/oci-shared; then\n")
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
(Path(tmp.name) / "proxmenux-rclone-119-fuse-hook.sh").write_text(hook)
config = "arch: amd64\nhookscript: local:snippets/proxmenux-rclone-119-fuse-hook.sh\n"
with patch.object(carried, "SNIPPETS", Path(tmp.name)):
self.assertEqual(carried.rclone_mount(config), self.MOUNT)
self.assertIsNone(carried.rclone_mount("arch: amd64\n"))
self.assertIsNone(carried.rclone_mount(config.replace("119", "120")))
class NvidiaStaticTests(unittest.TestCase):
"""A privileged container names every file of the NVIDIA driver of its host."""
@staticmethod
def inventory(version, uuid):
library = f"/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.{version}"
return {"gpus": [f"Quadro P1000, {uuid}, {version}"], "toolkit_version": ["cli-version: 1", "lib-version: 1"],
"devices": {"/dev/nvidia0": {"source": "/dev/nvidia0", "uid": 0, "gid": 0, "mode": 0o666,
"major": 195, "minor": 0}},
"files": {library: {"source": library, "uid": 0, "gid": 0, "mode": 0o644, "size": 1, "sha256": "x"}},
"links": {}}
def test_the_driver_files_are_named_again_for_the_host_the_container_is_on(self):
import oci_nvidia_runtime as runtime
old, new = self.inventory("550.1", "GPU-aaa"), self.inventory("560.2", "GPU-aaa")
text = ("arch: amd64\ndev0: path=/dev/nvidia0,mode=0666,gid=0,deny-write=0\n"
"lxc.mount.entry: /usr/lib/x86_64-linux-gnu/libnvidia-ml.so.550.1 "
"usr/lib/x86_64-linux-gnu/libnvidia-ml.so.550.1 none ro,bind,create=file 0 0\n").encode()
plan = runtime.refresh_plan(text, old, new)
self.assertTrue(plan["changed"])
self.assertIn(b"libnvidia-ml.so.560.2 usr/lib/x86_64-linux-gnu/libnvidia-ml.so.560.2", plan["config"])
self.assertNotIn(b"550.1", plan["config"])
other = self.inventory("560.2", "GPU-bbb")
with self.assertRaises(ValueError):
runtime.refresh_plan(text, old, other)
self.assertTrue(runtime.refresh_plan(text, old, other, same_gpu=False)["changed"])
self.assertFalse(runtime.refresh_plan(text, old, old)["changed"])
class ReturnedContainerTests(unittest.TestCase):
"""A container that came back from another node, an older backup or a
snapshot carries the record that describes it."""
def copy(self, value, generation):
return {"schema_version": 1, "kind": carried.KIND, "node": "other", "vmid": 119,
"generation": generation, "record": value}
def test_a_copy_this_host_did_not_write_with_another_record_wins(self):
host, older = record(119, APP), record(119, APP)
older["deployment"]["hostname"] = "before-the-update"
stamp = {"digest": "x", "generation": "ours"}
self.assertTrue(carried.superseded(host, self.copy(older, "theirs"), stamp))
# Written by this host: the record changed here afterwards.
self.assertFalse(carried.superseded(host, self.copy(older, "ours"), stamp))
# Came back without changes: the same record.
self.assertFalse(carried.superseded(host, self.copy(record(119, APP), "theirs"), stamp))
# Nothing was written by this host yet, or the copy is of another installation.
self.assertFalse(carried.superseded(host, self.copy(older, "theirs"), {}))
self.assertFalse(carried.superseded(host, self.copy(record(119, DB), "theirs"), stamp))
def test_the_mark_of_the_last_copy_is_kept_on_the_host(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
path = Path(tmp.name) / carried.STAMP
self.assertEqual(carried.read_stamp(path), {})
path.write_text("abc\n")
self.assertEqual(carried.read_stamp(path), {"digest": "abc"})
path.write_text('{"digest": "abc", "generation": "g1"}\n')
self.assertEqual(carried.read_stamp(path), {"digest": "abc", "generation": "g1"})
class ClusterCopyTests(unittest.TestCase):
"""Every node of a cluster reads the copy kept in /etc/pve."""
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
patcher = patch.object(carried, "CLUSTER", self.root / "cluster")
patcher.start()
self.addCleanup(patcher.stop)
def copy(self, value, generation="g1", node="pve1", saved_at="2026-10-03T10:00:00+00:00"):
return {"schema_version": 1, "kind": carried.KIND, "node": node, "vmid": value["vmid"],
"generation": generation, "saved_at": saved_at, "record": value}
def test_the_copy_is_written_and_read_back(self):
self.assertIsNone(carried.read_cluster(119))
self.assertTrue(carried.write_cluster(119, self.copy(record(119, APP))))
self.assertEqual(carried.read_cluster(119)["record"]["installation_id"], APP)
(carried.CLUSTER / "120.json").write_text("not json")
self.assertIsNone(carried.read_cluster(120))
carried.remove_cluster(119)
self.assertIsNone(carried.read_cluster(119))
def test_it_follows_the_copy_the_container_carries(self):
value = self.copy(record(119, APP))
carried.keep_cluster(119, value, "g1")
written = (carried.CLUSTER / "119.json").stat().st_mtime_ns
carried.keep_cluster(119, value, "g1")
self.assertEqual((carried.CLUSTER / "119.json").stat().st_mtime_ns, written)
carried.keep_cluster(119, value, "g2")
self.assertEqual(carried.read_cluster(119)["generation"], "g2")
def test_a_record_changed_on_another_node_replaces_the_one_of_this_host(self):
path = self.root / "oci-compose.json"
path.write_text("{}")
os.utime(path, (1_000_000_000, 1_000_000_000))
host, changed = record(119, APP), record(119, APP)
changed["deployment"]["hostname"] = "updated-on-the-other-node"
self.assertTrue(carried.replaced_elsewhere(host, path, self.copy(changed, node="pve2"), "pve1"))
# Written by this node, the same record, another installation or an older copy.
self.assertFalse(carried.replaced_elsewhere(host, path, self.copy(changed, node="pve1"), "pve1"))
self.assertFalse(carried.replaced_elsewhere(host, path, self.copy(record(119, APP), node="pve2"), "pve1"))
self.assertFalse(carried.replaced_elsewhere(host, path, self.copy(record(119, DB), node="pve2"), "pve1"))
old = self.copy(changed, node="pve2", saved_at="1999-01-01T00:00:00+00:00")
self.assertFalse(carried.replaced_elsewhere(host, path, old, "pve1"))
self.assertFalse(carried.replaced_elsewhere(host, path, None, "pve1"))
def examine(self, inside, shared):
if shared is not None:
carried.write_cluster(119, shared)
done = type("Done", (), {"returncode": 0, "stdout": config(APP)})()
class Root:
def __enter__(self): return Path("/rootfs")
def __exit__(self, *_): return False
with patch.object(recovery, "run", return_value=done), \
patch.object(carried, "container_root", return_value=Root()), \
patch.object(carried, "read_copy", return_value=inside):
return recovery.examine({"vmid": 119, "installation_id": APP, "hostname": "app"})
def test_the_same_copy_in_the_cluster_is_taken_without_asking(self):
value = self.copy(record(119, APP), "g1")
result = self.examine(dict(value), value)
self.assertTrue(result["trusted"])
self.assertIsNone(result["problem"])
def test_a_container_of_another_moment_is_described_by_its_own_copy(self):
older = record(119, APP)
older["deployment"]["hostname"] = "before"
result = self.examine(self.copy(older, "g0"), self.copy(record(119, APP), "g1"))
self.assertFalse(result["trusted"])
self.assertEqual(result["copy"]["record"]["deployment"]["hostname"], "before")
def test_a_container_without_its_copy_takes_the_one_of_the_cluster_and_asks(self):
result = self.examine(None, self.copy(record(119, APP), "g1"))
self.assertFalse(result["trusted"])
self.assertIsNone(result["problem"])
self.assertIsNotNone(result["copy"])
carried.remove_cluster(119)
nothing = self.examine(None, None)
self.assertTrue(nothing["unrecoverable"])
def test_the_copy_of_another_installation_is_not_used(self):
carried.remove_cluster(119)
result = self.examine(self.copy(record(119, APP), "g1"), self.copy(record(119, DB), "g1"))
self.assertFalse(result["trusted"])
self.assertEqual(result["copy"]["record"]["installation_id"], APP)
def test_only_what_needs_no_question_is_registered_on_its_own(self):
def plan(**extra):
item = dict(entry(119, APP, record(119, APP)), trusted=True)
item.update(extra.pop("entry", {}))
return {"blockers": [], "bridges": {}, "renumbered": {}, "hold": False, "restored": [item], **extra}
self.assertTrue(recovery.automatic(plan()))
self.assertFalse(recovery.automatic(plan(entry={"trusted": False})))
self.assertFalse(recovery.automatic(plan(bridges={"vmbr11": "10.77.1.0/24"})))
self.assertFalse(recovery.automatic(plan(blockers=["missing"])))
self.assertFalse(recovery.automatic(plan(renumbered={119: 140})))
self.assertFalse(recovery.automatic(plan(entry={"firewall": {"port": 19999}})))
self.assertFalse(recovery.automatic(plan(entry={"contract": {"schema": 1}})))
class RegistrationTests(unittest.TestCase):
def test_no_record_is_left_when_the_application_cannot_be_registered_whole(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
root = Path(tmp.name)
database = record(120, DB, stack_member={"stack_id": APP, "primary_vmid": 119, "name": "database"})
application = record(119, APP, stack_member={"stack_id": APP, "primary_vmid": 119, "name": "application"})
plan = {"restored": [entry(119, APP, application), entry(120, DB, database)]}
def refuse(_root, vmid):
if vmid == 120:
raise ValueError("cannot be updated")
with patch.object(instances, "private_directory", lambda path: path.mkdir(parents=True, exist_ok=True)), \
patch.object(recovery.oci_stack_modify, "register", side_effect=refuse):
with self.assertRaises(ValueError):
recovery.register(root, plan)
self.assertFalse(instances.location(root, 119).exists())
self.assertFalse(instances.location(root, 120).exists())
def test_the_plan_each_container_was_installed_with_is_left_as_it_is_now(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
root = Path(tmp.name)
database = record(150, DB, stack_member={"stack_id": APP, "primary_vmid": 151, "name": "database"})
database["deployment"]["mounts"] = [{"source": "local-lvm:vm-150-disk-1"}]
application = record(151, APP, stack_member={"stack_id": APP, "primary_vmid": 151, "name": "application"})
application["stack"] = {"id": APP, "members": [], "deployment": {"services": [
{"name": "database", "vmid": 150, "deployment": {"mounts": [{"source": "local-lvm:vm-120-disk-1"}]}},
{"name": "application", "vmid": 151, "deployment": {"create_arguments": ["119"]}},
{"name": "check", "vmid": 150, "healthcheck": {"type": "running"}}]}}
with patch.object(instances, "private_directory", lambda path: path.mkdir(parents=True, exist_ok=True)):
instances.write(instances.location(root, 150), database)
instances.write(instances.location(root, 151), application)
recovery.refresh_service_plans(root, [151, 150])
services = instances.read(root, 151)["stack"]["deployment"]["services"]
self.assertEqual(services[0]["deployment"]["mounts"], [{"source": "local-lvm:vm-150-disk-1"}])
self.assertEqual(services[1]["deployment"], application["deployment"])
self.assertNotIn("deployment", services[2])
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
"""An image is the same in the registry and in the archive made from it,
whichever format the registry publishes its manifest in."""
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
from oci_installation_state import same_image
REGISTRY = "sha256:" + "a" * 64
ARCHIVE = "sha256:" + "b" * 64
LAYERS = ["sha256:" + "c" * 64, "sha256:" + "d" * 64]
BUILT = "2026-09-15T01:13:55.019799592Z"
class SameImageTests(unittest.TestCase):
def test_an_oci_manifest_keeps_its_digest(self):
self.assertTrue(same_image({"manifest_digest": REGISTRY}, {"manifest_digest": REGISTRY}))
def test_a_docker_manifest_is_recognised_by_its_layers_and_when_it_was_built(self):
self.assertTrue(same_image({"manifest_digest": REGISTRY, "layers": LAYERS, "created": BUILT},
{"manifest_digest": ARCHIVE, "layers": list(LAYERS), "created": BUILT}))
def test_another_image_is_not_the_same(self):
candidate = {"manifest_digest": REGISTRY, "layers": LAYERS, "created": BUILT}
self.assertFalse(same_image(candidate, {"manifest_digest": ARCHIVE, "layers": LAYERS[:1], "created": BUILT}))
self.assertFalse(same_image(candidate, {"manifest_digest": ARCHIVE, "layers": LAYERS[::-1], "created": BUILT}))
# The same layers rebuilt with another configuration are another image.
self.assertFalse(same_image(candidate, {"manifest_digest": ARCHIVE, "layers": LAYERS,
"created": "2026-10-01T00:00:00Z"}))
def test_what_is_missing_never_matches(self):
self.assertFalse(same_image({"manifest_digest": REGISTRY}, {"manifest_digest": ARCHIVE}))
self.assertFalse(same_image({"manifest_digest": REGISTRY, "layers": [], "created": BUILT},
{"manifest_digest": ARCHIVE, "layers": [], "created": BUILT}))
self.assertFalse(same_image({"manifest_digest": REGISTRY, "layers": LAYERS, "created": None},
{"manifest_digest": ARCHIVE, "layers": LAYERS, "created": None}))
# A record saved before the layers were kept is compared by its digest alone.
self.assertFalse(same_image({"manifest_digest": REGISTRY, "layers": LAYERS, "created": BUILT},
{"manifest_digest": ARCHIVE}))
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
"""The notes Proxmox shows for a multi-container application link to the
address it answers on from the local network, not to its private leg."""
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_native_stack as stack
TWO_LEGS = "net0: name=eth0,bridge=vmbr0,ip=dhcp,type=veth\nnet1: name=eth1,bridge=vmbr10,ip=10.77.0.10/24,type=veth\n"
PRIVATE_ONLY = "net0: name=eth0,bridge=vmbr10,ip=10.77.0.12/24,type=veth\n"
class AccessAddressTests(unittest.TestCase):
def address(self, config, *answers, wait=20):
listed = iter(answers)
def run(command, **_):
if command[0] == "pct":
return SimpleNamespace(stdout=config)
return SimpleNamespace(stdout=next(listed))
with patch.object(stack.subprocess, "run", side_effect=run), patch.object(stack.time, "sleep"):
return stack.access_address(101, wait)
def test_the_address_of_the_local_network_is_used(self):
self.assertEqual(self.address(TWO_LEGS, "10.77.0.10\n192.168.0.44\n"), "192.168.0.44")
self.assertEqual(self.address(TWO_LEGS, "192.168.0.44\n10.77.0.10\n"), "192.168.0.44")
def test_a_lease_that_has_not_arrived_is_waited_for(self):
self.assertEqual(self.address(TWO_LEGS, "10.77.0.10\n", "10.77.0.10\n192.168.0.44\n"), "192.168.0.44")
def test_a_container_with_only_its_private_leg_keeps_that_address(self):
self.assertEqual(self.address(PRIVATE_ONLY, "10.77.0.12\n"), "10.77.0.12")
def test_a_container_without_address_has_none(self):
self.assertEqual(self.address(PRIVATE_ONLY, "\n"), "")
self.assertEqual(self.address(TWO_LEGS, "10.77.0.10\n", wait=0), "10.77.0.10")
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
"""The temporary container that holds the data of an application during an
update is removed when the operation ends: empty after a commit, and with the
disks of the failed attempt after a verified recovery."""
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 / "remote"))
import oci_instance_transaction as transaction
EMPTY = b"arch: amd64\nhostname: oci-data-holder\nrootfs: local-lvm:vm-104-disk-0,size=8G\n"
HOLDING = EMPTY + b"mp0: local-lvm:vm-104-disk-1,mp=/transaction-retained/bd021f9e,backup=1,size=32G\n"
STATE = {"id": "4dfbfd9223a440c1b07a0334b08fc4fd", "stage": 104}
class StageReleaseTests(unittest.TestCase):
def release(self, config, **arguments):
calls = []
with patch.object(transaction, "owned", return_value=config) as owned, \
patch.object(transaction, "stop", side_effect=lambda vmid: calls.append(("stop", vmid))), \
patch.object(transaction, "run", side_effect=lambda *command: calls.append(command)):
transaction.release_stage(STATE, **arguments)
owned.assert_called_once_with(104, "proxmenux-transaction=" + STATE["id"])
return calls
def test_after_a_commit_the_empty_container_is_removed(self):
self.assertEqual(self.release(EMPTY), [("pct", "destroy", "104")])
def test_a_container_that_still_holds_a_disk_is_kept(self):
self.assertEqual(self.release(HOLDING), [])
self.assertEqual(self.release(EMPTY + b"unused0: local-lvm:vm-104-disk-2\n"), [])
def test_after_a_verified_recovery_it_is_removed_with_what_it_holds(self):
self.assertEqual(self.release(HOLDING, discard=True), [("stop", 104), ("pct", "destroy", "104")])
def test_a_container_of_another_operation_is_never_removed(self):
calls = []
with patch.object(transaction, "owned", side_effect=ValueError("not ours")), \
patch.object(transaction, "run", side_effect=lambda *command: calls.append(command)):
transaction.release_stage(STATE, discard=True)
self.assertEqual(calls, [])
def test_an_operation_without_a_temporary_container_does_nothing(self):
with patch.object(transaction, "owned") as owned:
transaction.release_stage({"id": "x"}, discard=True)
owned.assert_not_called()
def test_a_removal_that_fails_does_not_undo_the_recovery(self):
with patch.object(transaction, "owned", return_value=HOLDING), \
patch.object(transaction, "stop"), \
patch.object(transaction, "run", side_effect=RuntimeError("pct failed with exit code 255")), \
patch.object(transaction, "log") as logged:
transaction.discard_stage(STATE)
self.assertIn("pct failed", logged.call_args[0][0])
if __name__ == "__main__":
unittest.main()