mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Merge branch 'develop' into feature/zram-memory-profile
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user