mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-15 03:06:45 +00:00
New version 1.2.6
Restores AI Assistant support for OpenAI-compatible endpoints on private IPs, loopback and Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies) and surfaces the server's error under the Load button (#325). Aligns the Secure Gateway wizard's Alpine template selection and pct create with the host's real architecture on x86_64 and arm64 (#324). Consolidates changes landing on develop: atomic notification delivery, custom SSH port for Borg remote targets (#236), optional GitHub API token for app version tracking (#306), and richer replication failure notifications.
This commit is contained in:
@@ -2020,6 +2020,20 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None:
|
||||
if guest_type == 'lxc':
|
||||
_invalidate_lxc_ip(guest_id)
|
||||
if action in ('start', 'reboot'):
|
||||
if guest_type == 'lxc':
|
||||
# TaskWatcher already owns the authoritative lifecycle event.
|
||||
# Reuse it to retire a reboot-required warning left by the last
|
||||
# scheduled update instead of adding another polling loop.
|
||||
try:
|
||||
import lxc_apps
|
||||
lxc_apps.clear_schedule_reboot_required(guest_id)
|
||||
_vm_cache_invalidate(guest_id, _vm_schedule_cache)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f'[ProxMenux] could not clear scheduled-update reboot state '
|
||||
f'for CT {guest_id}: {exc}',
|
||||
flush=True,
|
||||
)
|
||||
_schedule_started_guest_refresh(guest_id, guest_type)
|
||||
return
|
||||
if action == 'stop':
|
||||
@@ -13626,6 +13640,42 @@ def api_vm_apps_schedule(vmid):
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/schedule/log', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_apps_schedule_log(vmid):
|
||||
"""Return the bounded tail of the latest scheduled-update log."""
|
||||
try:
|
||||
import lxc_apps
|
||||
schedule = lxc_apps.get_schedule(vmid) or {}
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'lxc_apps unavailable: {exc}'}), 500
|
||||
name = os.path.basename(str(schedule.get('last_run_log') or ''))
|
||||
if not _LXC_UPDATE_LOG_RE.fullmatch(name) or not name.startswith(f'{vmid}-'):
|
||||
return jsonify({'error': 'no scheduled update log is available'}), 404
|
||||
path = os.path.join(_LXC_UPDATE_LOG_DIR, name)
|
||||
if not os.path.isfile(path):
|
||||
return jsonify({'error': 'scheduled update log was not found'}), 404
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
offset = max(0, size - _LXC_UPDATE_LOG_READ_LIMIT)
|
||||
with open(path, 'rb') as stream:
|
||||
stream.seek(offset)
|
||||
content = stream.read(_LXC_UPDATE_LOG_READ_LIMIT).decode('utf-8', errors='replace')
|
||||
if offset:
|
||||
newline = content.find('\n')
|
||||
if newline >= 0:
|
||||
content = content[newline + 1:]
|
||||
return jsonify({
|
||||
'content': content,
|
||||
'size': size,
|
||||
'truncated': bool(offset),
|
||||
'run_at': schedule.get('last_run_at'),
|
||||
'status': schedule.get('last_run_status'),
|
||||
})
|
||||
except OSError as exc:
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/bulk-update', methods=['GET', 'PUT', 'DELETE'])
|
||||
@require_auth
|
||||
def api_vm_bulk_update(vmid):
|
||||
@@ -14098,6 +14148,8 @@ def _lxc_update_details(
|
||||
after: dict,
|
||||
verification_pending: bool,
|
||||
verification_errors: list[str],
|
||||
reboot_required: bool | None,
|
||||
reboot_packages: list[str],
|
||||
) -> str:
|
||||
lines = [
|
||||
f"Source: {'Scheduled' if source == 'scheduled' else 'Manual'}",
|
||||
@@ -14170,6 +14222,12 @@ def _lxc_update_details(
|
||||
lines.append('Deferred targets: ' + ', '.join(deferred_targets))
|
||||
if reason:
|
||||
lines.append(f'Reason: {reason}')
|
||||
if reboot_required is True:
|
||||
lines.append('Restart required: yes')
|
||||
if reboot_packages:
|
||||
lines.append('Restart-triggering packages: ' + ', '.join(reboot_packages[:12]))
|
||||
elif reboot_required is False:
|
||||
lines.append('Restart required: no')
|
||||
if verification_pending:
|
||||
lines.append('Verification pending until the container is running')
|
||||
for error in verification_errors[:4]:
|
||||
@@ -14194,6 +14252,8 @@ def _finalize_lxc_update(
|
||||
reason: str | None = None,
|
||||
refresh_docker_inventory: bool = False,
|
||||
before_snapshot: dict | None = None,
|
||||
reboot_required: bool | None = None,
|
||||
reboot_packages=None,
|
||||
) -> dict:
|
||||
safe_run_id = _normalise_lxc_update_run_id(run_id)
|
||||
key = (int(vmid), safe_run_id)
|
||||
@@ -14273,6 +14333,8 @@ def _finalize_lxc_update(
|
||||
after=after,
|
||||
verification_pending=verification_pending,
|
||||
verification_errors=verification_errors,
|
||||
reboot_required=reboot_required,
|
||||
reboot_packages=list(reboot_packages or []),
|
||||
)
|
||||
try:
|
||||
notification_manager.emit_event(
|
||||
@@ -14303,6 +14365,8 @@ def _finalize_lxc_update(
|
||||
'verification_pending': verification_pending,
|
||||
'verification_errors': verification_errors,
|
||||
'docker_inventory': docker_inventory,
|
||||
'reboot_required': reboot_required,
|
||||
'reboot_packages': list(reboot_packages or []),
|
||||
}
|
||||
with _lxc_update_finalization_lock:
|
||||
_lxc_update_finalizations[key] = {
|
||||
@@ -21578,6 +21642,97 @@ _DOCKER_ENGINE_INTEGRATED_COMMAND = (
|
||||
'update_docker_engine.py --vmid "$VMID"'
|
||||
)
|
||||
_scheduled_fired_this_minute: set = set()
|
||||
_LXC_UPDATE_LOG_DIR = "/usr/local/share/proxmenux/logs/lxc-updates"
|
||||
_LXC_UPDATE_LOG_RE = re.compile(r'^[1-9][0-9]*-scheduled-[a-f0-9]{32}\.log$')
|
||||
_LXC_UPDATE_LOG_KEEP_PER_CT = 10
|
||||
_LXC_UPDATE_LOG_READ_LIMIT = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def _create_lxc_update_log(vmid: int, run_id: str) -> tuple[str | None, str | None]:
|
||||
"""Create a private, persistent log for one scheduled LXC run."""
|
||||
name = f'{int(vmid)}-{run_id}.log'
|
||||
if not _LXC_UPDATE_LOG_RE.fullmatch(name):
|
||||
return None, None
|
||||
try:
|
||||
os.makedirs(_LXC_UPDATE_LOG_DIR, mode=0o700, exist_ok=True)
|
||||
path = os.path.join(_LXC_UPDATE_LOG_DIR, name)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', errors='replace') as stream:
|
||||
stream.write(f'=== ProxMenux scheduled LXC update — CT {vmid} ===\n')
|
||||
stream.write(f'Run ID: {run_id}\n')
|
||||
stream.write(f'Started: {datetime.now().astimezone().isoformat()}\n\n')
|
||||
return name, path
|
||||
except OSError as exc:
|
||||
print(f'[ProxMenux] scheduler: could not create update log for CT {vmid}: {exc}',
|
||||
flush=True)
|
||||
return None, None
|
||||
|
||||
|
||||
def _append_lxc_update_log(path: str | None, text: str) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, 'a', encoding='utf-8', errors='replace') as stream:
|
||||
stream.write(text)
|
||||
except OSError as exc:
|
||||
print(f'[ProxMenux] scheduler: could not append update log: {exc}', flush=True)
|
||||
|
||||
|
||||
def _prune_lxc_update_logs(vmid: int) -> None:
|
||||
try:
|
||||
candidates = sorted(
|
||||
glob.glob(os.path.join(_LXC_UPDATE_LOG_DIR, f'{int(vmid)}-scheduled-*.log')),
|
||||
key=os.path.getmtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in candidates[_LXC_UPDATE_LOG_KEEP_PER_CT:]:
|
||||
if _LXC_UPDATE_LOG_RE.fullmatch(os.path.basename(path)):
|
||||
os.unlink(path)
|
||||
except OSError as exc:
|
||||
print(f'[ProxMenux] scheduler: update-log retention failed for CT {vmid}: {exc}',
|
||||
flush=True)
|
||||
|
||||
|
||||
def _inspect_lxc_reboot_requirement(
|
||||
vmid: int,
|
||||
*,
|
||||
update_succeeded: bool,
|
||||
restart_requested: bool,
|
||||
originally_running: bool,
|
||||
) -> tuple[bool | None, list[str], str | None]:
|
||||
"""Read Debian's reboot marker without installing extra guest tools."""
|
||||
if update_succeeded and (restart_requested or not originally_running):
|
||||
return False, [], None
|
||||
if _fast_guest_status(vmid, 'lxc') != 'running':
|
||||
return None, [], 'container is not running; reboot marker could not be checked'
|
||||
try:
|
||||
marker = subprocess.run(
|
||||
['/usr/sbin/pct', 'exec', str(vmid), '--',
|
||||
'test', '-f', '/var/run/reboot-required'],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
|
||||
return None, [], str(exc)
|
||||
if marker.returncode == 1:
|
||||
return False, [], None
|
||||
if marker.returncode != 0:
|
||||
return None, [], (marker.stderr or marker.stdout or 'reboot marker check failed').strip()[:300]
|
||||
packages: list[str] = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['/usr/sbin/pct', 'exec', str(vmid), '--',
|
||||
'cat', '/var/run/reboot-required.pkgs'],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
packages = list(dict.fromkeys(
|
||||
line.strip()[:160]
|
||||
for line in result.stdout.splitlines()
|
||||
if line.strip()
|
||||
))[:32]
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return True, packages, None
|
||||
|
||||
|
||||
def _normalise_schedule_targets(sched: dict) -> list[str]:
|
||||
@@ -21908,16 +22063,40 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
"""Run one scheduled update and finalize it through the shared path."""
|
||||
started_at = time.monotonic()
|
||||
run_id = f'scheduled-{uuid.uuid4().hex}'
|
||||
log_name, log_path = _create_lxc_update_log(vmid, run_id)
|
||||
originally_running = _fast_guest_status(vmid, 'lxc') == 'running'
|
||||
requested_targets = _normalise_schedule_targets(sched)
|
||||
targets = list(requested_targets)
|
||||
before = _lxc_update_snapshot(vmid)
|
||||
deferred_targets: list[str] = []
|
||||
reasons: list[str] = []
|
||||
reboot_required: bool | None = None
|
||||
reboot_packages: list[str] = []
|
||||
reboot_check_error: str | None = None
|
||||
|
||||
def finish(status: str, actual_target: str, executed: list[str]) -> dict:
|
||||
reason = '; '.join(dict.fromkeys(value for value in reasons if value)) or None
|
||||
duration_seconds = max(0, int(time.monotonic() - started_at))
|
||||
labels = _lxc_update_target_labels(requested_targets, [], before)
|
||||
footer = [
|
||||
'',
|
||||
'=== ProxMenux result ===',
|
||||
f'Finished: {datetime.now().astimezone().isoformat()}',
|
||||
f'Status: {status}',
|
||||
f'Duration: {duration_seconds}s',
|
||||
]
|
||||
if reason:
|
||||
footer.append(f'Reason: {reason}')
|
||||
if reboot_required is True:
|
||||
footer.append('Restart required: yes')
|
||||
if reboot_packages:
|
||||
footer.append('Restart-triggering packages: ' + ', '.join(reboot_packages))
|
||||
elif reboot_required is False:
|
||||
footer.append('Restart required: no')
|
||||
elif reboot_check_error:
|
||||
footer.append(f'Restart check unavailable: {reboot_check_error}')
|
||||
_append_lxc_update_log(log_path, '\n'.join(footer) + '\n')
|
||||
_prune_lxc_update_logs(vmid)
|
||||
finalization = _finalize_lxc_update(
|
||||
vmid,
|
||||
run_id=run_id,
|
||||
@@ -21935,6 +22114,8 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
value.startswith('docker-') for value in requested_targets
|
||||
),
|
||||
before_snapshot=before,
|
||||
reboot_required=reboot_required,
|
||||
reboot_packages=reboot_packages,
|
||||
)
|
||||
return {
|
||||
'status': status,
|
||||
@@ -21945,6 +22126,9 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
'executed_targets': list(executed),
|
||||
'deferred_targets': list(deferred_targets),
|
||||
'duration_seconds': duration_seconds,
|
||||
'log_name': log_name,
|
||||
'reboot_required': reboot_required,
|
||||
'reboot_packages': list(reboot_packages),
|
||||
'finalization': finalization,
|
||||
}
|
||||
|
||||
@@ -22063,13 +22247,32 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
"1" if any(value.startswith('docker-') for value in requested_targets) else "0"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["bash", _APPLY_UPDATES_SCRIPT],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60 * 60, # 1h hard cap so a stuck run doesn't
|
||||
# block the queue forever
|
||||
if log_path:
|
||||
with open(log_path, 'a', encoding='utf-8', errors='replace') as log_stream:
|
||||
r = subprocess.run(
|
||||
["bash", _APPLY_UPDATES_SCRIPT],
|
||||
env=env,
|
||||
stdout=log_stream,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=60 * 60, # 1h hard cap so a stuck run doesn't
|
||||
# block the queue forever
|
||||
)
|
||||
else:
|
||||
r = subprocess.run(
|
||||
["bash", _APPLY_UPDATES_SCRIPT],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60 * 60,
|
||||
)
|
||||
reboot_required, reboot_packages, reboot_check_error = (
|
||||
_inspect_lxc_reboot_requirement(
|
||||
vmid,
|
||||
update_succeeded=r.returncode == 0,
|
||||
restart_requested=bool(sched.get("restart")),
|
||||
originally_running=originally_running,
|
||||
)
|
||||
)
|
||||
if r.returncode != 0:
|
||||
reasons.append(f'update runner exited with code {r.returncode}')
|
||||
@@ -22078,6 +22281,14 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
return finish('partial', target, targets)
|
||||
return finish('success', target, targets)
|
||||
except subprocess.TimeoutExpired:
|
||||
reboot_required, reboot_packages, reboot_check_error = (
|
||||
_inspect_lxc_reboot_requirement(
|
||||
vmid,
|
||||
update_succeeded=False,
|
||||
restart_requested=False,
|
||||
originally_running=originally_running,
|
||||
)
|
||||
)
|
||||
reasons.append('scheduled update timed out')
|
||||
return finish('failure', target, targets)
|
||||
except Exception as exc:
|
||||
@@ -22131,7 +22342,17 @@ def _scheduler_loop():
|
||||
actual_target = outcome.get('target') or 'both'
|
||||
reason = outcome.get('reason')
|
||||
try:
|
||||
lxc_apps.record_schedule_run(_vmid, status, actual_target, reason)
|
||||
lxc_apps.record_schedule_run(
|
||||
_vmid,
|
||||
status,
|
||||
actual_target,
|
||||
reason,
|
||||
log_name=outcome.get('log_name'),
|
||||
reboot_required=outcome.get('reboot_required'),
|
||||
reboot_packages=outcome.get('reboot_packages'),
|
||||
)
|
||||
_vm_cache_invalidate(_vmid, _vm_schedule_cache)
|
||||
_publish_guest_modal_cache_revision(_vmid)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] scheduler: could not record run for {_vmid}: {e}")
|
||||
print(f"[ProxMenux] scheduler: CT {_vmid} finished with status={status}"
|
||||
|
||||
Reference in New Issue
Block a user