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

This commit is contained in:
MacRimi
2026-10-16 23:58:03 +02:00
92 changed files with 5099 additions and 394 deletions
+1
View File
@@ -182,6 +182,7 @@ done
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
cp "$SCRIPT_DIR/flask_vm_backup_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_vm_backup_routes.py not found"
cp "$SCRIPT_DIR/guest_backup_archives.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found"
cp "$SCRIPT_DIR/audit_store.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_store.py not found"
cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found"
+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'},
+73 -1
View File
@@ -95,6 +95,8 @@ from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
from flask_audit_routes import audit_bp # noqa: E402
from flask_vm_backup_routes import vm_backup_bp # noqa: E402
from guest_backup_archives import (BackupArchiveError, delete_guest_backups,
guest_backup_deletion_status) # noqa: E402
from notification_manager import notification_manager, resolve_notification_hostname # noqa: E402
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
@@ -13272,6 +13274,32 @@ def api_create_backup(vmid):
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/backups/delete', methods=['POST'])
@require_admin_scope
def api_delete_vm_backups(vmid):
"""Delete an explicitly confirmed selection, revalidated by Proxmox."""
try:
return jsonify(delete_guest_backups(vmid, request.get_json(silent=True)))
except BackupArchiveError as error:
return jsonify({'error': str(error)}), error.code
finally:
# Native deletion may have started even if the pvesh response was lost.
_vm_cache_invalidate(vmid, _vm_backups_cache)
@app.route('/api/vms/<int:vmid>/backups/delete-status', methods=['GET'])
@require_admin_scope
def api_vm_backup_deletion_status(vmid):
"""Only poll deletion tasks started here for this guest."""
try:
result = guest_backup_deletion_status(vmid, request.args.get('task'))
if result['status'] != 'pending':
_vm_cache_invalidate(vmid, _vm_backups_cache)
return jsonify(result)
except BackupArchiveError as error:
return jsonify({'error': str(error)}), error.code
@app.route('/api/vms/<int:vmid>/backups', methods=['GET'])
@require_auth
def api_vm_backups(vmid):
@@ -13352,6 +13380,7 @@ def api_vm_backups(vmid):
'timestamp': ctime,
'date': datetime.fromtimestamp(ctime).strftime('%Y-%m-%d %H:%M') if ctime else '',
'notes': item.get('notes', ''),
'protected': item.get('protected', False) not in (False, 0, '0', '', None),
})
except Exception:
pass
@@ -22214,6 +22243,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"'
@@ -22255,6 +22285,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(
@@ -22648,8 +22695,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
@@ -22701,6 +22750,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),
@@ -22712,10 +22762,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()
@@ -22856,6 +22919,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 ""
@@ -22921,6 +22987,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,
@@ -22996,12 +23066,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'),
@@ -16,6 +16,9 @@ Endpoints, all under /api/vm-backup-jobs:
DELETE /<id> delete a job (its backups are kept)
POST /<id>/toggle enable / disable
POST /<id>/run run it now, as a Proxmox task on each node
Storage retention is read/edited under /api/vm-backup-storages. Saving a
policy only changes the native storage configuration; it never runs pruning.
"""
import json
@@ -229,6 +232,58 @@ def options():
})
@vm_backup_bp.route("/api/vm-backup-storages", methods=["GET"])
@require_auth
def backup_storage_retention():
ok, storages = _pvesh_json(["get", "/storage"])
if not ok or not isinstance(storages, list):
return _err(storages if not ok else "Invalid storage inventory", 502)
return jsonify({"storages": [
{"id": storage["storage"], "type": storage.get("type", ""),
"prune_backups": _flat(storage.get("prune-backups") or ""),
"digest": storage.get("digest", "")}
for storage in storages
if isinstance(storage, dict) and storage.get("storage")
and "backup" in (storage.get("content") or "").split(",")
]})
@vm_backup_bp.route("/api/vm-backup-storages/<storage_id>", methods=["PUT"])
@require_admin_scope
def update_backup_storage_retention(storage_id):
if not STORAGE_RE.fullmatch(storage_id):
return _err("Invalid storage")
data = request.get_json(silent=True)
if not isinstance(data, dict):
return _err("A retention policy and current configuration digest are required")
digest, prune = data.get("digest"), data.get("prune_backups")
if not isinstance(digest, str) or not re.fullmatch(r"[a-fA-F0-9]{40}", digest):
return _err("Refresh the storage list before editing its retention")
# An empty policy removes the storage setting, so the Proxmox default applies.
if not isinstance(prune, str) or (prune and not PRUNE_RE.fullmatch(prune)):
return _err("Invalid retention policy")
pairs = [part.split("=", 1) for part in prune.split(",")] if prune else []
keys = [key for key, _ in pairs]
if (len(keys) != len(set(keys)) or
("keep-all" in keys and (len(keys) != 1 or pairs[0][1] != "1")) or
any(int(value) < 1 for _, value in pairs)):
return _err("Choose keep-all=1 or one or more positive retention counts")
# Check that this is still a backup storage, then let Proxmox check the
# digest under its storage lock. Other storage settings are never passed.
ok, storage = _pvesh_json(["get", f"/storage/{storage_id}"])
if not ok:
return _err(storage, 502)
if not isinstance(storage, dict) or "backup" not in (storage.get("content") or "").split(","):
return _err("This storage does not accept backups", 409)
if storage.get("digest") != digest:
return _err("Storage configuration changed; refresh before saving", 409)
change = ["--prune-backups", prune] if prune else ["--delete", "prune-backups"]
ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, *change])
if not ok:
return _err(out, 409)
return jsonify({"success": True})
@vm_backup_bp.route("/api/vm-backup-jobs", methods=["POST"])
@require_admin_scope
def create_job():
+172
View File
@@ -0,0 +1,172 @@
# ==========================================================
# ProxMenux Monitor - Guest backup archives
# Author : Jonatan
# GitHub : github.com/jcastro
# Maintainer : MacRimi
# Copyright : (c) 2026 MacRimi & contributors
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 16/10/2026
# ==========================================================
# Description:
# Deletes explicitly selected VM/CT backups through the native Proxmox
# storage API after a fresh inventory/protection check. Tracks native
# deletion tasks so an accepted operation is not mistaken for completion.
# No filesystem paths or cached inventory authorize deletion.
# ==========================================================
import json
import re
import socket
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
MAX_SELECTION = 20
REMOVED_RE = re.compile(r"Removed volume '[^'\n]+'")
STORAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
NODE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}")
_tasks = {}
_lock = threading.Lock()
class BackupArchiveError(Exception):
def __init__(self, message, code=400):
super().__init__(message)
self.code = code
def _pvesh(args, timeout=20):
try:
result = subprocess.run(["pvesh", *args, "--output-format", "json"],
capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as error:
raise BackupArchiveError("Proxmox command unavailable or timed out; outcome unconfirmed", 502) from error
if result.returncode:
raise BackupArchiveError((result.stderr or "Proxmox command failed").strip()[:1000], 502)
output = result.stdout.strip()
try:
# pvesh can print no bytes for the API's successful null result.
return json.loads(output or "null")
except (ValueError, TypeError) as error:
# With --delay, a deletion that ends in time prints the line of its
# task log instead of JSON. Only that exact line is accepted.
if args[0] == "delete" and REMOVED_RE.fullmatch(output):
return output
raise BackupArchiveError("Invalid Proxmox response; outcome unconfirmed", 502) from error
def _node():
# Same local storage/node scope as the guest's existing backup list.
node = socket.gethostname().split(".")[0]
if not NODE_RE.fullmatch(node):
raise BackupArchiveError("Invalid local node name", 502)
return node
def _selection(vmid, data):
if not 100 <= vmid <= 999999999:
raise BackupArchiveError("Invalid VM/CT ID")
if not isinstance(data, dict) or data.get("confirm") is not True:
raise BackupArchiveError("Explicit confirmation is required")
selected = data.get("backups")
if not isinstance(selected, list) or not 1 <= len(selected) <= MAX_SELECTION:
raise BackupArchiveError(f"Select between 1 and {MAX_SELECTION} backups")
seen = set()
for item in selected:
if not isinstance(item, dict):
raise BackupArchiveError("Invalid backup selection")
storage, volid = item.get("storage"), item.get("volid")
if not isinstance(storage, str) or not STORAGE_RE.fullmatch(storage):
raise BackupArchiveError("Invalid backup storage")
if (not isinstance(volid, str) or not volid.startswith(storage + ":")
or len(volid) > 512 or not re.fullmatch(r"[A-Za-z0-9_:./+@-]+", volid)
or any(part in ("", ".", "..") for part in volid.split(":", 1)[1].split("/"))):
raise BackupArchiveError("Invalid backup volume ID")
if volid in seen:
raise BackupArchiveError("Duplicate backup selection")
seen.add(volid)
return [{"storage": item["storage"], "volid": item["volid"]} for item in selected]
def _protected(value):
# Fail closed for an unexpected protection value, including the text 'true'.
return value not in (None, False, 0, "0", "")
def _remember_task(task, vmid, node, volid):
# These are server-returned task IDs, never arbitrary client paths.
if (not isinstance(task, str) or not task.startswith(f"UPID:{node}:")
or ":imgdel:" not in task or "/" in task or len(task) > 1024):
raise BackupArchiveError("Unexpected deletion task; outcome unconfirmed", 502)
with _lock:
now = time.monotonic()
expired = [key for key, entry in _tasks.items() if now - entry["created"] > 3600]
for key in expired:
del _tasks[key]
# Bound even failed/unpolled jobs; expiry does not cancel a PVE task.
if len(_tasks) >= 512:
del _tasks[min(_tasks, key=lambda key: _tasks[key]["created"])]
_tasks[task] = {"vmid": vmid, "node": node, "volid": volid, "created": now}
def delete_guest_backups(vmid, data):
selected = _selection(vmid, data)
node = _node()
inventory = {}
# Preflight the entire batch before deleting anything. An unavailable
# storage or a protected/stale/cross-guest selection fails the whole batch.
for storage in sorted({item["storage"] for item in selected}):
entries = _pvesh(["get", f"/nodes/{node}/storage/{storage}/content",
"--content", "backup", "--vmid", str(vmid)])
if not isinstance(entries, list):
raise BackupArchiveError("Invalid backup inventory", 502)
for entry in entries:
if (isinstance(entry, dict) and entry.get("content") == "backup"
and isinstance(entry.get("volid"), str)):
inventory[(storage, entry.get("volid"))] = entry
for item in selected:
entry = inventory.get((item["storage"], item["volid"]))
if entry is None or str(entry.get("vmid")) != str(vmid):
raise BackupArchiveError("Backup no longer exists or does not belong to this VM/CT", 409)
if _protected(entry.get("protected")):
raise BackupArchiveError("Protected backups cannot be deleted", 409)
def remove(item):
try:
# Proxmox also enforces protection at deletion time. It removes
# associated notes/log files and returns null only after success.
task = _pvesh(["delete", f'/nodes/{node}/storage/{item["storage"]}/content/{item["volid"]}',
"--delay", "1"])
if isinstance(task, str) and task.startswith("UPID:"):
_remember_task(task, vmid, node, item["volid"])
return {**item, "status": "pending", "task": task}
if task is None or task == f"Removed volume '{item['volid']}'":
return {**item, "status": "deleted"}
return {**item, "status": "unconfirmed", "error": "Invalid Proxmox response; outcome unconfirmed"}
except BackupArchiveError as error:
return {**item, "status": "unconfirmed", "error": str(error)}
with ThreadPoolExecutor(max_workers=min(4, len(selected)), thread_name_prefix="backup-delete") as pool:
results = list(pool.map(remove, selected))
return {"results": results}
def guest_backup_deletion_status(vmid, task):
with _lock:
entry = _tasks.get(task) if isinstance(task, str) else None
if not entry or entry["vmid"] != vmid or time.monotonic() - entry["created"] > 3600:
raise BackupArchiveError("Deletion task unknown or expired; refresh the backup list", 409)
entry = entry.copy()
status = _pvesh(["get", f'/nodes/{entry["node"]}/tasks/{task}/status'])
if not isinstance(status, dict) or status.get("status") not in ("running", "stopped"):
raise BackupArchiveError("Invalid deletion task status", 502)
result = {"volid": entry["volid"], "status": "pending"}
if status["status"] == "stopped":
if status.get("exitstatus") == "OK":
result["status"] = "deleted"
else:
result.update(status="unconfirmed", error=status.get("exitstatus") or "Deletion outcome unconfirmed")
return result
+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()