Merge branch 'develop' into feature/zram-memory-profile

This commit is contained in:
MacRimi
2026-10-16 17:27:04 +02:00
79 changed files with 5421 additions and 764 deletions
+1
View File
@@ -147,6 +147,7 @@ cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
+164 -3
View File
@@ -6729,6 +6729,11 @@ def _get_lxc_update_status_map() -> dict:
'managed_oci_app': it.get('_managed_oci_app'),
'os_family': it.get('_os_family'),
}
if os.path.exists(f'/run/lock/proxmenux-lxc-check-invalid-{int(vmid)}'):
# Read-only projection: a failed/pending explicit check must not
# re-expose a former positive count after a persistence failure.
out[str(vmid)].update(available=False, count=0, packages=[],
error='Latest explicit package check is running or failed; check again')
return out
@@ -6843,10 +6848,15 @@ def get_proxmox_vms():
continue
vm_type = 'lxc' if resource.get('type') == 'lxc' else 'qemu'
# The PVE summary can lag behind a completed LXC task.
# Use the same direct local cgroup check as the detail
# route; never resurrect a stopped CT from that summary.
vm_status = (_fast_guest_status(resource.get('vmid'), vm_type)
if vm_type == 'lxc' else resource.get('status', 'unknown'))
vm_data = {
'vmid': resource.get('vmid'),
'name': resource.get('name', f"VM-{resource.get('vmid')}"),
'status': resource.get('status', 'unknown'),
'status': vm_status,
'type': vm_type,
'cpu': resource.get('cpu', 0),
'mem': resource.get('mem', 0),
@@ -6901,7 +6911,7 @@ def get_proxmox_vms():
# detail-aware clients. The cache is still warmed
# at startup and invalidated by LXC lifecycle events.
vm_data['ips'] = []
if resource.get('status') == 'running':
if vm_data['status'] == 'running':
lxc_ip_info = _get_lxc_ip_info_cached(resource.get('vmid'))
lxc_ips = _normalise_lxc_ips(lxc_ip_info)
vm_data['ips'] = lxc_ips
@@ -13676,19 +13686,57 @@ def api_lxc_updates_check(vmid):
'retry_after': retry}), 429, {'Retry-After': str(retry)}
_lxc_update_check_attempts[vmid] = now
_lxc_update_check_inflight.add(vmid)
operation_lock = None
try:
import lxc_check_job
operation_lock = lxc_check_job.acquire(vmid)
item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False)
if item is None:
return jsonify({'error': 'No supported running LXC detected'}), 409
_vm_cache_invalidate(vmid, _lxc_os_info_cache)
return jsonify({'vmid': vmid, 'update_check': _get_lxc_update_status_map().get(str(vmid))})
except BlockingIOError:
return jsonify({'error': 'Another container operation is running'}), 409
except Exception as exc:
return jsonify({'error': str(exc)}), 500
finally:
if operation_lock is not None:
operation_lock.close()
with _lxc_update_check_lock:
_lxc_update_check_inflight.discard(vmid)
@app.route('/api/vms/<int:vmid>/updates/refresh-check', methods=['POST'])
@require_auth
def api_lxc_updates_refresh_check(vmid):
"""Explicit index refresh/check for a running CT; never installs."""
import lxc_check_job
try:
result, code = lxc_check_job.start(vmid)
if code == 429:
return jsonify({'error': 'Check already running or recently requested',
'retry_after': 60}), 429, {'Retry-After': '60'}
return jsonify(result), code
except BlockingIOError:
return jsonify({'error': 'Another container operation is running'}), 409
except (OSError, RuntimeError, ValueError) as exc:
return jsonify({'error': str(exc)}), 409
@app.route('/api/vms/<int:vmid>/updates/refresh-check/<job_id>', methods=['GET'])
@require_auth
def api_lxc_updates_refresh_check_status(vmid, job_id):
import lxc_check_job
result = lxc_check_job.get(vmid, job_id)
if result is None:
return jsonify({'error': 'Check result not found; check again'}), 404
if result['state'] != 'running':
_vm_cache_invalidate(vmid, _lxc_os_info_cache)
_pvesh_cache['cluster_resources_vm_time'] = 0
result['update_check'] = _get_lxc_update_status_map().get(str(vmid))
return jsonify(result)
# ─── LXC Update Detection toggle ────────────────────────────────────────────
# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt
# list --upgradable` scan entirely. The Notifications section keeps its own
@@ -14265,6 +14313,7 @@ def api_vm_apps_dismiss(vmid):
# ─── LXC update finalization ────────────────────────────────────────────────
_LXC_APPLY_UPDATES_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
_LXC_INSTALL_CHECKED_OS_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
_LXC_UPDATE_RUN_ID_RE = re.compile(r'^[A-Za-z0-9._:-]{1,128}$')
_LXC_UPDATE_TARGET_RE = re.compile(r'^[A-Za-z0-9._:-]{1,180}$')
from lxc_update_finalization import lock as _lxc_update_finalization_lock, runs as _lxc_update_finalizations
@@ -14718,7 +14767,9 @@ def _finalize_lxc_update(
def _terminal_lxc_update_completed(*, script_path, params, exit_code, duration_seconds):
if os.path.realpath(script_path) != os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT):
if os.path.realpath(script_path) not in {
os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT),
os.path.realpath(_LXC_INSTALL_CHECKED_OS_SCRIPT)}:
return
run_id = _normalise_lxc_update_run_id(params.get('RUN_ID'), create=False)
if not run_id:
@@ -15831,6 +15882,8 @@ def api_vm_config_set(vmid):
@require_auth
def api_vm_control(vmid):
"""Control VM/LXC (start, stop, shutdown, reboot)"""
operation_lock = None
task_identity_confirmed = False
try:
data = request.get_json()
action = data.get('action') # start, stop, shutdown, reboot
@@ -15853,6 +15906,13 @@ def api_vm_control(vmid):
vm_type = 'lxc' if vm_info.get('type') == 'lxc' else 'qemu'
node = vm_info.get('node', 'pve')
if vm_type == 'lxc':
import lxc_check_job
try:
operation_lock = lxc_check_job.acquire(vmid)
except BlockingIOError:
return jsonify({'error': 'Another container operation is running'}), 409
lxc_check_job.begin_control(operation_lock, node)
# Execute action
control_result = subprocess.run(
@@ -15860,6 +15920,37 @@ def api_vm_control(vmid):
capture_output=True, text=True, timeout=30)
if control_result.returncode == 0:
import re
# pvesh can print progress lines before the task identity.
# The final non-empty line is the only task associated with
# this request; accepting a different log line would make the
# frontend poll an unrelated guest operation.
lines = [line.strip().strip('"') for line in
(control_result.stdout or '').splitlines() if line.strip()]
task = lines[-1] if lines else ''
task_types = [f"{'vz' if vm_type == 'lxc' else 'qm'}{action}"]
if action == 'start':
task_types.append('hastart')
elif action in ('stop', 'shutdown'):
# Proxmox 9 routes HA shutdown through hastop for both
# LXC and QEMU guests.
task_types.append('hastop')
task_pattern = (r'UPID:[\w.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:'
r'[0-9A-Fa-f]+:(?:' + '|'.join(task_types) +
rf'):{vmid}:[\w.@!-]+:')
if not re.fullmatch(task_pattern, task):
# A successful process exit without a verifiable task
# cannot be polled. Do not leave a task-less control
# marker that would block this CT forever.
if operation_lock is not None:
lxc_check_job.cancel_control(operation_lock)
return jsonify({
'success': False,
'vmid': vmid,
'action': action,
'error': 'Control task was not confirmed by Proxmox',
}), 502
task_identity_confirmed = True
# Invalidate VM resources cache so the next /api/vms call
# returns fresh status instead of the pre-action snapshot.
_pvesh_cache['cluster_resources_vm_time'] = 0
@@ -15867,13 +15958,21 @@ def api_vm_control(vmid):
# (lxc_ip_info, os_info) — a start/stop flips those, so
# drop the modal caches for this vmid.
_vm_cache_invalidate(vmid)
# The frontend waits for this exact task rather than assuming
# that accepting the request means the action completed.
if operation_lock is not None:
lxc_check_job.retain_control(vmid, task, operation_lock)
operation_lock = None
return jsonify({
'success': True,
'vmid': vmid,
'action': action,
'task': task,
'message': f'Successfully executed {action} on {vm_info.get("name")}'
})
else:
if operation_lock is not None:
lxc_check_job.cancel_control(operation_lock)
# `pvesh` failed → fire the matching vm_fail / ct_fail
# notification so the user gets paged on their channels
# too, not just an in-dashboard alert. Previously this
@@ -15925,8 +16024,70 @@ def api_vm_control(vmid):
else:
return jsonify({'error': 'Failed to get VM details'}), 500
except Exception as e:
# A transport error happens before we have an identity that can be
# recovered later. Leaving the pre-submission marker would otherwise
# make the CT permanently busy after one timed-out pvesh invocation.
if operation_lock is not None and not task_identity_confirmed:
try:
lxc_check_job.cancel_control(operation_lock)
except Exception:
pass
return jsonify({'error': str(e)}), 500
finally:
if operation_lock is not None:
operation_lock.close()
@app.route('/api/vms/<int:vmid>/control-task/<path:task>', methods=['GET'])
@require_auth
def api_vm_control_task(vmid, task):
"""Read only the power-control task bound to this guest and node."""
import re
import json
if not re.fullmatch(r'UPID:[\w.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:'
r'(?:(?:vz|qm)(?:start|stop|shutdown|reboot)|ha(?:start|stop)):'
r'[0-9]+:[\w.@!-]+:', task):
return jsonify({'error': 'Invalid control task'}), 400
parts = task.split(':')
resource = next((item for item in get_cached_pvesh_cluster_resources_vm() or []
if item.get('vmid') == vmid), None)
if resource is None:
return jsonify({'error': 'VM/LXC not found'}), 404
vm_type = 'lxc' if resource.get('type') == 'lxc' else 'qemu'
node = resource.get('node', 'pve')
prefix = 'vz' if vm_type == 'lxc' else 'qm'
allowed_task_types = {f'{prefix}{action}' for action in
('start', 'stop', 'shutdown', 'reboot')}
allowed_task_types.update(('hastart', 'hastop'))
if (parts[1] != node or parts[6] != str(vmid)
or parts[5] not in allowed_task_types):
return jsonify({'error': 'Control task does not belong to this guest'}), 400
try:
result = subprocess.run(['pvesh', 'get', f'/nodes/{node}/tasks/{task}/status',
'--output-format', 'json'],
capture_output=True, text=True, timeout=5)
if result.returncode != 0:
return jsonify({'error': 'Control task status unavailable'}), 502
status = json.loads(result.stdout)
if not isinstance(status, dict) or status.get('status') not in ('running', 'stopped'):
return jsonify({'error': 'Invalid control task status'}), 502
payload = {'status': status['status'], 'exitstatus': status.get('exitstatus')}
if status['status'] == 'stopped' and status.get('exitstatus') == 'OK':
current = subprocess.run(['pvesh', 'get', f'/nodes/{node}/{vm_type}/{vmid}/status/current',
'--output-format', 'json'],
capture_output=True, text=True, timeout=5)
if current.returncode != 0:
return jsonify({'error': 'Guest status unavailable'}), 502
guest = json.loads(current.stdout)
if not isinstance(guest, dict) or guest.get('status') not in ('running', 'stopped'):
return jsonify({'error': 'Invalid guest status'}), 502
payload['guest_status'] = guest['status']
_pvesh_cache['cluster_resources_vm_time'] = 0
_vm_cache_invalidate(vmid)
return jsonify(payload)
except (ValueError, subprocess.TimeoutExpired):
return jsonify({'error': 'Control task status unavailable'}), 502
@app.route('/api/vms/<int:vmid>/description', methods=['PUT'])
@app.route('/api/vms/<int:vmid>/config', methods=['PUT']) # legacy alias
@require_auth
+301
View File
@@ -0,0 +1,301 @@
"""Explicit check-only jobs; the shared updater lock protects each CT.
Importing or reading a job never probes a guest. Only start() may launch work
for an already-running CT; package indexes are never refreshed by starting one.
"""
import datetime
import fcntl
import json
import os
import re
from pathlib import Path
import subprocess
import threading
import time
import uuid
import managed_installs
import lxc_update_observation
LOCK_DIR = Path(os.environ.get('PROXMENUX_LOCK_DIR', '/run/lock'))
CONF_DIR = Path('/etc/pve/lxc')
OCI_REGISTRY = Path('/usr/local/share/proxmenux/oci/installed.json')
_lock = threading.RLock()
_jobs = {}
_attempts = {}
COOLDOWN = 60
JOB_TTL = 3600
CONTROL_TASK_WAIT_SECONDS = 180
def acquire(vmid):
path = LOCK_DIR / f'proxmenux-lxc-update-{int(vmid)}.lock'
handle = os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'r+')
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
check_control_task(vmid, handle)
return handle
except BaseException:
handle.close()
raise
def _invalid_marker(vmid):
return LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}'
def _acquire_invalid_marker_guard(vmid):
path = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}.guard'
handle = os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'r+')
try:
fcntl.flock(handle, fcntl.LOCK_EX)
return handle
except BaseException:
handle.close()
raise
def _mark_check_invalid(vmid):
handle = _acquire_invalid_marker_guard(vmid)
try:
_invalid_marker(vmid).touch(mode=0o600)
finally:
handle.close()
def clear_invalid_marker(vmid, *, check_started_ns=None):
"""Clear an obsolete failed-check marker without hiding a newer run."""
try:
handle = _acquire_invalid_marker_guard(vmid)
except OSError:
return False
try:
marker = _invalid_marker(vmid)
try:
if (check_started_ns is not None
and marker.stat().st_mtime_ns > check_started_ns):
return False
marker.unlink()
return True
except FileNotFoundError:
# A concurrent successful check may already have published the
# valid state; there is no stale marker left to clear.
return True
except OSError:
return False
finally:
handle.close()
def _write_control(handle, record):
handle.seek(0)
if record is not None:
handle.write(json.dumps(record))
handle.flush()
# Do not erase the old reservation before the replacement was written.
# A partial/failed write stays non-empty and therefore fails closed.
handle.truncate(handle.tell())
def begin_control(handle, node):
"""Persist uncertainty before submission, including across Monitor restart."""
if not isinstance(node, str) or not re.fullmatch(r'[\w.-]+', node):
raise ValueError('Invalid control node')
_write_control(handle, {'node': node, 'task': None})
def cancel_control(handle):
"""Clear a request for which no recoverable task identity was recorded."""
_write_control(handle, None)
def check_control_task(vmid, handle=None):
"""Called under the shared flock, also by the shell update runner.
No record means no probe. A restart releases OS file descriptors, but the
record still forbids a new operation until PVE confirms task termination.
Unknown submission/status cannot safely be treated as completion.
"""
if handle is None:
with open(LOCK_DIR / f'proxmenux-lxc-update-{int(vmid)}.lock', 'r+') as stream:
return check_control_task(vmid, stream)
handle.seek(0)
raw = handle.read(1025)
if not raw:
return
try:
record = json.loads(raw)
node, task = record['node'], record['task']
if not isinstance(node, str) or not re.fullmatch(r'[\w.-]+', node):
raise ValueError('Invalid node')
if (not isinstance(task, str)
or not re.fullmatch(r'UPID:[\w.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:'
r'[0-9A-Fa-f]+:(?:vz(?:start|stop|shutdown|reboot)'
r'|ha(?:start|stop)):'
r'[0-9]+:[\w.@!-]+:', task)
or task.split(':')[1] != node or task.split(':')[6] != str(vmid)):
raise ValueError('Unconfirmed control task')
result = subprocess.run(['pvesh', 'get', f'/nodes/{node}/tasks/{task}/status',
'--output-format', 'json'],
capture_output=True, text=True, timeout=5)
status = json.loads(result.stdout) if result.returncode == 0 else None
if (not isinstance(status, dict) or status.get('status') != 'stopped'
or not isinstance(status.get('exitstatus'), str) or not status['exitstatus']):
raise ValueError('Control task completion not confirmed')
except (ValueError, KeyError, TypeError, OSError, subprocess.TimeoutExpired) as exc:
raise BlockingIOError('Container control completion is not confirmed') from exc
_write_control(handle, None)
def _wait_control(vmid, reservation):
deadline = time.monotonic() + CONTROL_TASK_WAIT_SECONDS
try:
while time.monotonic() < deadline:
try:
check_control_task(vmid, reservation)
return
except BlockingIOError:
# Keep the known task record for recovery after this bounded
# waiter exits; a later caller asks Proxmox to verify it.
remaining = deadline - time.monotonic()
if remaining > 0:
time.sleep(min(2, remaining))
finally:
reservation.close()
def retain_control(vmid, task, reservation):
reservation.seek(0)
record = json.loads(reservation.read(1025))
record['task'] = task
_write_control(reservation, record)
# Reservation ownership transfers only after Thread.start succeeds.
threading.Thread(target=_wait_control, args=(vmid, reservation), daemon=True).start()
def guard(vmid):
if not managed_installs._lxc_updates_detection_enabled():
raise RuntimeError('LXC update detection is disabled')
# Fail closed if current configuration cannot be read. Never infer OCI
# eligibility from an old OS hint or from a failed config read.
config = (CONF_DIR / f'{int(vmid)}.conf').read_text()
# The normal collector tolerates broken OCI metadata. An action must
# instead fail closed: an unreadable registry is not an empty registry.
if OCI_REGISTRY.exists():
registry = json.loads(OCI_REGISTRY.read_text())
instances = registry.get('instances')
if not isinstance(instances, dict) or any(not isinstance(item, dict) for item in instances.values()):
raise RuntimeError('OCI registry could not be verified')
if any(str(item.get('vmid')) == str(vmid) for item in instances.values()):
raise RuntimeError('OCI containers require their own updater')
if any(line.lstrip().startswith(managed_installs._OCI_LXC_MARKERS)
for line in config.splitlines()) or str(vmid) in managed_installs._get_oci_managed_vmids():
raise RuntimeError('OCI containers require their own updater')
# Retain the independent registered-OCI boundary if its collector is
# temporarily unavailable. These hints can only forbid an action.
if any(str(item.get('_vmid')) == str(vmid) and item.get('_managed_oci_app')
for item in managed_installs.get_active_items()):
raise RuntimeError('OCI containers require their own updater')
if any(line.startswith('lock:') for line in config.splitlines()):
raise RuntimeError('Container is locked by Proxmox')
def command(vmid, *args, timeout=30):
result = subprocess.run([managed_installs._PCT_BIN, *args[:1], str(vmid), *args[1:]],
capture_output=True, text=True, timeout=timeout)
if result.returncode:
raise RuntimeError(f'Container {args[0]} failed')
return result.stdout.strip()
def status(vmid):
value = command(vmid, 'status')
if value not in ('status: running', 'status: stopped'):
raise RuntimeError('Container state could not be determined')
return value.split(': ', 1)[1]
def checked_os(vmid):
"""Installation precondition; called under the updater lock before start."""
guard(vmid)
if (LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}').exists():
raise RuntimeError('Latest check failed or is still running')
item = next((item for item in managed_installs.get_active_items()
if item.get('id') == f'lxc:{int(vmid)}'), {})
observation = item.get('update_check') or {}
checked = datetime.datetime.fromisoformat(observation.get('last_check') or '')
if checked.tzinfo is None:
checked = checked.replace(tzinfo=datetime.timezone.utc)
age = (datetime.datetime.now(datetime.timezone.utc) - checked).total_seconds()
if (not 0 <= age < 172800 or observation.get('error')
or observation.get('available') is not True
or type(observation.get('_count')) is not int or observation['_count'] <= 0):
raise RuntimeError('Check OS updates successfully before installation')
def run(vmid, job_id, reservation):
error = None
check_started = False
try:
_mark_check_invalid(vmid)
guard(vmid)
if status(vmid) != 'running':
raise RuntimeError('Container is stopped; start it first')
check_started = True
observation = lxc_update_observation.capture(vmid, job_id, refresh_indexes=True)
if not managed_installs.store_lxc_update_observation(vmid, observation):
raise RuntimeError(observation['result'].get('error') or 'Package check failed')
lxc_update_observation.discard(vmid, job_id)
except Exception as exc:
error = str(exc)
# A failed package operation must block installation. A state change
# before the worker runs did not check or alter packages, so keep the
# last verified observation but leave the invalid marker in place.
if check_started:
try:
managed_installs.store_lxc_update_observation(vmid, None)
except Exception:
error += '; package result could not be persisted'
finally:
if not error:
if not clear_invalid_marker(vmid):
error = 'Successful check could not be published for installation'
reservation.close()
with _lock:
_jobs[job_id].update(state='failed' if error else 'complete', error=error)
def start(vmid):
now = time.monotonic()
with _lock:
for key in list(_jobs):
if _jobs[key]['state'] != 'running' and now - _jobs[key]['created'] >= JOB_TTL:
del _jobs[key]
last = _attempts.get(vmid)
if (any(job['vmid'] == vmid and job['state'] == 'running' for job in _jobs.values())
or last is not None and now - last < COOLDOWN):
return None, 429
reservation = acquire(vmid)
try:
guard(vmid)
if status(vmid) != 'running':
raise RuntimeError('Container is stopped; start it first')
job_id = uuid.uuid4().hex
_jobs[job_id] = dict(vmid=vmid, state='running', error=None, created=now)
_attempts[vmid] = now
threading.Thread(target=run, args=(vmid, job_id, reservation), daemon=False).start()
except BaseException:
if 'job_id' in locals():
_jobs.pop(job_id, None)
_attempts.pop(vmid, None)
reservation.close()
raise
return dict(job_id=job_id, vmid=vmid, state='running'), 202
def get(vmid, job_id):
with _lock:
job = _jobs.get(job_id)
if job is None or job['vmid'] != vmid:
return None
return {key: value for key, value in job.items() if key != 'created'}
+29 -1
View File
@@ -1796,6 +1796,22 @@ def _store_update_result(item: dict, result: dict) -> None:
item["update_check"][extra_key] = result[extra_key]
def _clear_lxc_check_invalid_marker(vmid: int, check_started_ns: int) -> bool:
"""Publish a persisted newer LXC check to the installation gate.
The marker is created by the explicit refresh worker. Its own lock also
protects this cleanup against a later refresh starting while a scheduled
or lifecycle check is still completing.
"""
try:
import lxc_check_job
return lxc_check_job.clear_invalid_marker(
vmid, check_started_ns=check_started_ns)
except Exception as exc:
print(f'[ProxMenux] could not clear stale LXC check marker for {vmid}: {exc}')
return False
def refresh_oci_app(app_id: str) -> Optional[dict]:
"""Read one OCI-managed application again and store what it reports.
@@ -1834,6 +1850,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
_nvidia_cache["fetched_at"] = 0
updates_available: list[dict] = []
successful_lxc_checks: list[tuple[int, int]] = []
with _lock:
reg = _read_registry()
items = reg.get("items", [])
@@ -1854,6 +1871,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
checker = _CHECKERS.get(it.get("type"))
if not checker:
continue
check_started_ns = time.time_ns() if it.get('type') == 'lxc' else None
try:
result = checker(it)
except Exception as e:
@@ -1863,13 +1881,20 @@ def check_for_updates(force: bool = False) -> list[dict]:
"last_check": _now_iso(), "error": str(e)}
_store_update_result(it, result)
if check_started_ns is not None and result.get('error') is None:
try:
successful_lxc_checks.append((int(it.get('_vmid')), check_started_ns))
except (TypeError, ValueError):
pass
if it["update_check"]["available"]:
updates_available.append(it)
reg["items"] = items
reg["last_check_run"] = _now_iso()
_write_registry(reg)
if _write_registry(reg):
for vmid, check_started_ns in successful_lxc_checks:
_clear_lxc_check_invalid_marker(vmid, check_started_ns)
return updates_available
@@ -1945,6 +1970,7 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di
else:
_merge_detected_entry(target, entry, now)
check_started_ns = time.time_ns()
try:
result = (_check_lxc_updates(target) if refresh_package_cache else
_check_lxc_updates(target, refresh_package_cache=False))
@@ -1961,4 +1987,6 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di
reg["last_targeted_refresh"] = now
if not _write_registry(reg):
raise OSError("could not persist LXC update check")
if result.get('error') is None:
_clear_lxc_check_invalid_marker(target_vmid, check_started_ns)
return dict(target)
@@ -36,6 +36,7 @@ class LxcVmIpInventoryTests(unittest.TestCase):
"_get_lxc_app_watch_map": lambda: {},
"_get_lxc_docker_inventory_map": lambda: {},
"get_proxmox_node_name": lambda: "pve",
"_fast_guest_status": lambda _vmid, _vm_type: "running",
"_get_cached_vm_list_search_notes": lambda _resources, _node: {},
"get_cached_vm_disk": lambda _vmid: None,
"_guest_lifecycle_lock": threading.RLock(),