mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
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>
201 lines
6.6 KiB
Python
201 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Proxmox console of a native OCI container: a shell through lxc-attach.
|
|
|
|
An OCI image starts its own entrypoint as PID 1 and runs no login service, so
|
|
the default `cmode: console` attaches to a tty nothing answers on. `cmode:
|
|
shell` makes Proxmox open `lxc-attach --clear-env` instead: a new process in
|
|
the container's namespaces, with PID 1 and the image untouched. It is the
|
|
equivalent of `docker exec` — root inside that container, without a password,
|
|
for whoever Proxmox lets open its console.
|
|
|
|
lxc-attach runs the shell /etc/passwd gives root, and falls back to /bin/sh
|
|
only when root has no entry at all. An image whose root is set to nologin, or
|
|
that ships no shell, keeps `cmode: console` rather than offering a console
|
|
that closes as soon as it opens.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
NO_LOGIN = ('nologin', 'false')
|
|
LOG_DIR = Path('/var/log/proxmenux/oci')
|
|
LOGROTATE = Path('/etc/logrotate.d/proxmenux-oci')
|
|
# copytruncate, because liblxc keeps the file open for as long as the
|
|
# container runs; moving it away would leave the application writing into the
|
|
# rotated copy. The threshold is checked by the host's daily logrotate run, so
|
|
# it is a rotation threshold, not a hard cap.
|
|
LOGROTATE_POLICY = """/var/log/proxmenux/oci/*.console.log {
|
|
size 10M
|
|
rotate 3
|
|
missingok
|
|
notifempty
|
|
copytruncate
|
|
compress
|
|
}
|
|
"""
|
|
|
|
|
|
def _run(*args: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(args, capture_output=True, text=True, timeout=60)
|
|
|
|
|
|
def _running_pid(vmid: int) -> str | None:
|
|
result = _run('lxc-info', '-n', str(vmid), '-pH')
|
|
pid = result.stdout.strip()
|
|
return pid if result.returncode == 0 and pid.isdigit() else None
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _rootfs(vmid: int):
|
|
"""The container's root filesystem, read from the host.
|
|
|
|
A running container is read through its init process, which needs no
|
|
mount. A stopped one is mounted for the duration of the check and
|
|
unmounted afterwards.
|
|
"""
|
|
pid = _running_pid(vmid)
|
|
if pid:
|
|
yield Path(f'/proc/{pid}/root')
|
|
return
|
|
mounted = _run('pct', 'mount', str(vmid))
|
|
if mounted.returncode != 0:
|
|
yield None
|
|
return
|
|
try:
|
|
yield Path(f'/var/lib/lxc/{vmid}/rootfs')
|
|
finally:
|
|
_run('pct', 'unmount', str(vmid))
|
|
|
|
|
|
def _executable(root: Path, path: str) -> bool:
|
|
candidate = root / path.lstrip('/')
|
|
try:
|
|
return candidate.is_file() and os.access(candidate, os.X_OK)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def root_shell(root: Path) -> str | None:
|
|
"""The shell lxc-attach would start as root, if it can start one."""
|
|
shell = None
|
|
try:
|
|
for line in (root / 'etc/passwd').read_text(errors='replace').splitlines():
|
|
fields = line.split(':')
|
|
if len(fields) >= 7 and fields[0] == 'root':
|
|
shell = fields[6].strip()
|
|
break
|
|
except OSError:
|
|
pass
|
|
if shell is None:
|
|
shell = '/bin/sh'
|
|
if not shell or any(shell.endswith(name) for name in NO_LOGIN):
|
|
return None
|
|
return shell if _executable(root, shell) else None
|
|
|
|
|
|
def _cmode(vmid: int) -> str:
|
|
result = _run('pct', 'config', str(vmid))
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith('cmode:'):
|
|
return line.split(':', 1)[1].strip()
|
|
return 'tty'
|
|
|
|
|
|
def status(vmid: int) -> dict:
|
|
with _rootfs(vmid) as root:
|
|
shell = root_shell(root) if root else None
|
|
cmode = _cmode(vmid)
|
|
return {'vmid': vmid, 'cmode': cmode, 'shell': shell, 'terminal': cmode == 'shell'}
|
|
|
|
|
|
def enable_terminal(vmid: int) -> dict:
|
|
"""Open the Proxmox console as a shell, when the image has one."""
|
|
state = status(vmid)
|
|
if state['shell'] and state['cmode'] != 'shell':
|
|
if _run('pct', 'set', str(vmid), '--cmode', 'shell').returncode == 0:
|
|
state.update(cmode='shell', terminal=True)
|
|
return state
|
|
|
|
|
|
def disable_terminal(vmid: int) -> dict:
|
|
if _cmode(vmid) == 'shell':
|
|
_run('pct', 'set', str(vmid), '--cmode', 'console')
|
|
return status(vmid)
|
|
|
|
|
|
def log_path(vmid: int) -> Path:
|
|
return LOG_DIR / f'{int(vmid)}.console.log'
|
|
|
|
|
|
def _ensure_logrotate() -> None:
|
|
try:
|
|
if LOGROTATE.read_text() == LOGROTATE_POLICY:
|
|
return
|
|
except OSError:
|
|
pass
|
|
LOGROTATE.write_text(LOGROTATE_POLICY)
|
|
os.chmod(LOGROTATE, 0o644)
|
|
|
|
|
|
def enable_log(vmid: int) -> Path:
|
|
"""Keep what the container writes to its console, the way `docker logs` does.
|
|
|
|
liblxc copies the console of the container — the stdout and stderr of its
|
|
entrypoint — into the file named by `lxc.console.logfile`, from the moment
|
|
the container starts and across restarts, without touching the image.
|
|
It is the only one of the lxc.console options the Proxmox configuration
|
|
layer keeps; size and rotation are left to logrotate.
|
|
|
|
The file starts empty on every creation: a new container, whether freshly
|
|
installed or rebuilt by an update, has its own log, and the installer reads
|
|
the first-boot credentials from it without finding a previous one's.
|
|
"""
|
|
LOG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
path = log_path(vmid)
|
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
os.close(descriptor)
|
|
os.chmod(path, 0o600)
|
|
conf = Path(f'/etc/pve/lxc/{int(vmid)}.conf')
|
|
text = conf.read_text()
|
|
current, _, snapshots = text.partition('\n[')
|
|
wanted = f'lxc.console.logfile: {path}'
|
|
kept = [line for line in current.splitlines() if not line.startswith('lxc.console.logfile:')]
|
|
kept.append(wanted)
|
|
rebuilt = '\n'.join(kept) + '\n'
|
|
if snapshots:
|
|
rebuilt += '\n[' + snapshots
|
|
if rebuilt != text:
|
|
conf.write_text(rebuilt)
|
|
_ensure_logrotate()
|
|
return path
|
|
|
|
|
|
def configure(vmid: int) -> dict:
|
|
"""Console log and Proxmox terminal of a container being created."""
|
|
path = enable_log(vmid)
|
|
state = enable_terminal(vmid)
|
|
state['log'] = str(path)
|
|
return state
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0])
|
|
parser.add_argument('action', choices=('status', 'configure', 'enable-terminal', 'disable-terminal'))
|
|
parser.add_argument('vmid', type=int, nargs='+')
|
|
args = parser.parse_args(argv)
|
|
action = {'status': status, 'configure': configure, 'enable-terminal': enable_terminal,
|
|
'disable-terminal': disable_terminal}[args.action]
|
|
results = [action(vmid) for vmid in args.vmid]
|
|
print(json.dumps(results if len(results) > 1 else results[0]))
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|