mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Merge develop and preserve generic i18n regression coverage
This commit is contained in:
@@ -37,6 +37,23 @@ import DOMPurify from "dompurify"
|
||||
import { marked } from "marked"
|
||||
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
||||
|
||||
// A schedule record predating the structured reason code still contains one
|
||||
// of these exact diagnostic messages. Recognise only those known historical
|
||||
// values so an already-recorded safe skip localizes after an upgrade, while
|
||||
// unrelated freeform diagnostics remain verbatim for troubleshooting.
|
||||
const LEGACY_STOPPED_SCHEDULE_REASONS = new Set([
|
||||
"container is stopped; scheduled update was skipped",
|
||||
"container was stopped before scheduled update could begin",
|
||||
])
|
||||
|
||||
const scheduleReasonCodeFromPersisted = (reasonCode: unknown, reason: unknown): string | null => {
|
||||
if (typeof reasonCode === "string" && reasonCode) return reasonCode
|
||||
if (typeof reason === "string" && LEGACY_STOPPED_SCHEDULE_REASONS.has(reason.toLowerCase())) {
|
||||
return "stopped_container"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Sent by /api/vms only for LXC rows, only when the user has enabled
|
||||
// `lxc_updates_available` notifications. The Monitor populates this
|
||||
// from managed_installs registry → frontend uses it to render the
|
||||
@@ -2072,6 +2089,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const [scheduleLastRunAt, setScheduleLastRunAt] = useState<string | null>(null)
|
||||
const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState<string | null>(null)
|
||||
const [scheduleLastRunReason, setScheduleLastRunReason] = useState<string | null>(null)
|
||||
const [scheduleLastRunReasonCode, setScheduleLastRunReasonCode] = useState<string | null>(null)
|
||||
const [scheduleLastRunLog, setScheduleLastRunLog] = useState<string | null>(null)
|
||||
const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false)
|
||||
const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState<string[]>([])
|
||||
@@ -2148,6 +2166,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setScheduleLastRunAt(s.last_run_at || null)
|
||||
setScheduleLastRunStatus(s.last_run_status || null)
|
||||
setScheduleLastRunReason(s.last_run_reason || null)
|
||||
setScheduleLastRunReasonCode(scheduleReasonCodeFromPersisted(s.last_run_reason_code, s.last_run_reason))
|
||||
setScheduleLastRunLog(s.last_run_log || null)
|
||||
setScheduleLastRunRebootRequired(s.last_run_reboot_required === true)
|
||||
setScheduleLastRunRebootPackages(Array.isArray(s.last_run_reboot_packages)
|
||||
@@ -2425,6 +2444,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const renderScheduleRunDetails = (indented = false) => {
|
||||
if (!scheduleLastRunAt || !selectedVM) return null
|
||||
const spacing = indented ? "pl-4" : ""
|
||||
const scheduleLastRunReasonText = scheduleLastRunReasonCode === "stopped_container"
|
||||
? selectedVM.status === "stopped"
|
||||
? t("vmLxc.scheduled.reasonStoppedContainer")
|
||||
: t("vmLxc.scheduled.reasonStoppedContainerHistorical")
|
||||
: scheduleLastRunReason
|
||||
return (
|
||||
<div className={`space-y-1.5 ${spacing}`}>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -2438,14 +2462,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
: scheduleLastRunStatus === "deferred"
|
||||
? t("vmLxc.scheduled.runDeferred")
|
||||
: scheduleLastRunStatus === "skipped"
|
||||
? t("vmLxc.scheduled.runSkipped")
|
||||
? t("vmLxc.scheduled.runSkippedStatus")
|
||||
: t("vmLxc.scheduled.runFailed")}
|
||||
</span></>
|
||||
)}
|
||||
</div>
|
||||
{scheduleLastRunReason && (
|
||||
{scheduleLastRunReasonText && (
|
||||
<div className="text-xs text-muted-foreground break-words">
|
||||
{scheduleLastRunReason}
|
||||
{scheduleLastRunReasonText}
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunRebootRequired && (
|
||||
|
||||
@@ -1316,7 +1316,10 @@
|
||||
"Donnerstag",
|
||||
"Freitag",
|
||||
"Samstag"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "Lauf übersprungen",
|
||||
"reasonStoppedContainer": "Container ist gestoppt;Das geplante Update wurde übersprungen.",
|
||||
"reasonStoppedContainerHistorical": "Der Container wurde zum geplanten Aktualisierungszeitpunkt gestoppt."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "Host-Cron erkannt",
|
||||
|
||||
@@ -1302,6 +1302,9 @@
|
||||
"runFailed": "✗ failed",
|
||||
"runDeferred": "held for safety",
|
||||
"runSkipped": "nothing pending",
|
||||
"runSkippedStatus": "run skipped",
|
||||
"reasonStoppedContainer": "Container is stopped; scheduled update was skipped.",
|
||||
"reasonStoppedContainerHistorical": "Container was stopped at the scheduled update time.",
|
||||
"releaseDelaySummary": "{days}-day release hold",
|
||||
"releaseDelayLabel": "Wait after a new app release",
|
||||
"releaseDelayNone": "No waiting period",
|
||||
|
||||
@@ -1316,7 +1316,10 @@
|
||||
"jueves",
|
||||
"viernes",
|
||||
"sábado"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "ejecución omitida",
|
||||
"reasonStoppedContainer": "el contenedor está detenido;Se omitió la actualización programada.",
|
||||
"reasonStoppedContainerHistorical": "el contenedor se detuvo a la hora de actualización programada."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron del host detectado",
|
||||
|
||||
@@ -1316,7 +1316,10 @@
|
||||
"Jeudi",
|
||||
"Vendredi",
|
||||
"Samedi"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "exécution ignorée",
|
||||
"reasonStoppedContainer": "le conteneur est arrêté ;la mise à jour programmée a été ignorée.",
|
||||
"reasonStoppedContainerHistorical": "le conteneur a été arrêté à l'heure de mise à jour prévue."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron hôte détecté",
|
||||
|
||||
@@ -1316,7 +1316,10 @@
|
||||
"Giovedì",
|
||||
"Venerdì",
|
||||
"Sabato"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "corsa saltata",
|
||||
"reasonStoppedContainer": "il contenitore è fermo;l'aggiornamento pianificato è stato saltato.",
|
||||
"reasonStoppedContainerHistorical": "il contenitore è stato arrestato all'ora di aggiornamento pianificata."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron host rilevato",
|
||||
|
||||
@@ -1316,7 +1316,10 @@
|
||||
"Quinta-feira",
|
||||
"Sexta-feira",
|
||||
"Sábado"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "execução ignorada",
|
||||
"reasonStoppedContainer": "Container está parado;a atualização agendada foi ignorada.",
|
||||
"reasonStoppedContainerHistorical": "o contêiner foi interrompido no horário de atualização agendado."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron do host detectado",
|
||||
|
||||
@@ -1302,6 +1302,9 @@
|
||||
"runFailed": "✗ zlyhalo",
|
||||
"runDeferred": "odložené z bezpečnostných dôvodov",
|
||||
"runSkipped": "nič nečaká",
|
||||
"runSkippedStatus": "spustenie bolo preskočené",
|
||||
"reasonStoppedContainer": "Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.",
|
||||
"reasonStoppedContainerHistorical": "Kontajner bol v čase plánovanej aktualizácie vypnutý.",
|
||||
"releaseDelaySummary": "odklad {days} deň/dní",
|
||||
"releaseDelayLabel": "Počkať po novej verzii aplikácie",
|
||||
"releaseDelayNone": "Bez čakacej doby",
|
||||
|
||||
@@ -1316,7 +1316,10 @@
|
||||
"torsdag",
|
||||
"fredag",
|
||||
"lördag"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "kör hoppade över",
|
||||
"reasonStoppedContainer": "Behållaren är stoppad;schemalagd uppdatering hoppades över.",
|
||||
"reasonStoppedContainerHistorical": "Behållaren stoppades vid den schemalagda uppdateringstiden."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "host cron upptäckt",
|
||||
|
||||
@@ -22274,6 +22274,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"'
|
||||
@@ -22315,6 +22316,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(
|
||||
@@ -22708,8 +22726,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
|
||||
@@ -22761,6 +22781,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),
|
||||
@@ -22772,10 +22793,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()
|
||||
@@ -22916,6 +22950,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 ""
|
||||
@@ -22981,6 +23018,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,
|
||||
@@ -23056,12 +23097,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'),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user