mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
- Registry accounts are kept in /etc/pve/priv/proxmenux/registry-auth.json, shared by every node, unless REGISTRY_AUTH_FILE says otherwise - The custom image importer offers to sign in when a registry refuses an image, and reads it again - Installers, updates, the watchdog and the Monitor update check read private images with the saved accounts - The host backup includes the accounts, and uninstalling ProxMenux removes them and keeps the OCI records of the cluster - Spanish written by hand and spacing fixed for the registry account messages in every locale - Docs: private registries section, Registry accounts in the OCI menu, README storage notes
146 lines
6.3 KiB
Python
146 lines
6.3 KiB
Python
"""Registry accounts are kept for the whole cluster, offered when a registry
|
|
refuses an image, and read by every program that downloads images."""
|
|
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "src"))
|
|
|
|
from proxmenux_oci import custom, registry_accounts, registry_auth
|
|
from proxmenux_oci.ui import UserCancelled
|
|
|
|
CLUSTER_FILE = "/etc/pve/priv/proxmenux/registry-auth.json"
|
|
GHCR_PRIVATE = ('time="2026-10-16T18:06:24+02:00" level=fatal msg="Error parsing image name '
|
|
'\\"docker://ghcr.io/macrimi/private-test:latest\\": Requesting bearer token: '
|
|
'invalid status code from registry 403 (Forbidden)"')
|
|
HUB_PRIVATE = 'reading manifest latest in docker.io/macrimi/private-test: requested access to the resource is denied'
|
|
|
|
|
|
class FakeUI:
|
|
def __init__(self, answers=(), confirms=(True,)):
|
|
self.answers = list(answers)
|
|
self.confirms = list(confirms)
|
|
self.messages = []
|
|
|
|
def ask(self, text, default=None, required=True):
|
|
answer = self.answers.pop(0)
|
|
if answer is UserCancelled:
|
|
raise UserCancelled(text)
|
|
return answer
|
|
|
|
def password(self, text, required=True):
|
|
return self.answers.pop(0)
|
|
|
|
def confirm(self, text, default=False, title=None):
|
|
return self.confirms.pop(0)
|
|
|
|
def info(self, text, title=None):
|
|
pass
|
|
|
|
def message(self, text, title=None):
|
|
self.messages.append(text)
|
|
|
|
|
|
class ClusterFile(unittest.TestCase):
|
|
def test_the_cluster_file_is_used_when_the_cluster_filesystem_is_mounted(self):
|
|
environment = {}
|
|
with patch.object(registry_auth.os.path, "ismount", return_value=True):
|
|
registry_auth.use_cluster_auth_file(environment)
|
|
self.assertEqual(environment, {"REGISTRY_AUTH_FILE": CLUSTER_FILE})
|
|
|
|
def test_nothing_is_chosen_without_the_cluster_filesystem(self):
|
|
environment = {}
|
|
with patch.object(registry_auth.os.path, "ismount", return_value=False):
|
|
registry_auth.use_cluster_auth_file(environment)
|
|
self.assertEqual(environment, {})
|
|
|
|
def test_a_file_chosen_by_the_user_is_kept(self):
|
|
environment = {"REGISTRY_AUTH_FILE": "/root/own.json"}
|
|
with patch.object(registry_auth.os.path, "ismount", return_value=True):
|
|
registry_auth.use_cluster_auth_file(environment)
|
|
self.assertEqual(environment, {"REGISTRY_AUTH_FILE": "/root/own.json"})
|
|
|
|
def test_the_accounts_menu_reads_and_writes_the_cluster_file(self):
|
|
with patch.dict(registry_auth.os.environ, {"REGISTRY_AUTH_FILE": CLUSTER_FILE}):
|
|
self.assertEqual(str(registry_auth.RegistryAuth().path), CLUSTER_FILE)
|
|
|
|
|
|
class RefusedAccess(unittest.TestCase):
|
|
def test_a_refusal_is_told_apart_from_other_failures(self):
|
|
for text in (GHCR_PRIVATE, HUB_PRIVATE, "unauthorized: authentication required"):
|
|
self.assertTrue(registry_auth.refuses_access(text), text)
|
|
for text in ("dial tcp: lookup ghcr.io: no such host", "manifest unknown", ""):
|
|
self.assertFalse(registry_auth.refuses_access(text), text)
|
|
|
|
def test_the_classifier_still_names_the_refusal(self):
|
|
self.assertIn("Access denied by ghcr.io", registry_auth.registry_error("ghcr.io", GHCR_PRIVATE))
|
|
|
|
|
|
class SignInFromTheImporter(unittest.TestCase):
|
|
def sign_in(self, ui, login=None):
|
|
auth = MagicMock()
|
|
if login:
|
|
auth.login.side_effect = login
|
|
with patch.object(registry_accounts, "require_root"), \
|
|
patch.object(registry_accounts, "RegistryAuth", return_value=auth):
|
|
result = registry_accounts.offer_sign_in(ui, "ghcr.io/macrimi/app:latest", "Title")
|
|
return result, auth
|
|
|
|
def test_an_accepted_account_is_saved(self):
|
|
ui = FakeUI(["macrimi", "ghp_secret"])
|
|
result, auth = self.sign_in(ui)
|
|
self.assertTrue(result)
|
|
auth.login.assert_called_once_with("ghcr.io", "macrimi", "ghp_secret")
|
|
|
|
def test_a_declined_offer_asks_nothing(self):
|
|
ui = FakeUI([], confirms=[False])
|
|
result, auth = self.sign_in(ui)
|
|
self.assertFalse(result)
|
|
auth.login.assert_not_called()
|
|
|
|
def test_a_cancelled_question_returns_without_signing_in(self):
|
|
ui = FakeUI([UserCancelled])
|
|
result, auth = self.sign_in(ui)
|
|
self.assertFalse(result)
|
|
auth.login.assert_not_called()
|
|
|
|
def test_a_refused_account_is_explained(self):
|
|
ui = FakeUI(["macrimi", "wrong"])
|
|
refused = registry_auth.RegistryError("Authentication failed for ghcr.io.")
|
|
result, _ = self.sign_in(ui, login=refused)
|
|
self.assertFalse(result)
|
|
self.assertEqual(ui.messages, ["Authentication failed for ghcr.io."])
|
|
|
|
def test_a_refused_image_is_checked_again_after_signing_in(self):
|
|
compose = "services:\n app:\n image: ghcr.io/macrimi/app:latest\n"
|
|
reports = iter([(False, "Could not inspect the image in its registry"), (True, "The image is in its registry")])
|
|
ui = SimpleNamespace(message=lambda *a, **k: None, review=lambda *a, **k: False)
|
|
with patch.object(custom, "read_definition", return_value=(compose, "pasted Compose file")), \
|
|
patch.object(custom, "registry_report", side_effect=lambda _: next(reports)) as report, \
|
|
patch.object(custom, "_registry_refused", return_value=True), \
|
|
patch.object(registry_accounts, "offer_sign_in", return_value=True) as offer:
|
|
custom.explore(ui)
|
|
offer.assert_called_once()
|
|
self.assertEqual(report.call_count, 2)
|
|
|
|
|
|
class EveryImageReaderUsesTheAccounts(unittest.TestCase):
|
|
def test_every_program_that_runs_skopeo_reads_the_cluster_file(self):
|
|
readers = [path for path in sorted((ROOT / "remote").iterdir())
|
|
if path.suffix in {".sh", ".py"}
|
|
and re.search(r"skopeo['\",]*\s+(inspect|copy)|'skopeo',\s*'(inspect|copy)'", path.read_text())]
|
|
self.assertGreaterEqual(len(readers), 7)
|
|
for path in readers:
|
|
with self.subTest(path=path.name):
|
|
self.assertIn(CLUSTER_FILE, path.read_text())
|
|
self.assertIn("_use_cluster_auth_file()", (ROOT / "src/proxmenux_oci/__init__.py").read_text())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|