mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes
OCI manager Apps - App tab: containers installed from an OCI image are identified from their installation record; the application and image versions are shown and an update is detected by image digest; repository link; Refresh data. - Updates tab for OCI containers: Update and Recreate run the same flow as the OCI menu in the Monitor terminal; the pre-update backup can be kept in a backup storage; scheduled image updates with an optional minimum age. - Logs tab: console output of the application, kept on the host (lxc.console.logfile + logrotate) and followed live. - The Proxmox console opens a shell (cmode: shell) when the image has one. - A damaged image download is fetched again before failing. - Multi-container applications open at their LAN address; volume mount points on block storage report their usage. Monitor - Proxmox notifications are delivered to a loopback-only HTTP listener when HTTPS is enabled, so they no longer fail certificate verification. - Log persistence counts recurring patterns only; an ended burst is not reported as persistent and its warning clears on its own (#386). - Proxmox notification config backups are deduplicated and capped at three. - The update icon on the Apps page opens the container on its Updates tab. - Version 1.2.6.2-beta and its release notes in every Monitor language. Docs - OCI manager Apps and Audit & Report rebuilt as per-page message files, with a new page for OCI containers in the Monitor. - Seven pages fixed where rich-text tags were missing from t.rich. Translations - Spanish fixes across the OCI engine, the Monitor and the TUI menus. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
386d33df6e
commit
4437a671d2
@@ -678,6 +678,36 @@ def _warmup_lxc_ip_cache() -> int:
|
||||
return count
|
||||
|
||||
|
||||
def _lxc_isolated_ips(vmid):
|
||||
"""Static addresses on interfaces that have no way out of the host.
|
||||
|
||||
lxc-info lists a container's addresses without saying which interface
|
||||
carries each, so a container with a second network leg could be offered
|
||||
at an address nobody can open: a ProxMenux stack wires its members through
|
||||
a private bridge, and Paperless-ngx answered on 10.77.0.30 instead of its
|
||||
LAN address. The interface a reader reaches is the one with a route out —
|
||||
DHCP, or a static address with a gateway. A static address with no gateway
|
||||
is local to the host, and is kept only as a last resort.
|
||||
"""
|
||||
try:
|
||||
with open(f"/etc/pve/lxc/{int(vmid)}.conf", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
except (OSError, ValueError):
|
||||
return set()
|
||||
isolated = set()
|
||||
for line in text.splitlines():
|
||||
if line.startswith("["):
|
||||
break # snapshot sections describe past states, not this one
|
||||
match = re.match(r"net\d+:\s*(.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
options = dict(part.split("=", 1) for part in match.group(1).split(",") if "=" in part)
|
||||
address = options.get("ip", "")
|
||||
if address and address not in ("dhcp", "manual") and "gw" not in options:
|
||||
isolated.add(address.split("/", 1)[0])
|
||||
return isolated
|
||||
|
||||
|
||||
def get_lxc_ip_from_lxc_info(vmid):
|
||||
"""Get LXC IP addresses using lxc-info command (for DHCP containers)
|
||||
Returns a dict with all IPs and classification"""
|
||||
@@ -705,6 +735,9 @@ def get_lxc_ip_from_lxc_info(vmid):
|
||||
else:
|
||||
# Real network IPs (192.168.x.x, 10.x.x.x, etc.)
|
||||
real_ips.append(ip)
|
||||
isolated = _lxc_isolated_ips(vmid)
|
||||
if isolated:
|
||||
real_ips.sort(key=lambda ip: ip in isolated)
|
||||
|
||||
return {
|
||||
'all_ips': ips,
|
||||
@@ -3369,7 +3402,7 @@ def get_available_updates():
|
||||
_system_info_cache['available_updates_stamp'] = stamp
|
||||
return available_updates
|
||||
|
||||
# AGREGANDO FUNCIÓN PARA PARSEAR PROCESOS DE INTEL_GPU_TOP (SIN -J)
|
||||
# Parse intel_gpu_top process output without -J.
|
||||
def get_intel_gpu_processes_from_text():
|
||||
"""Parse processes from intel_gpu_top text output (more reliable than JSON)"""
|
||||
try:
|
||||
@@ -3384,7 +3417,7 @@ def get_intel_gpu_processes_from_text():
|
||||
bufsize=1
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# intel_gpu_top no está instalado, retornar lista vacía
|
||||
# intel_gpu_top is not installed; return an empty list
|
||||
return []
|
||||
|
||||
# Wait 2 seconds for intel_gpu_top to collect data
|
||||
@@ -5519,7 +5552,7 @@ def _get_proxmox_storage_uncached():
|
||||
for resource in resources:
|
||||
node = resource.get('node', '')
|
||||
|
||||
# Filtrar solo storage del nodo local
|
||||
# Keep only the local node storage
|
||||
if node != local_node:
|
||||
# print(f"[v0] Skipping storage {resource.get('storage')} from remote node: {node}")
|
||||
pass
|
||||
@@ -5538,7 +5571,7 @@ def _get_proxmox_storage_uncached():
|
||||
pass
|
||||
continue
|
||||
|
||||
# No filtrar storages no disponibles - mantenerlos para mostrar errores
|
||||
# Keep unavailable storages so their errors stay visible
|
||||
# Calcular porcentaje
|
||||
percent = (used / total * 100) if total > 0 else 0.0
|
||||
|
||||
@@ -7832,7 +7865,7 @@ def get_detailed_gpu_info(gpu):
|
||||
else:
|
||||
# print(f"[v0] WARNING: No valid JSON objects found", flush=True)
|
||||
pass
|
||||
# CHANGE: Evitar bloqueo al leer stderr - usar communicate() con timeout
|
||||
# communicate() with a timeout: reading stderr directly blocks
|
||||
try:
|
||||
# Use communicate() with timeout instead of read() to avoid blocking
|
||||
_, stderr_output = process.communicate(timeout=0.5)
|
||||
@@ -8312,8 +8345,7 @@ def get_detailed_gpu_info(gpu):
|
||||
# print(f"[v0] Parsing fdinfo with {len(fdinfo)} entries", flush=True)
|
||||
pass
|
||||
|
||||
# CHANGE: Corregir parseo de fdinfo con estructura anidada
|
||||
# fdinfo es un diccionario donde las claves son los PIDs (como strings)
|
||||
# fdinfo is nested: the keys are the PIDs, as strings
|
||||
for pid_str, proc_data in fdinfo.items():
|
||||
try:
|
||||
process_info = {
|
||||
@@ -13764,6 +13796,13 @@ def api_vm_apps_suggestions(vmid):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/apps/adguard-setup', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_apps_adguard_setup(vmid):
|
||||
import lxc_apps
|
||||
return jsonify({'available': lxc_apps.oci_adguard_setup_available(vmid)})
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/docker/inventory', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_docker_inventory(vmid):
|
||||
@@ -14113,6 +14152,8 @@ def _lxc_update_target_labels(
|
||||
label = 'Applications'
|
||||
elif target == 'docker-engine':
|
||||
label = 'Docker Engine'
|
||||
elif target == 'oci_image':
|
||||
label = 'OCI image'
|
||||
elif target.startswith('app:'):
|
||||
app_item = apps.get(target.split(':', 1)[1]) or {}
|
||||
label = str(app_item.get('name') or 'Application')
|
||||
@@ -15077,7 +15118,7 @@ def api_vms_modal_cache_all():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# CHANGE: Modificar el endpoint para incluir la información completa de IPs
|
||||
# The endpoint returns the complete IP information.
|
||||
@app.route('/api/vms/<int:vmid>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_vm_config(vmid):
|
||||
@@ -15215,6 +15256,38 @@ def api_lxc_mount_points_runtime(vmid):
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/lxc/<int:vmid>/console-log', methods=['GET'])
|
||||
@require_auth
|
||||
def api_lxc_console_log(vmid):
|
||||
"""Console output of a native OCI container. Never cached: the first
|
||||
request returns the last `lines` lines, later ones pass back `offset`
|
||||
and `inode` to receive only what was appended since."""
|
||||
try:
|
||||
import oci_console_logs
|
||||
except ImportError as e:
|
||||
return jsonify({"ok": False, "error": f"helper unavailable: {e}"}), 503
|
||||
try:
|
||||
lines = request.args.get('lines', default=200, type=int)
|
||||
offset = request.args.get('offset', type=int)
|
||||
inode = request.args.get('inode', type=int)
|
||||
return jsonify(oci_console_logs.read(vmid, lines=lines, offset=offset, inode=inode))
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/lxc/<int:vmid>/oci-instance', methods=['GET'])
|
||||
@require_auth
|
||||
def api_lxc_oci_instance(vmid):
|
||||
"""Whether the container was installed by OCI manager Apps, its stack,
|
||||
host directories and pending operation, read from its record. Never
|
||||
cached; it also says whether the console log exists (Logs tab)."""
|
||||
try:
|
||||
import oci_instance_info
|
||||
return jsonify(oci_instance_info.info(vmid))
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/logs', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_logs(vmid):
|
||||
@@ -22161,6 +22234,41 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
}
|
||||
|
||||
requested_target = sched.get("target") or "both"
|
||||
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.
|
||||
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()
|
||||
if sched.get('backup') and storage:
|
||||
command += ['--keep-backup', storage]
|
||||
if sched.get('acknowledge_external_data'):
|
||||
command.append('--acknowledge-external-data')
|
||||
delay = int(sched.get('release_delay_days') or 0)
|
||||
if delay > 0:
|
||||
command += ['--min-image-age-days', str(delay)]
|
||||
try:
|
||||
proc = subprocess.run(command, stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||
timeout=4 * 3600, env=dict(os.environ, TERM='dumb'))
|
||||
except subprocess.TimeoutExpired:
|
||||
reasons.append('the OCI image update did not finish in time')
|
||||
return finish('failure', 'oci_image', [])
|
||||
output = re.sub(r'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[()][A-Z0-9]|\r', '', (proc.stdout or '') + (proc.stderr or ''))
|
||||
_append_lxc_update_log(log_path, output)
|
||||
if proc.returncode == 0:
|
||||
return finish('success', 'oci_image', ['oci_image'])
|
||||
if proc.returncode == 5:
|
||||
deferred_targets.append('oci_image')
|
||||
reasons.append(f'the new image is younger than {delay} days')
|
||||
return finish('deferred', 'oci_image', [])
|
||||
if proc.returncode == 4:
|
||||
reasons.append('the container has changes made outside ProxMenux; review them in OCI manager Apps')
|
||||
return finish('skipped', 'oci_image', [])
|
||||
if proc.returncode == 3:
|
||||
reasons.append('another OCI operation was running')
|
||||
return finish('skipped', 'oci_image', [])
|
||||
reasons.append('the OCI image update failed; the previous installation is restored when the update started')
|
||||
return finish('failure', 'oci_image', [])
|
||||
if not os.path.isfile(_APPLY_UPDATES_SCRIPT):
|
||||
reasons.append('update runner is not installed')
|
||||
return finish('skipped', requested_target, [])
|
||||
@@ -22744,6 +22852,18 @@ if __name__ == '__main__':
|
||||
print(f"[ProxMenux] SSL config error, falling back to HTTP: {e}")
|
||||
ssl_ctx = None
|
||||
|
||||
# With HTTPS on, PVE cannot validate the Monitor certificate against the
|
||||
# system CA store, so its webhook is delivered to this plain-HTTP
|
||||
# listener instead. It binds 127.0.0.1 only and answers nothing but the
|
||||
# webhook route.
|
||||
from flask_notification_routes import WEBHOOK_LOOPBACK_PORT
|
||||
|
||||
def _webhook_loopback_app(environ, start_response):
|
||||
if environ.get('PATH_INFO') == '/api/notifications/webhook':
|
||||
return app(environ, start_response)
|
||||
start_response('404 Not Found', [('Content-Type', 'text/plain')])
|
||||
return [b'Not found']
|
||||
|
||||
# Use gevent for SSL+WebSocket support, or fallback to Flask dev server
|
||||
gevent_available = False
|
||||
ssl_loaded = False
|
||||
@@ -22842,11 +22962,26 @@ if __name__ == '__main__':
|
||||
ssl_context=ssl_context
|
||||
)
|
||||
gevent_available = True
|
||||
try:
|
||||
webhook_server = pywsgi.WSGIServer(
|
||||
('127.0.0.1', WEBHOOK_LOOPBACK_PORT), _webhook_loopback_app, log=None)
|
||||
webhook_server.start()
|
||||
print(f"[ProxMenux] PVE webhook listener on 127.0.0.1:{WEBHOOK_LOOPBACK_PORT}")
|
||||
except Exception as _e:
|
||||
print(f"[ProxMenux] WARN: PVE webhook listener could not start on "
|
||||
f"127.0.0.1:{WEBHOOK_LOOPBACK_PORT} ({_e}); PVE notifications will not arrive", flush=True)
|
||||
server.serve_forever()
|
||||
except ImportError as e:
|
||||
print(f"[ProxMenux] gevent not available ({e})")
|
||||
# Fallback: Flask dev server with SSL - flask-sock handles WebSockets
|
||||
ssl_context = auth_manager.create_reloadable_ssl_context(ssl_cert, ssl_key)
|
||||
try:
|
||||
from werkzeug.serving import make_server
|
||||
_webhook_srv = make_server('127.0.0.1', WEBHOOK_LOOPBACK_PORT, _webhook_loopback_app, threaded=True)
|
||||
threading.Thread(target=_webhook_srv.serve_forever, daemon=True).start()
|
||||
print(f"[ProxMenux] PVE webhook listener on 127.0.0.1:{WEBHOOK_LOOPBACK_PORT}")
|
||||
except Exception as _e:
|
||||
print(f"[ProxMenux] WARN: PVE webhook listener could not start ({_e})", flush=True)
|
||||
print("[ProxMenux] Starting Flask server with SSL (using flask-sock for WebSockets)...")
|
||||
app.run(host='::', port=8008, debug=False, ssl_context=ssl_context, threaded=True)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user