Files
ProxMenux/oci/remote/oci_console.py
T
MacRimiandClaude Opus 5.5 4437a671d2 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>
2026-09-25 21:51:12 +02:00

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())