Files

286 lines
16 KiB
Python
Raw Permalink Normal View History

"""An image that is not in the catalog can also be installed from an
oci-compose: one written by hand with what its author decides, a file of the
catalog, or the record of an installed application, of which only the template
is read."""
import copy
2026-10-12 23:03:49 +02:00
import io
import json
from pathlib import Path
import sys
import tempfile
2026-10-12 23:03:49 +02:00
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
2026-10-12 23:03:49 +02:00
from proxmenux_oci import custom, i18n
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.converter import ConversionError
2026-10-12 23:03:49 +02:00
# The messages are compared in English, also on a host set to another language.
i18n._language = "en"
CATALOG = Catalog(ROOT)
EXAMPLES = ("jellyfin-official", "duplicati-official")
# Fields that describe where a template came from and are not read to install it.
ORIGIN = {"id", "compose_source_example", "source", "pull_policy"}
def catalog_file(app):
return (ROOT / "catalog/apps" / f"{app}.json").read_text(encoding="utf-8")
def example(app):
return (ROOT / "examples" / f"{app}.oci-compose.json").read_text(encoding="utf-8")
def installed(value):
"""A block without the fields that only say where it came from."""
if isinstance(value, dict):
return {key: installed(item) for key, item in value.items() if key not in ORIGIN}
if isinstance(value, list):
return [installed(item) for item in value]
return value
class FakeUI:
def __init__(self, choices, answers=()):
self.choices, self.answers = list(choices), list(answers)
self.offered, self.reviews, self.asked = [], [], []
def choose(self, prompt, options, default=None, **kwargs):
self.offered.append([key for key, _ in options])
return self.choices.pop(0)
def ask(self, prompt, default="", **kwargs):
self.asked.append(prompt)
return self.answers.pop(0)
def review(self, text, title, **kwargs):
self.reviews.append(text)
return True
def message(self, text, title=None):
self.reviews.append(text)
class CompleteFileTests(unittest.TestCase):
def test_a_file_of_the_catalog_is_read_as_it_is(self):
template = custom.template_from_oci_compose(catalog_file("mariadb"))
self.assertEqual(template["kind"], "proxmenux.oci-template")
self.assertEqual(template["container_contract"]["image"]["reference"], "lscr.io/linuxserver/mariadb:latest")
def test_of_an_installation_record_only_the_template_is_taken(self):
record = {"vmid": 114, "status": "installed", "template": json.loads(catalog_file("mariadb")),
"deployment": {"environment": [{"name": "MYSQL_ROOT_PASSWORD", "value": "the-secret-of-someone"}]}}
template = custom.template_from_oci_compose(json.dumps(record))
self.assertEqual(template["container_contract"]["image"]["reference"], "lscr.io/linuxserver/mariadb:latest")
self.assertNotIn("the-secret-of-someone", json.dumps(template))
self.assertNotIn("deployment", template)
def test_a_template_taken_from_an_installation_installs_the_same(self):
# The record of an installation holds the template as the catalog
# composed it; reading it again must not change it.
index = json.loads((ROOT / "catalog/index.json").read_text(encoding="utf-8"))["applications"]
checked = 0
for item in index:
if item.get("hidden") or not item.get("automatic_install_candidate"):
continue
composed = CATALOG.compose(item["id"])
again = custom.template_from_oci_compose(json.dumps(composed))
for template in (composed, again):
template.pop("status", None)
template.pop("community_tested", None)
self.assertEqual(again, composed, item["id"])
checked += 1
self.assertGreater(checked, 300)
def test_it_is_not_verified_because_it_says_so(self):
document = json.loads(catalog_file("mariadb"))
document["status"] = "laboratory-validated"
document["community_tested"] = {"by": "someone"}
template = custom.template_from_oci_compose(json.dumps(document))
self.assertNotEqual(template["status"], "laboratory-validated")
self.assertNotIn("community_tested", template)
class HandWrittenTests(unittest.TestCase):
"""An oci-compose written by hand says what its author decides; ProxMenux
completes it with what it writes for any image."""
def test_the_examples_install_what_the_catalog_installs(self):
for app in EXAMPLES:
with self.subTest(app=app):
written = json.loads(example(app))
self.assertNotIn("compose_stack", written)
self.assertNotIn("source", written)
template = custom.template_from_oci_compose(example(app))
catalog = CATALOG.compose(app)
for field in ("image", "ports", "environment", "restart", "service_name", "container_name"):
self.assertEqual(installed(template["container_contract"][field]),
installed(catalog["container_contract"][field]), field)
self.assertEqual(installed(template["container_contract"]["volumes"]),
installed(catalog["container_contract"]["volumes"]))
self.assertEqual(template["proxmox"]["defaults"], catalog["proxmox"]["defaults"])
for name in sorted(set(template["proxmox"]["installer_profile"]) | set(catalog["proxmox"]["installer_profile"])):
if name == "network":
# Says how Docker connected it; nothing reads it here.
continue
self.assertEqual(template["proxmox"]["installer_profile"].get(name),
catalog["proxmox"]["installer_profile"].get(name), name)
self.assertEqual(template["first_run"], catalog["first_run"])
self.assertEqual(template["catalog_ui"]["category_label"], catalog["catalog_ui"]["category_label"])
self.assertTrue(template["compatibility"]["automatic_install_candidate"])
2026-10-12 23:03:49 +02:00
self.assertEqual(custom._errors(template), [])
def test_the_least_an_oci_compose_says_is_its_image(self):
template = custom.template_from_oci_compose(json.dumps(
{"kind": "proxmenux.oci-template", "container_contract": {"image": {"reference": "traefik/whoami:latest"}}}))
self.assertEqual(template["container_contract"]["image"]["repository"], "traefik/whoami")
self.assertEqual(template["catalog_ui"]["title"], {"en_US": "whoami"})
self.assertEqual(template["container_contract"]["volumes"], [])
2026-10-12 23:03:49 +02:00
self.assertEqual(custom._errors(template), [])
def test_what_the_author_writes_is_kept(self):
document = json.loads(example("duplicati-official"))
document["container_contract"]["ports"] = [{"container_port": 5514, "protocol": "udp", "required": False}]
document["container_contract"]["volumes"][0].update(read_only=True, managed_volume={"default_size_gb": 40})
document["container_contract"]["volumes"].append(
{"container_path": "/exports", "installation_choice": ["managed-volume", "host-bind"]})
document["container_contract"]["volumes"].append({"container_path": "/state"})
document["catalog_ui"]["launch"] = {"scheme": "http", "port": None, "path": "/"}
document["first_run"] = {"endpoints": [], "credentials": []}
document["proxmox"]["defaults"] = {"memory_mb": 512}
template = custom.template_from_oci_compose(json.dumps(document))
port = template["container_contract"]["ports"][0]
self.assertEqual((port["container_port"], port["protocol"], port["required"]), (5514, "udp", False))
data, _, _, exports, state = template["container_contract"]["volumes"]
self.assertTrue(data["read_only"])
self.assertEqual(data["managed_volume"], {"backup": True, "default_size_gb": 40})
# A path its author gives as a host directory is offered as one; the
# others follow the rule of the catalog and stay in a container volume.
self.assertEqual(exports["installation_choice"], ["managed-volume", "host-bind"])
self.assertEqual(state["installation_choice"], ["managed-volume"])
self.assertEqual(data["installation_choice"], ["managed-volume"])
self.assertEqual(template["first_run"]["endpoints"], [])
self.assertIsNone(template["catalog_ui"]["launch"]["port"])
self.assertEqual(template["proxmox"]["defaults"]["memory_mb"], 512)
self.assertEqual(template["proxmox"]["defaults"]["cores"], 2)
def test_a_secret_left_empty_is_not_given_a_value(self):
template = custom.template_from_oci_compose(example("duplicati-official"))
self.assertEqual([item["example"] for item in template["container_contract"]["environment"]], ["", ""])
class FaultTests(unittest.TestCase):
def test_what_is_not_an_oci_compose_is_refused(self):
for text in ("services:\n app:\n image: x", "[1, 2]", json.dumps({"kind": "something-else"}),
json.dumps({"kind": "proxmenux.oci-template", "container_contract": {"image": {}}}),
json.dumps({"kind": "proxmenux.oci-template", "container_contract": "jellyfin"})):
with self.subTest(text=text[:30]):
with self.assertRaises(ConversionError):
custom.template_from_oci_compose(text)
def test_a_fault_says_where_it_is(self):
for edit, where in ((lambda d: d["container_contract"].update(ports="8200"), "ports"),
(lambda d: d["container_contract"]["volumes"].append({"path": "/x"}),
"container_contract/volumes/3/container_path: required"),
(lambda d: d["first_run"].update(endpoints=[{"port": 8200}]), "first_run/endpoints/0"),
(lambda d: d["catalog_ui"].update(launch={"port": "8200"}), "catalog_ui/launch"),
(lambda d: d["container_contract"]["image"].update(reference="not an image"), "not an image")):
with self.subTest(where=where):
document = json.loads(example("duplicati-official"))
edit(document)
with self.assertRaises(ConversionError) as raised:
custom.template_from_oci_compose(json.dumps(document))
self.assertIn(where, str(raised.exception))
def test_a_fault_is_reported_on_a_node_that_cannot_check_the_schema(self):
document = json.loads(example("duplicati-official"))
document["first_run"]["endpoints"] = [{"port": 8200}]
with patch.object(Catalog, "schema_errors", return_value=None):
custom.template_from_oci_compose(example("duplicati-official"))
with self.assertRaises(ConversionError) as raised:
custom.template_from_oci_compose(json.dumps(document))
self.assertIn("The oci-compose has errors:", str(raised.exception))
self.assertIn("first_run/endpoints/0/label: required", str(raised.exception))
def test_the_check_without_the_schema_library_accepts_every_file_of_the_catalog(self):
index = json.loads((ROOT / "catalog/index.json").read_text(encoding="utf-8"))["applications"]
for item in index:
if item.get("hidden") or not item.get("automatic_install_candidate"):
continue
self.assertEqual(CATALOG.shape_errors(CATALOG.compose(item["id"])), [], item["id"])
document = json.loads(catalog_file("mariadb"))
document["container_contract"]["ports"] = "3306"
document["container_contract"]["volumes"][0]["container_path"] = 7
self.assertEqual(CATALOG.shape_errors(document),
["container_contract/volumes/0/container_path: string", "container_contract/ports: array"])
class MenuTests(unittest.TestCase):
def test_the_review_says_what_an_oci_compose_adds(self):
text = custom.describe_oci_compose(CATALOG.compose("filebrowser-quantum"))
self.assertIn("/home/filebrowser/data/config.yaml", text)
self.assertIn("Install it only if it comes from a source you trust.", text)
self.assertTrue(text.startswith("\n\n"))
def test_the_menu_offers_an_oci_compose_from_a_file_or_an_address(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "oci-compose.json"
path.write_text(example("jellyfin-official"), encoding="utf-8")
ui = FakeUI(["oci-compose"], [str(path)])
text, origin = custom.read_definition(ui)
2026-10-12 23:03:49 +02:00
self.assertEqual(ui.offered[0][-3:], ["oci-compose-paste", "oci-compose", "oci-compose-url"])
self.assertEqual(origin, custom.OCI_COMPOSE)
self.assertEqual(json.loads(text)["catalog_ui"]["title"], {"en_US": "Jellyfin Official"})
2026-10-12 23:03:49 +02:00
def test_an_oci_compose_can_be_pasted_in_the_terminal(self):
quiet = SimpleNamespace(show_logo=lambda: None, msg_title=lambda *_: None, msg_info2=lambda *_: None)
with patch.object(custom, "console", quiet), patch("builtins.print"), \
patch.object(custom.sys, "stdin", io.StringIO(example("duplicati-official"))):
text, origin = custom.read_definition(FakeUI(["oci-compose-paste"]))
self.assertEqual(origin, custom.OCI_COMPOSE)
self.assertEqual(custom.template_from_oci_compose(text)["catalog_ui"]["title"], {"en_US": "Duplicati Official"})
with patch.object(custom, "console", quiet), patch("builtins.print"), \
patch.object(custom.sys, "stdin", io.StringIO(" \n")):
with self.assertRaises(custom.UserCancelled) as raised:
custom.read_definition(FakeUI(["oci-compose-paste"]))
self.assertEqual(str(raised.exception), "No oci-compose was given")
def test_an_oci_compose_keeps_its_name_and_its_own_questions(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "oci-compose.json"
path.write_text(example("duplicati-official"), encoding="utf-8")
ui = FakeUI(["oci-compose", custom.DEFAULT_MODE], [str(path)])
with patch.object(custom, "registry_report", return_value=(True, "image found")), \
patch.object(custom, "install_template") as install, \
patch.object(custom, "_ask_secrets", side_effect=AssertionError("not for an oci-compose")), \
patch.object(custom, "_name", side_effect=AssertionError("not for an oci-compose")):
custom.explore(ui)
_, template, name, mode = install.call_args.args
self.assertEqual(name, "duplicati-official")
self.assertEqual(mode, custom.DEFAULT_MODE)
self.assertEqual(len(ui.asked), 1)
self.assertTrue(ui.reviews[0].startswith("This is what the oci-compose installs:"))
self.assertIn("Install it only if it comes from a source you trust.", ui.reviews[0])
def test_a_compose_file_is_read_as_before(self):
compose = "services:\n whoami:\n image: traefik/whoami:latest\n ports:\n - 8080:80\n"
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "docker-compose.yml"
path.write_text(compose, encoding="utf-8")
ui = FakeUI(["file", None], [str(path), "whoami"])
with patch.object(custom, "registry_report", return_value=(True, "image found")), \
patch.object(custom, "install_template") as install:
custom.explore(ui)
install.assert_not_called()
self.assertNotIn("Install it only if it comes from a source you trust.", ui.reviews[0])
self.assertNotIn("oci-compose", ui.reviews[0])
if __name__ == "__main__":
unittest.main()