Merge remote-tracking branch 'origin/develop' into HEAD

# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
This commit is contained in:
MacRimi
2026-10-16 23:32:05 +02:00
76 changed files with 3714 additions and 309 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ TOOL_METADATA = {
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.2'},
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'},
+44 -1
View File
@@ -22245,6 +22245,7 @@ def api_proxmenux_self_update_status():
# so scheduled and manual applies cannot overlap.
_APPLY_UPDATES_SCRIPT = _LXC_APPLY_UPDATES_SCRIPT
_SCHEDULED_STOPPED_EXIT = 9
_DOCKER_ENGINE_INTEGRATED_COMMAND = (
'python3 /usr/local/share/proxmenux/monitor-app/usr/bin/'
'update_docker_engine.py --vmid "$VMID"'
@@ -22286,6 +22287,23 @@ def _append_lxc_update_log(path: str | None, text: str) -> None:
print(f'[ProxMenux] scheduler: could not append update log: {exc}', flush=True)
def _scheduled_runner_reported_stopped(result, log_path: str | None, marker: str) -> bool:
"""Return true only for the scheduler's own pre-start stopped marker."""
if result.returncode != _SCHEDULED_STOPPED_EXIT or not marker:
return False
if log_path:
try:
with open(log_path, 'rb') as stream:
stream.seek(0, os.SEEK_END)
stream.seek(max(0, stream.tell() - 4096))
output = stream.read().decode('utf-8', errors='replace')
except OSError:
return False
else:
output = (result.stdout or '') + (result.stderr or '')
return marker in output
def _prune_lxc_update_logs(vmid: int) -> None:
try:
candidates = sorted(
@@ -22679,8 +22697,10 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
requested_targets = _normalise_schedule_targets(sched)
targets = list(requested_targets)
before = _lxc_update_snapshot(vmid)
requested_target = sched.get("target") or "both"
deferred_targets: list[str] = []
reasons: list[str] = []
reason_code: str | None = None
reboot_required: bool | None = None
reboot_packages: list[str] = []
reboot_check_error: str | None = None
@@ -22732,6 +22752,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
'status': status,
'target': actual_target,
'reason': reason,
'reason_code': reason_code,
'run_id': run_id,
'requested_targets': requested_targets,
'executed_targets': list(executed),
@@ -22743,10 +22764,23 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
'finalization': finalization,
}
requested_target = sched.get("target") or "both"
if not originally_running:
reason_code = 'stopped_container'
reasons.append('container is stopped; scheduled update was skipped')
return finish('skipped', requested_target, [])
if requested_targets == ['oci_image']:
# A container installed by OCI manager Apps is updated by replacing
# its image, through the same flow as the OCI menu, unattended.
# OCI updates have their own runner rather than apply_updates.sh.
# Check once more immediately before handing control to it: a user
# may have intentionally stopped the CT after the scheduler's first
# observation, and the OCI transaction can start a replacement CT
# while validating its new image.
if _fast_guest_status(vmid, 'lxc') != 'running':
reason_code = 'stopped_container'
reasons.append('container was stopped before scheduled update could begin')
return finish('skipped', 'oci_image', [])
command = ['bash', '/usr/local/share/proxmenux/scripts/oci/oci_manager_apps.sh',
'manage', str(vmid), '--action', 'update', '--unattended']
storage = (sched.get('backup_storage') or '').strip()
@@ -22887,6 +22921,9 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
target = "both" if has_os_target and has_app_targets else ("os" if has_os_target else "app")
env = dict(os.environ)
env["VMID"] = str(vmid)
env["EXECUTION_MODE"] = "scheduled"
scheduled_stopped_marker = f"PROXMENUX_SCHEDULED_STOPPED:{run_id}"
env["SCHEDULED_STOPPED_MARKER"] = scheduled_stopped_marker
env["TARGET"] = target
env["BACKUP"] = "1" if sched.get("backup") else "0"
env["BACKUP_STORAGE"] = sched.get("backup_storage") or ""
@@ -22952,6 +22989,10 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
text=True,
timeout=60 * 60,
)
if _scheduled_runner_reported_stopped(r, log_path, scheduled_stopped_marker):
reason_code = 'stopped_container'
reasons.append('container was stopped before scheduled update could begin')
return finish('skipped', target, [])
reboot_required, reboot_packages, reboot_check_error = (
_inspect_lxc_reboot_requirement(
vmid,
@@ -23027,12 +23068,14 @@ def _scheduler_loop():
status = outcome.get('status') or 'failure'
actual_target = outcome.get('target') or 'both'
reason = outcome.get('reason')
reason_code = outcome.get('reason_code')
try:
lxc_apps.record_schedule_run(
_vmid,
status,
actual_target,
reason,
reason_code=reason_code,
log_name=outcome.get('log_name'),
reboot_required=outcome.get('reboot_required'),
reboot_packages=outcome.get('reboot_packages'),
+43 -12
View File
@@ -94,6 +94,7 @@ _VALID_UPSTREAM_TYPES = ("github", "http_json", "docker_hub")
# `*/N` step, and comma lists — that covers every preset the UI
# exposes and the freeform "custom" text field.
_VALID_SCHEDULE_TARGETS = ("os", "app", "both")
_SCHEDULE_REASON_CODES = frozenset({"stopped_container"})
_SCHEDULE_TARGET_ID_RE = re.compile(
r"^(?:os|apps|oci_image|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-(?:compose|container):[A-Za-z0-9][A-Za-z0-9_.-]{0,127}|docker-unit:[a-f0-9]{20})$"
)
@@ -2081,8 +2082,28 @@ class _DockerNoRedirect(urllib.request.HTTPRedirectHandler):
_docker_no_redirect_opener = urllib.request.build_opener(_DockerNoRedirect)
def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str]]:
"""Exchange a parsed ``WWW-Authenticate`` challenge for a pull token."""
_REGISTRY_AUTH_FILE = "/etc/pve/priv/proxmenux/registry-auth.json"
_DOCKER_HUB_API_HOSTS = {"docker.io", "index.docker.io", "registry-1.docker.io", "registry.hub.docker.com"}
def _registry_saved_login(api_host: str) -> Optional[str]:
"""The login saved in ProxMenux for a registry, as a Basic credential."""
try:
with open(_REGISTRY_AUTH_FILE, encoding="utf-8") as handle:
auths = (json.load(handle) or {}).get("auths") or {}
except (OSError, ValueError, AttributeError):
return None
host = (api_host or "").lower()
for key in ([host, "docker.io"] if host in _DOCKER_HUB_API_HOSTS else [host]):
entry = auths.get(key) if isinstance(auths, dict) else None
if isinstance(entry, dict) and isinstance(entry.get("auth"), str) and entry["auth"]:
return f"Basic {entry['auth']}"
return None
def _registry_bearer_token(challenge: dict, saved_login: Optional[str] = None) -> tuple[Optional[str], Optional[str]]:
"""Exchange a parsed ``WWW-Authenticate`` challenge for a pull token,
with the login saved for a private registry when there is one."""
query = {
key: challenge[key]
for key in ("service", "scope") if challenge.get(key)
@@ -2090,10 +2111,10 @@ def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str
token_url = challenge["realm"]
if query:
token_url += ("&" if "?" in token_url else "?") + urllib.parse.urlencode(query)
token_req = urllib.request.Request(
token_url,
headers={"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"},
)
token_headers = {"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"}
if saved_login:
token_headers["Authorization"] = saved_login
token_req = urllib.request.Request(token_url, headers=token_headers)
try:
with urllib.request.urlopen(token_req, timeout=_docker_registry_timeout()) as response:
body = response.read(65537)
@@ -2160,13 +2181,18 @@ def _registry_request(url: str, headers: dict, method: str = "HEAD",
if error:
return None, None, token, error
if status == 401:
challenge = _parse_bearer_challenge((response_headers or {}).get("WWW-Authenticate", ""))
if not challenge:
authenticate = (response_headers or {}).get("WWW-Authenticate", "")
saved_login = _registry_saved_login(urllib.parse.urlsplit(url).netloc)
challenge = _parse_bearer_challenge(authenticate)
if challenge:
token, token_error = _registry_bearer_token(challenge, saved_login)
if token_error:
return None, None, None, token_error
attempt_headers["Authorization"] = f"Bearer {token}"
elif saved_login and authenticate.lower().startswith("basic") and url.startswith("https://"):
attempt_headers["Authorization"] = saved_login
else:
return None, None, token, "registry authentication required"
token, token_error = _registry_bearer_token(challenge)
if token_error:
return None, None, None, token_error
attempt_headers["Authorization"] = f"Bearer {token}"
response_headers, body, status, location, error = _registry_open(
url, attempt_headers, method, max_bytes,
)
@@ -3918,6 +3944,7 @@ def record_schedule_run(
target: str,
reason: Optional[str] = None,
*,
reason_code: Optional[str] = None,
log_name: Optional[str] = None,
reboot_required: Optional[bool] = None,
reboot_packages: Optional[list[str]] = None,
@@ -3937,6 +3964,10 @@ def record_schedule_run(
sidecar["schedule"]["last_run_reason"] = str(reason)[:300]
else:
sidecar["schedule"].pop("last_run_reason", None)
if reason_code in _SCHEDULE_REASON_CODES:
sidecar["schedule"]["last_run_reason_code"] = reason_code
else:
sidecar["schedule"].pop("last_run_reason_code", None)
if log_name:
sidecar["schedule"]["last_run_log"] = os.path.basename(str(log_name))[:220]
else:
+1 -1
View File
@@ -1564,7 +1564,7 @@ class AppriseChannel(NotificationChannel):
return apprise_mod.NotifyType.FAILURE
if sev == 'WARNING':
return apprise_mod.NotifyType.WARNING
if sev == 'SUCCESS':
if sev in ('SUCCESS', 'OK'):
return apprise_mod.NotifyType.SUCCESS
return apprise_mod.NotifyType.INFO
@@ -33,6 +33,8 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
self.vmid,
'success',
'both',
reason='container is stopped; scheduled update was skipped',
reason_code='stopped_container',
log_name='../9911-scheduled-' + ('a' * 32) + '.log',
reboot_required=True,
reboot_packages=['linux-image-amd64', 'libc6'],
@@ -43,11 +45,31 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
'9911-scheduled-' + ('a' * 32) + '.log',
)
self.assertTrue(schedule['last_run_reboot_required'])
self.assertEqual(schedule['last_run_reason_code'], 'stopped_container')
self.assertEqual(
schedule['last_run_reboot_packages'],
['linux-image-amd64', 'libc6'],
)
def test_record_rejects_unknown_reason_code_and_clears_the_previous_one(self):
self.assertTrue(lxc_apps.record_schedule_run(
self.vmid,
'skipped',
'os',
reason='container is stopped; scheduled update was skipped',
reason_code='stopped_container',
))
self.assertTrue(lxc_apps.record_schedule_run(
self.vmid,
'failure',
'os',
reason='update runner exited with code 9',
reason_code='untrusted_value',
))
schedule = lxc_apps._read_sidecar(self.vmid)['schedule']
self.assertEqual(schedule['last_run_reason'], 'update runner exited with code 9')
self.assertNotIn('last_run_reason_code', schedule)
def test_lifecycle_clear_preserves_run_and_log(self):
lxc_apps.record_schedule_run(
self.vmid,
@@ -0,0 +1,101 @@
"""The update check of a private image uses the login saved in ProxMenux:
it is sent to the token service the registry names, or to the registry
itself when it asks for Basic authentication, and never anywhere else."""
import base64
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import lxc_apps
CREDENTIAL = base64.b64encode(b"macrimi:ghp_secret").decode()
MANIFEST = "https://ghcr.io/v2/macrimi/app/manifests/latest"
class SavedLoginTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.auth_file = Path(self.folder.name) / "registry-auth.json"
self.auth_file.write_text(json.dumps({"auths": {"ghcr.io": {"auth": CREDENTIAL},
"docker.io": {"auth": CREDENTIAL},
"registry.example.com:5000": {"auth": CREDENTIAL}}}))
patcher = mock.patch.object(lxc_apps, "_REGISTRY_AUTH_FILE", str(self.auth_file))
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(self.folder.cleanup)
def test_the_login_is_found_by_registry_host(self):
self.assertEqual(lxc_apps._registry_saved_login("ghcr.io"), f"Basic {CREDENTIAL}")
self.assertEqual(lxc_apps._registry_saved_login("registry-1.docker.io"), f"Basic {CREDENTIAL}")
self.assertEqual(lxc_apps._registry_saved_login("registry.example.com:5000"), f"Basic {CREDENTIAL}")
self.assertIsNone(lxc_apps._registry_saved_login("quay.io"))
def test_without_the_file_nothing_is_sent(self):
self.auth_file.unlink()
self.assertIsNone(lxc_apps._registry_saved_login("ghcr.io"))
def test_a_bearer_challenge_asks_the_token_with_the_saved_login(self):
challenge = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:macrimi/app:pull"'
answers = [({"WWW-Authenticate": challenge}, b"", 401, None, None),
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
sent = []
def token_service(request, timeout):
sent.append(dict(request.header_items()))
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b'{"token": "pull-token"}'
return response
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry, \
mock.patch.object(lxc_apps.urllib.request, "urlopen", side_effect=token_service):
headers, _body, token, error = lxc_apps._registry_request(MANIFEST, {"Accept": "*/*"})
self.assertIsNone(error)
self.assertEqual(token, "pull-token")
self.assertEqual(sent[0]["Authorization"], f"Basic {CREDENTIAL}")
self.assertEqual(registry.call_args_list[1].args[1]["Authorization"], "Bearer pull-token")
def test_a_basic_challenge_is_answered_with_the_saved_login(self):
answers = [({"WWW-Authenticate": 'Basic realm="registry"'}, b"", 401, None, None),
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
url = "https://registry.example.com:5000/v2/team/app/manifests/latest"
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry:
_headers, _body, _token, error = lxc_apps._registry_request(url, {"Accept": "*/*"})
self.assertIsNone(error)
self.assertEqual(registry.call_args_list[1].args[1]["Authorization"], f"Basic {CREDENTIAL}")
def test_a_basic_challenge_over_http_gets_no_login(self):
answers = [({"WWW-Authenticate": 'Basic realm="registry"'}, b"", 401, None, None)]
url = "http://registry.example.com:5000/v2/team/app/manifests/latest"
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry:
_headers, _body, _token, error = lxc_apps._registry_request(url, {"Accept": "*/*"})
self.assertEqual(error, "registry authentication required")
self.assertEqual(registry.call_count, 1)
def test_a_public_image_asks_its_token_without_a_login(self):
challenge = 'Bearer realm="https://auth.example.org/token",service="example",scope="repository:x:pull"'
answers = [({"WWW-Authenticate": challenge}, b"", 401, None, None),
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
sent = []
def token_service(request, timeout):
sent.append(dict(request.header_items()))
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b'{"token": "t"}'
return response
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers), \
mock.patch.object(lxc_apps.urllib.request, "urlopen", side_effect=token_service):
lxc_apps._registry_request("https://quay.io/v2/x/manifests/latest", {})
self.assertNotIn("Authorization", sent[0])
if __name__ == "__main__":
unittest.main()