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:
MacRimi
2026-09-25 21:51:12 +02:00
co-authored by Claude Opus 5.5
parent 386d33df6e
commit 4437a671d2
524 changed files with 14459 additions and 3841 deletions
+43 -28
View File
@@ -261,7 +261,7 @@ resolve_image_manifest() {
ensure_image() {
local key=$1 image=$2 transport_image inspect digest short archive_name archive_volume archive_path
local partial log pid bytes elapsed status process_bytes pull_name
local partial log pid bytes elapsed status process_bytes pull_name attempt
msg_info "$(translate "Checking the image in the registry...")"
oci_log "Resolving ${key}: ${image}"
transport_image=$(skopeo_transport_reference "$image")
@@ -285,34 +285,49 @@ ensure_image() {
oci_log "Reusing ${archive_volume}"
else
rm -f "$archive_path"
partial="${archive_path}.partial.$$"
log="${partial}.log"
oci_log "Downloading ${image} by digest ${digest}"
pull_name=${transport_image%@sha256:*}
[[ ${pull_name##*/} != *:* ]] || pull_name=${pull_name%:*}
skopeo copy --override-os linux --override-arch "$ARCH" --retry-times 3 \
--retry-delay 5s --image-parallel-copies 1 \
"docker://${pull_name}@${digest}" "oci-archive:${partial}:image-immich-${key}" >"$log" 2>&1 &
pid=$!
elapsed=0
while kill -0 "$pid" 2>/dev/null; do
bytes=$(stat -c %s "$partial" 2>/dev/null || printf 0)
process_bytes=$(awk '$1 == "rchar:" { print $2 }' "/proc/${pid}/io" 2>/dev/null || printf 0)
process_bytes=${process_bytes:-0}
(( process_bytes <= bytes )) || bytes=$process_bytes
msg_progress "$(translate "Downloading the image:") ${key} · $((bytes / 1048576)) MiB · ${elapsed}s"
sleep 2
elapsed=$((elapsed + 2))
# A download can come back complete yet damaged when the connection drops
# and the transfer resumes; the integrity check catches it, and a second
# download is what repairs it.
for attempt in 1 2; do
partial="${archive_path}.partial.$$"
log="${partial}.log"
oci_log "Downloading ${image} by digest ${digest} (attempt ${attempt}/2)"
pull_name=${transport_image%@sha256:*}
[[ ${pull_name##*/} != *:* ]] || pull_name=${pull_name%:*}
skopeo copy --override-os linux --override-arch "$ARCH" --retry-times 3 \
--retry-delay 5s --image-parallel-copies 1 \
"docker://${pull_name}@${digest}" "oci-archive:${partial}:image-immich-${key}" >"$log" 2>&1 &
pid=$!
elapsed=0
while kill -0 "$pid" 2>/dev/null; do
bytes=$(stat -c %s "$partial" 2>/dev/null || printf 0)
process_bytes=$(awk '$1 == "rchar:" { print $2 }' "/proc/${pid}/io" 2>/dev/null || printf 0)
process_bytes=${process_bytes:-0}
(( process_bytes <= bytes )) || bytes=$process_bytes
msg_progress "$(translate "Downloading the image:") ${key} · $((bytes / 1048576)) MiB · ${elapsed}s"
sleep 2
elapsed=$((elapsed + 2))
done
status=0
wait "$pid" || status=$?
cat "$log" >>"$OCI_LOG"
rm -f "$log"
if (( status != 0 )); then
rm -f "$partial"
(( attempt < 2 )) || die "$(translate "Image download failed:") $image"
msg_warn "$(translate "The image download did not complete correctly; downloading it again...")"
continue
fi
msg_info "$(translate "Verifying the image integrity...")"
if ! oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$partial"; then
rm -f "$partial"
(( attempt < 2 )) || die "$(translate "The downloaded image is corrupt:") $image"
msg_warn "$(translate "The image download did not complete correctly; downloading it again...")"
continue
fi
mv -f "$partial" "$archive_path"
break
done
status=0
wait "$pid" || status=$?
cat "$log" >>"$OCI_LOG"
rm -f "$log"
(( status == 0 )) || { rm -f "$partial"; die "$(translate "Image download failed:") $image"; }
msg_info "$(translate "Verifying the image integrity...")"
oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$partial" \
|| { rm -f "$partial"; die "$(translate "The downloaded image is corrupt:") $image"; }
mv -f "$partial" "$archive_path"
fi
msg_ok "$(translate "Image:") $image"
RESOLVED_ARCHIVE=$archive_volume
+38 -23
View File
@@ -247,7 +247,7 @@ resolve_image_manifest() {
ensure_image() {
local key=$1 image=$2 transport_image inspect digest short archive_name archive_volume archive_path
local partial log pid bytes elapsed status
local partial log pid bytes elapsed status attempt
msg_info "$(translate "Checking the image in the registry...")"
oci_log "Resolving ${key}: ${image}"
transport_image=$(skopeo_transport_reference "$image")
@@ -268,29 +268,44 @@ ensure_image() {
oci_log "Reusing ${archive_volume}"
else
rm -f "$archive_path"
partial="${archive_path}.partial.$$"
log="${partial}.log"
oci_log "Downloading ${image} by digest ${digest}"
skopeo copy --override-os linux --override-arch "$ARCH" --retry-times 3 \
--retry-delay 5s --image-parallel-copies 1 \
"docker://${transport_image}" "oci-archive:${partial}:image-nextcloud-${key}" >"$log" 2>&1 &
pid=$!
elapsed=0
while kill -0 "$pid" 2>/dev/null; do
bytes=$(stat -c %s "$partial" 2>/dev/null || printf 0)
msg_progress "$(translate "Downloading the image:") ${key} · $((bytes / 1048576)) MiB · ${elapsed}s"
sleep 2
elapsed=$((elapsed + 2))
# A download can come back complete yet damaged when the connection drops
# and the transfer resumes; the integrity check catches it, and a second
# download is what repairs it.
for attempt in 1 2; do
partial="${archive_path}.partial.$$"
log="${partial}.log"
oci_log "Downloading ${image} by digest ${digest} (attempt ${attempt}/2)"
skopeo copy --override-os linux --override-arch "$ARCH" --retry-times 3 \
--retry-delay 5s --image-parallel-copies 1 \
"docker://${transport_image}" "oci-archive:${partial}:image-nextcloud-${key}" >"$log" 2>&1 &
pid=$!
elapsed=0
while kill -0 "$pid" 2>/dev/null; do
bytes=$(stat -c %s "$partial" 2>/dev/null || printf 0)
msg_progress "$(translate "Downloading the image:") ${key} · $((bytes / 1048576)) MiB · ${elapsed}s"
sleep 2
elapsed=$((elapsed + 2))
done
status=0
wait "$pid" || status=$?
cat "$log" >>"$OCI_LOG"
rm -f "$log"
if (( status != 0 )); then
rm -f "$partial"
(( attempt < 2 )) || die "$(translate "Image download failed:") $image"
msg_warn "$(translate "The image download did not complete correctly; downloading it again...")"
continue
fi
msg_info "$(translate "Verifying the image integrity...")"
if ! oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$partial"; then
rm -f "$partial"
(( attempt < 2 )) || die "$(translate "The downloaded image is corrupt:") $image"
msg_warn "$(translate "The image download did not complete correctly; downloading it again...")"
continue
fi
mv -f "$partial" "$archive_path"
break
done
status=0
wait "$pid" || status=$?
cat "$log" >>"$OCI_LOG"
rm -f "$log"
(( status == 0 )) || { rm -f "$partial"; die "$(translate "Image download failed:") $image"; }
msg_info "$(translate "Verifying the image integrity...")"
oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$partial" \
|| { rm -f "$partial"; die "$(translate "The downloaded image is corrupt:") $image"; }
mv -f "$partial" "$archive_path"
fi
msg_ok "$(translate "Image:") $image"
RESOLVED_ARCHIVE=$archive_volume
+20 -23
View File
@@ -226,19 +226,10 @@ INSTALL_COMPLETE=0
PRESERVE_FAILED_CT=0
cleanup_runtime_console_log() {
[[ -n $RUNTIME_CONSOLE_LOG ]] || return 0
if [[ -f ${CONF:-} ]]; then
local temporary_conf
temporary_conf=$(mktemp)
awk '
$0 !~ /^lxc\.console\.logfile:/ &&
$0 !~ /^lxc\.console\.size:/ &&
$0 !~ /^lxc\.console\.rotate:/
' "$CONF" >"$temporary_conf"
cat "$temporary_conf" >"$CONF"
rm -f "$temporary_conf"
fi
rm -f "$RUNTIME_CONSOLE_LOG" "${RUNTIME_CONSOLE_LOG}.1"
# The console log is the container's own log from here on, and its line in
# the configuration stays with it: there is nothing to undo. A failed
# installation keeps the file too, since it holds why the application did
# not come up.
RUNTIME_CONSOLE_LOG=""
}
@@ -1113,7 +1104,7 @@ if [[ $DRY_RUN == 1 ]]; then
fi
# Hold the registry lock through installation so reconciliation cannot race it.
INSTANCE_ROOT=${PROXMENUX_OCI_INSTANCE_ROOT:-/usr/local/share/proxmenux/oci/apps}
INSTANCE_ROOT=${PROXMENUX_OCI_INSTANCE_ROOT:-/usr/local/share/proxmenux/oci/instances}
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
INSTANCE_ID=$(python3 "${SCRIPT_DIR}/oci_instance_transaction.py" --root "$INSTANCE_ROOT" \
authorize-candidate "$VMID" --journal "$PROXMENUX_OCI_TRANSACTION" \
@@ -1303,8 +1294,7 @@ if [[ ! -s $ARCHIVE_PATH ]]; then
fi
fi
DESCRIPTION="ProxMenux OCI: ${APP_ID}; image=${IMAGE_REF}; digest=${DIGEST}; source=${REVISION}; status=${STATUS}"
DESCRIPTION="${DESCRIPTION}; proxmenux-instance=${INSTANCE_ID}"
DESCRIPTION="proxmenux-instance=${INSTANCE_ID}"
UNPRIVILEGED=$(jq -r 'if .security | has("unprivileged") then .security.unprivileged else true end' "$DEPLOYMENT_FILE")
case "$UNPRIVILEGED" in
true) UNPRIVILEGED_FLAG=1 ;;
@@ -1663,13 +1653,14 @@ CREDENTIALS=$(jq -c --argjson environment "$DEPLOYMENT_ENVIRONMENT" '
' "$TEMPLATE_FILE")
RUNTIME_CREDENTIALS=$(jq '[.[] | select(.retrieval.method? == "container-console-pattern")] | length' <<<"$CREDENTIALS")
if [[ $START_AFTER == 1 && ( $RUNTIME_CREDENTIALS -gt 0 || $HAS_STARTUP_HEALTHCHECK == 1 || $HAS_RUNNING_CHECK == 1 || $HAOS_HEALTHCHECK != 0 ) ]]; then
RUNTIME_CONSOLE_DIR="/run/proxmenux-oci"
install -d -m 700 "$RUNTIME_CONSOLE_DIR"
RUNTIME_CONSOLE_LOG="${RUNTIME_CONSOLE_DIR}/ct-${VMID}.console.log"
install -m 600 /dev/null "$RUNTIME_CONSOLE_LOG"
printf 'lxc.console.logfile: %s\n' "$RUNTIME_CONSOLE_LOG" >>"$CONF"
fi
# The console of the container is kept as its log, the way `docker logs` keeps
# it, and the Proxmox console opens a shell when the image has one. Both are
# set before the configuration is recorded, so the record carries them and an
# update, which rebuilds the container through this installer, sets them again.
# The first-boot credentials are read from the same log.
CONSOLE_STATE=$(python3 "${SCRIPT_DIR}/oci_console.py" configure "$VMID") \
|| die "$(translate "The console of the container could not be configured")"
RUNTIME_CONSOLE_LOG=$(jq -r '.log' <<<"$CONSOLE_STATE")
oci_log "Configuration created for CT $VMID"
PASSWORD_STATE=""
@@ -1870,6 +1861,12 @@ fi
cleanup_runtime_console_log
UPDATED_DESCRIPTION=$(python3 "${SCRIPT_DIR}/oci_description.py" --template "$TEMPLATE_FILE" \
--digest "$DIGEST" --instance "$INSTANCE_ID" --ip "$IP") \
|| die "Could not prepare the OCI notes"
oci_quiet pct set "$VMID" --description "$UPDATED_DESCRIPTION" \
|| die "Could not write the OCI notes in Proxmox"
URLS=$(jq -c --arg ip "$IP" '
if $ip == "" then []
elif (.first_run.endpoints? // []) | length > 0 then
+38 -23
View File
@@ -254,7 +254,7 @@ resolve_image_manifest() {
ensure_image() {
local key=$1 image=$2 transport_image inspect digest short archive_name archive_volume archive_path
local partial log pid bytes elapsed status
local partial log pid bytes elapsed status attempt
msg_info "$(translate "Checking the image in the registry...")"
oci_log "Resolving ${key}: ${image}"
transport_image=$(skopeo_transport_reference "$image")
@@ -275,29 +275,44 @@ ensure_image() {
oci_log "Reusing ${archive_volume}"
else
rm -f "$archive_path"
partial="${archive_path}.partial.$$"
log="${partial}.log"
oci_log "Downloading ${image} by digest ${digest}"
skopeo copy --override-os linux --override-arch "$ARCH" --retry-times 3 \
--retry-delay 5s --image-parallel-copies 1 \
"docker://${transport_image}" "oci-archive:${partial}:image-paperless-${key}" >"$log" 2>&1 &
pid=$!
elapsed=0
while kill -0 "$pid" 2>/dev/null; do
bytes=$(stat -c %s "$partial" 2>/dev/null || printf 0)
msg_progress "$(translate "Downloading the image:") ${key} · $((bytes / 1048576)) MiB · ${elapsed}s"
sleep 2
elapsed=$((elapsed + 2))
# A download can come back complete yet damaged when the connection drops
# and the transfer resumes; the integrity check catches it, and a second
# download is what repairs it.
for attempt in 1 2; do
partial="${archive_path}.partial.$$"
log="${partial}.log"
oci_log "Downloading ${image} by digest ${digest} (attempt ${attempt}/2)"
skopeo copy --override-os linux --override-arch "$ARCH" --retry-times 3 \
--retry-delay 5s --image-parallel-copies 1 \
"docker://${transport_image}" "oci-archive:${partial}:image-paperless-${key}" >"$log" 2>&1 &
pid=$!
elapsed=0
while kill -0 "$pid" 2>/dev/null; do
bytes=$(stat -c %s "$partial" 2>/dev/null || printf 0)
msg_progress "$(translate "Downloading the image:") ${key} · $((bytes / 1048576)) MiB · ${elapsed}s"
sleep 2
elapsed=$((elapsed + 2))
done
status=0
wait "$pid" || status=$?
cat "$log" >>"$OCI_LOG"
rm -f "$log"
if (( status != 0 )); then
rm -f "$partial"
(( attempt < 2 )) || die "$(translate "Image download failed:") $image"
msg_warn "$(translate "The image download did not complete correctly; downloading it again...")"
continue
fi
msg_info "$(translate "Verifying the image integrity...")"
if ! oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$partial"; then
rm -f "$partial"
(( attempt < 2 )) || die "$(translate "The downloaded image is corrupt:") $image"
msg_warn "$(translate "The image download did not complete correctly; downloading it again...")"
continue
fi
mv -f "$partial" "$archive_path"
break
done
status=0
wait "$pid" || status=$?
cat "$log" >>"$OCI_LOG"
rm -f "$log"
(( status == 0 )) || { rm -f "$partial"; die "$(translate "Image download failed:") $image"; }
msg_info "$(translate "Verifying the image integrity...")"
oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$partial" \
|| { rm -f "$partial"; die "$(translate "The downloaded image is corrupt:") $image"; }
mv -f "$partial" "$archive_path"
fi
msg_ok "$(translate "Image:") $image"
RESOLVED_ARCHIVE=$archive_volume
+38 -23
View File
@@ -261,7 +261,7 @@ resolve_image_manifest() {
ensure_image() {
local key=$1 image=$2 transport_image inspect digest short archive_name archive_volume archive_path
local partial log pid bytes elapsed status
local partial log pid bytes elapsed status attempt
msg_info "$(translate "Checking the image in the registry...")"
oci_log "Resolving ${key}: ${image}"
transport_image=$(skopeo_transport_reference "$image")
@@ -282,29 +282,44 @@ ensure_image() {
oci_log "Reusing ${archive_volume}"
else
rm -f "$archive_path"
partial="${archive_path}.partial.$$"
log="${partial}.log"
oci_log "Downloading ${image} by digest ${digest}"
skopeo copy --override-os linux --override-arch "$ARCH" --retry-times 3 \
--retry-delay 5s --image-parallel-copies 1 \
"docker://${transport_image}" "oci-archive:${partial}:image-tandoor-${key}" >"$log" 2>&1 &
pid=$!
elapsed=0
while kill -0 "$pid" 2>/dev/null; do
bytes=$(stat -c %s "$partial" 2>/dev/null || printf 0)
msg_progress "$(translate "Downloading the image:") ${key} · $((bytes / 1048576)) MiB · ${elapsed}s"
sleep 2
elapsed=$((elapsed + 2))
# A download can come back complete yet damaged when the connection drops
# and the transfer resumes; the integrity check catches it, and a second
# download is what repairs it.
for attempt in 1 2; do
partial="${archive_path}.partial.$$"
log="${partial}.log"
oci_log "Downloading ${image} by digest ${digest} (attempt ${attempt}/2)"
skopeo copy --override-os linux --override-arch "$ARCH" --retry-times 3 \
--retry-delay 5s --image-parallel-copies 1 \
"docker://${transport_image}" "oci-archive:${partial}:image-tandoor-${key}" >"$log" 2>&1 &
pid=$!
elapsed=0
while kill -0 "$pid" 2>/dev/null; do
bytes=$(stat -c %s "$partial" 2>/dev/null || printf 0)
msg_progress "$(translate "Downloading the image:") ${key} · $((bytes / 1048576)) MiB · ${elapsed}s"
sleep 2
elapsed=$((elapsed + 2))
done
status=0
wait "$pid" || status=$?
cat "$log" >>"$OCI_LOG"
rm -f "$log"
if (( status != 0 )); then
rm -f "$partial"
(( attempt < 2 )) || die "$(translate "Image download failed:") $image"
msg_warn "$(translate "The image download did not complete correctly; downloading it again...")"
continue
fi
msg_info "$(translate "Verifying the image integrity...")"
if ! oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$partial"; then
rm -f "$partial"
(( attempt < 2 )) || die "$(translate "The downloaded image is corrupt:") $image"
msg_warn "$(translate "The image download did not complete correctly; downloading it again...")"
continue
fi
mv -f "$partial" "$archive_path"
break
done
status=0
wait "$pid" || status=$?
cat "$log" >>"$OCI_LOG"
rm -f "$log"
(( status == 0 )) || { rm -f "$partial"; die "$(translate "Image download failed:") $image"; }
msg_info "$(translate "Verifying the image integrity...")"
oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$partial" \
|| { rm -f "$partial"; die "$(translate "The downloaded image is corrupt:") $image"; }
mv -f "$partial" "$archive_path"
fi
msg_ok "$(translate "Image:") $image"
RESOLVED_ARCHIVE=$archive_volume
+200
View File
@@ -0,0 +1,200 @@
#!/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())
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Render the Proxmox Notes for one ProxMenux OCI container."""
from __future__ import annotations
import argparse
import html
import ipaddress
import json
from pathlib import Path
from urllib.parse import urlparse
DOCS = 'https://macrimi.github.io/ProxMenux/docs/oci-manager'
CODE = 'https://github.com/MacRimi/ProxMenux/tree/main/oci'
LOGO = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logo_desc.png'
def safe_url(value):
if not isinstance(value, str):
return ''
parsed = urlparse(value.strip())
return value.strip() if parsed.scheme in ('http', 'https') and parsed.netloc else ''
def link(label, url):
url = safe_url(url)
if not url:
return ''
return (f'<a href="{html.escape(url, quote=True)}" target="_blank" '
f'rel="noopener noreferrer">{html.escape(label)}</a>')
def image_page(reference):
name = reference.split('@', 1)[0]
if ':' in name.rsplit('/', 1)[-1]:
name = name.rsplit(':', 1)[0]
if name.startswith('lscr.io/linuxserver/'):
return 'https://docs.linuxserver.io/images/docker-' + name.rsplit('/', 1)[-1] + '/'
if name.startswith('ghcr.io/'):
parts = name.split('/')
if len(parts) >= 3:
return f'https://github.com/{parts[1]}/' + '/'.join(parts[2:])
if name.startswith('docker.io/'):
name = name[len('docker.io/'):]
if '/' not in name and name and '.' not in name:
return 'https://hub.docker.com/_/' + name
if '/' in name and '.' not in name.split('/', 1)[0]:
return 'https://hub.docker.com/r/' + name
return ''
def render(template, digest, instance_id, ip=''):
ui = template.get('catalog_ui') or {}
contract = template.get('container_contract') or {}
image = contract.get('image') or {}
title = ui.get('title') or {}
if isinstance(title, dict):
title = title.get('en_US') or next(iter(title.values()), '')
title = str(title or contract.get('service_name') or template.get('id') or 'OCI')
reference = str(image.get('reference') or '')
source = template.get('source') or {}
image_url = (source.get('image_repository_url') or image_page(reference)
or ui.get('repository') or source.get('repository'))
resources = [('Image', image_url), ('App', ui.get('website')),
('App docs', ui.get('documentation'))]
repository = ui.get('repository') or source.get('repository')
if safe_url(repository) and repository != image_url:
resources.append(('Repository', repository))
resources = [link(label, url) for label, url in resources]
resources = [item for item in resources if item]
try:
address = str(ipaddress.ip_address(ip)) if ip else ''
except ValueError:
address = ''
endpoints = (template.get('first_run') or {}).get('endpoints') or []
if not endpoints and ui.get('launch'):
endpoints = [dict(ui['launch'], label='Web UI')]
if template.get('id') == 'image-adguard-home':
endpoints = [{'label': 'Setup (first run)', 'scheme': 'http', 'port': 3000, 'path': '/'},
{'label': 'Web UI (after setup)', 'scheme': 'http', 'port': 80, 'path': '/'}]
access = []
if address:
for endpoint in endpoints:
scheme = endpoint.get('scheme')
port = endpoint.get('port')
path = endpoint.get('path') or '/'
if scheme not in ('http', 'https') or not isinstance(port, int) or not 1 <= port <= 65535:
continue
if not isinstance(path, str) or not path.startswith('/'):
path = '/'
url = f'{scheme}://{address}:{port}{path}'
item = f'{link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
if item:
access.append(item)
badges = (
('Docs', DOCS, 'https://img.shields.io/badge/%F0%9F%93%9A_Docs-blue'),
('Code', CODE, 'https://img.shields.io/badge/%F0%9F%92%BB_Code-green'),
('Ko-fi', 'https://ko-fi.com/macrimi', 'https://img.shields.io/badge/%E2%98%95_Ko--fi-red'),
)
badge_links = ' '.join(
f'<a href="{html.escape(url, quote=True)}" target="_blank" rel="noopener noreferrer">'
f'<img src="{badge}" alt="{label}"></a>'
for label, url, badge in badges
)
return f'''<div align="center">
<table style="width: 100%; border-collapse: collapse;"><tr>
<td style="width: 100px; vertical-align: middle;"><img src="{LOGO}" alt="ProxMenux Logo" style="height: 100px;"></td>
<td style="vertical-align: middle;"><h1 style="margin: 0;">{html.escape(title)} OCI</h1><p style="margin: 0;">Created with ProxMenux</p></td>
</tr></table>
<p>{badge_links}</p>
<p>Image: <code>{html.escape(reference)}</code> {' &middot; '.join(resources)}</p>
{''.join(f'<p>{item}</p>' for item in access)}
</div>
<!-- proxmenux-instance={html.escape(instance_id, quote=True)} -->'''
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--template', required=True, type=Path)
parser.add_argument('--digest', default='')
parser.add_argument('--instance', required=True)
parser.add_argument('--ip', default='')
args = parser.parse_args()
print(render(json.loads(args.template.read_text()), args.digest, args.instance, args.ip))
if __name__ == '__main__':
main()
+57 -6
View File
@@ -21,6 +21,34 @@ from oci_ui import translate, msg_error, msg_ok
ROOT = Path('/var/lib/proxmenux/oci-installations')
FIELDS = ('Entrypoint', 'Cmd', 'Env', 'User', 'WorkingDir', 'StopSignal', 'Volumes', 'ExposedPorts', 'Healthcheck')
VERSION_ENV_RE = re.compile(r'^([A-Z][A-Z0-9_]*)_VERSION$')
# Repositories whose version variable is not named after the image.
VERSION_ENV_NAMES = {'postgres': 'PG'}
def version_from_environment(repository, environment):
"""The application version an image states in its own environment.
Official library images publish no labels at all, yet they carry the
version as NEXTCLOUD_VERSION, REDIS_VERSION, MONGO_VERSION. Most also
carry their dependencies there — GOSU_VERSION, PHP_VERSION, NJS_VERSION —
so only a variable that names this image is accepted. A version variable
that names something else is never taken for the application's, not even
when it is the only one: an application built on a Python base carries
PYTHON_VERSION alone, and Paperless-ngx would read as 3.14.7. Anything
that does not name the image is left to the label.
"""
found = {}
for entry in environment or []:
name, _, value = entry.partition('=')
match = VERSION_ENV_RE.match(name)
if match and value.strip():
found[match.group(1)] = value.strip()
if not found:
return None
basename = repository.rsplit('/', 1)[-1]
expected = VERSION_ENV_NAMES.get(basename, basename.replace('-', '_')).upper()
return found.get(expected)
def command(*args):
@@ -151,8 +179,19 @@ def resolve_candidate(reference, architecture):
if config.get('architecture') != architecture or config.get('os') != 'linux':
raise ValueError(translate('Incompatible image platform'))
labels = config.get('config', {}).get('Labels') or {}
return {'manifest_digest': digest, 'defaults': {k: config.get('config', {}).get(k) for k in FIELDS},
'version': labels.get('org.opencontainers.image.version') or labels.get('build_version')}
defaults = {k: config.get('config', {}).get(k) for k in FIELDS}
# The environment is read first because a self-naming variable cannot be
# inherited: `org.opencontainers.image.version` is copied from the base
# image by enough publishers that mongo and rabbitmq both report the
# Ubuntu release there instead of their own version.
version = (version_from_environment(repo, defaults.get('Env'))
or labels.get('org.opencontainers.image.version')
or labels.get('build_version'))
# The build date identifies the image as the publisher released it: it is
# what changes when an image is rebuilt, whether or not the application
# version inside it moved.
return {'manifest_digest': digest, 'defaults': defaults, 'version': version,
'created': config.get('created')}
def compare(record, current, candidate=None):
@@ -189,12 +228,24 @@ def compare(record, current, candidate=None):
'automatic_update_enabled': False, 'blockers': blockers,
'requires': ['consistent-backup', 'review-rootfs-only-data', 'transactional-updater-not-implemented']}
if candidate:
report['update_available'] = candidate['manifest_digest'] != record['image']['manifest_digest']
report['changed_image_fields'] = [key for key in FIELDS if record['image']['defaults'].get(key) != candidate['defaults'].get(key)]
replaced = candidate['manifest_digest'] != record['image']['manifest_digest']
report['update_available'] = replaced
report['candidate_digest'] = candidate['manifest_digest']
# The two sides read the image configuration through different paths:
# the record from the archive's own config blob, the candidate from
# `skopeo inspect --config`, which normalises to the OCI schema and
# drops Docker extensions such as Healthcheck. A field the candidate
# cannot report is unknown, not changed, and an identical digest means
# the configuration is byte-identical whatever either side returns.
report['changed_image_fields'] = [
key for key in FIELDS
if replaced and candidate['defaults'].get(key) is not None
and record['image']['defaults'].get(key) != candidate['defaults'].get(key)
]
old_env = dict(v.split('=', 1) for v in record['image']['defaults'].get('Env') or [] if '=' in v)
new_env = dict(v.split('=', 1) for v in candidate['defaults'].get('Env') or [] if '=' in v)
report['changed_environment_names'] = sorted(k for k in old_env.keys() | new_env.keys() if old_env.get(k) != new_env.get(k))
report['candidate_digest'] = candidate['manifest_digest']
report['changed_environment_names'] = sorted(
k for k in old_env.keys() | new_env.keys() if replaced and old_env.get(k) != new_env.get(k))
return report
+157
View File
@@ -0,0 +1,157 @@
"""Explicitly adopt supported Proxmox mounts and devices into an OCI instance."""
from __future__ import annotations
import copy
import re
import uuid
import oci_instances as instances
import oci_gpu_devices as gpu_devices
import oci_host_mounts as host_mounts
import oci_instance_transaction as transaction
from oci_installation_state import parse_config, sha
from oci_ui import translate
def _managed_size(value):
match = re.fullmatch(r'([1-9][0-9]*)([GMT])', value or '')
if not match:
raise ValueError(translate('The added disk needs a whole-GiB size recorded by Proxmox'))
amount, unit = int(match[1]), match[2]
if unit == 'M':
if amount % 1024:
raise ValueError(translate('The added disk size is not a whole GiB'))
return amount // 1024
return amount * (1024 if unit == 'T' else 1)
def _mount(key, value, vmid):
source, *parts = value.split(',')
if not source or not parts or any('=' not in part for part in parts):
raise ValueError(f'{key}: {translate("Incomplete mount configuration")}')
options = dict(part.split('=', 1) for part in parts)
if len(options) != len(parts) or set(options) - {'mp', 'size', 'backup', 'ro'}:
raise ValueError(f'{key}: {translate("Unsupported mount options")}')
target = host_mounts.valid_path(options.get('mp'))
read_only = options.get('ro', '0') == '1'
if options.get('ro', '0') not in ('0', '1'):
raise ValueError(f'{key}: {translate("Invalid read-only option")}')
if source.startswith('/'):
if options.get('backup', '0') != '0' or 'size' in options:
raise ValueError(f'{key}: {translate("Host directories cannot be included in vzdump")}')
host_mounts.validate_source(source)
return {'type': 'host-bind', 'container_path': target, 'source': source,
'size_gb': None, 'backup': False, 'read_only': read_only,
'create_if_missing': False}
if options.get('backup') != '1' or ':' not in source:
raise ValueError(f'{key}: {translate("Only backed-up Proxmox volumes can be adopted")}')
storage, volume = source.split(':', 1)
if not re.fullmatch(r'[A-Za-z0-9_-]+', storage) or not volume:
raise ValueError(f'{key}: {translate("Invalid Proxmox volume ID")}')
if not re.search(rf'(?:^|/)(?:vm|subvol)-{vmid}-disk-[0-9]+(?:\.|$)', volume):
raise ValueError(f'{key}: {translate("The disk does not belong to this CT; automatic adoption is unsafe")}')
return {'type': 'managed-volume', 'container_path': target, 'source': storage,
'size_gb': _managed_size(options.get('size')), 'backup': True,
'read_only': read_only}
def _device(key, value):
parts = value.split(',')
if any('=' not in part for part in parts):
raise ValueError(f'{key}: {translate("Incomplete device configuration")}')
fields = dict(part.split('=', 1) for part in parts)
if len(fields) != len(parts) or set(fields) - {'path', 'mode', 'gid', 'uid', 'deny-write'}:
raise ValueError(f'{key}: {translate("Unsupported device options")}')
path = fields.get('path')
if not (gpu_devices.gpu_path(path) or gpu_devices.peripheral_path(path)):
raise ValueError(f'{key}: {translate("Only Intel/AMD DRM, Coral and USB nodes can be adopted automatically")}')
snapshot = gpu_devices.snapshot(path)
mode = fields.get('mode', '0660')
if not re.fullmatch(r'0?[0-7]{3}', mode):
raise ValueError(f'{key}: {translate("Invalid device mode")}')
gid = int(fields.get('gid', '0'))
if gid not in (0, snapshot['gid']):
raise ValueError(f'{key}: {translate("Device GID does not match the host")}')
if fields.get('deny-write', '0') not in ('0', '1'):
raise ValueError(f'{key}: {translate("Invalid device permissions")}')
uid = int(fields.get('uid', '0'))
if uid < 0 or uid >= 4294967295:
raise ValueError(f'{key}: {translate("Invalid device UID")}')
device = {'id': 'adopted-' + path.removeprefix('/dev/').replace('/', '-'),
'kind': 'character-device', 'host_path': path, 'container_path': path,
'mode': mode, 'gid_strategy': 'host-device-gid' if gid == snapshot['gid'] else 'none',
'deny_write': fields.get('deny-write', '0') == '1'}
if uid:
device['uid'] = uid
if gpu_devices.gpu_path(path) and path != '/dev/kfd':
device['drm_vendor_ids'] = [snapshot['vendor']]
return device
def propose(record, config):
if (record.get('status') != 'installed' or record.get('stack') or record.get('stack_member')
or record.get('native_stack_intent') or record.get('deployment', {}).get('stack_managed')):
raise ValueError(translate('Only an installed standalone OCI instance can adopt external changes'))
if instances.identity(config) != record['installation_id']:
raise ValueError(translate('The container identity changed; nothing was adopted'))
before = parse_config(record['observed']['config'].encode())
current = parse_config(config)
changed = sorted(key for key in before.keys() | current.keys() if before.get(key) != current.get(key))
new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)]
unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE]
if unsupported:
raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} {', '.join(unsupported)}")
if not new_keys:
return None
candidate = copy.deepcopy(record)
deployment = candidate['deployment']
details = []
for key in new_keys:
if key.startswith('mp'):
mount = _mount(key, current[key], record['vmid'])
deployment.setdefault('mounts', []).append(mount)
details.append(f"{key}: {mount['container_path']} <- {current[key].split(',', 1)[0]} "
f"({mount['type']}, backup={int(mount['backup'])})")
else:
device = _device(key, current[key])
deployment.setdefault('devices', []).append(device)
kind = 'GPU' if gpu_devices.gpu_path(device['host_path']) else 'USB/Coral'
details.append(f"{key}: {device['host_path']} ({kind})")
filtered = b'\n'.join(line for line in config.splitlines()
if not any(line.startswith(key.encode() + b': ') for key in new_keys)) + b'\n'
for key, value in transaction.external_changes(record, filtered).items():
section, name = transaction.ADOPTABLE[key]
target = deployment.setdefault(section, {}) if section else deployment
target[name] = value
candidate['observed']['config'] = config.decode()
candidate['observed']['config_sha256'] = sha(config)
candidate['observed']['gpu_devices'] = gpu_devices.capture(config)
candidate['observed']['host_bind_sources'] = host_mounts.capture_sources(config)
transaction.preflight(candidate, copy.deepcopy(candidate), config)
return {'candidate': candidate, 'details': details,
'previous_record_hash': sha(instances.location(instances.ROOT, record['vmid']).read_bytes()),
'config_sha256': sha(config)}
def commit(root, vmid, proposal):
with instances.locked(root):
record = instances.read(root, vmid)
path = instances.location(root, vmid)
if sha(path.read_bytes()) != proposal['previous_record_hash']:
raise ValueError(translate('The OCI contract changed while reviewing; nothing was adopted'))
config = instances.command('pct', 'config', str(vmid))
if sha(config) != proposal['config_sha256']:
raise ValueError(translate('The LXC changed while reviewing; nothing was adopted'))
checked = propose(record, config)
if checked is None or checked['details'] != proposal['details']:
raise ValueError(translate('The proposed changes no longer match; nothing was adopted'))
candidate = checked['candidate']
observed = instances.observe(vmid, record['installation_id'], record['observed']['archive_path'],
record['observed']['resolved_registry_digest'], record['observed']['image'])
if observed['config_sha256'] != proposal['config_sha256']:
raise ValueError(translate('The LXC changed during verification; nothing was adopted'))
candidate['observed'] = observed
history = path.parent / 'history'
instances.write(history / f'before-reconciliation-{uuid.uuid4().hex}.json', record)
instances.write(path, candidate)
return candidate
+11 -2
View File
@@ -40,7 +40,10 @@ BASIC = {'arch', 'cmode', 'console', 'tty', 'cores', 'cpulimit', 'cpuunits', 'de
'entrypoint', 'env', 'features', 'hostname', 'memory', 'net0', 'onboot',
'ostype', 'rootfs', 'swap', 'tags', 'unprivileged',
'lxc.init.cwd', 'lxc.init.uid', 'lxc.init.gid', 'lxc.init.groups',
'lxc.signal.halt', 'lxc.environment.runtime'}
'lxc.signal.halt', 'lxc.environment.runtime',
# The container's console log, set by the installer on every
# creation; the rebuilt container gets it again the same way.
'lxc.console.logfile'}
# Their output is data (and may hold saved secrets); it is never logged.
DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get')}
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
@@ -759,7 +762,8 @@ def check_archive(archive):
def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, interrupt_after=None,
backup_compression='zstd', acknowledge_external_data=False, coordinated=None, progress=None):
backup_compression='zstd', acknowledge_external_data=False, coordinated=None, progress=None,
keep_backup=None):
# A coordinated member is shown by its stack; progress prefixes the installer steps.
show = not coordinated
update = operation == 'update'
@@ -944,6 +948,11 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
freed = 0
try:
release_stage(state)
if keep_backup and state.get('backup'):
import oci_keep_backup
kept = oci_keep_backup.keep(state['backup'], keep_backup)
if kept:
msg_ok(f"{translate('Backup kept in')} {keep_backup}: {Path(kept).name}")
prune_backups(root, vmid)
for path, size in image_cache.prune(root, lock=False):
log(f'removed unused image archive: {path}')
+1 -1
View File
@@ -22,7 +22,7 @@ from oci_host_mounts import capture_sources
from oci_accelerators import capture as capture_gpu_devices
from oci_ui import translate, msg_error, msg_ok
ROOT = Path('/usr/local/share/proxmenux/oci/apps')
ROOT = Path('/usr/local/share/proxmenux/oci/instances')
MARKER = 'proxmenux-instance='
ACTIVE = {'installing', 'assembling', 'updating', 'recovering'}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Keeping the backup an OCI update takes, in a Proxmox VE backup storage.
Every update already stops the container and takes a verified vzdump backup,
which the rollback uses and which is deleted once the new image works. When
the backup is to be kept, that same archive is moved into the dump directory
of the chosen storage instead of being deleted: one backup, not two.
A storage without a directory of its own (Proxmox Backup Server) cannot
receive a file, so a backup is written to it with vzdump before the update.
"""
from __future__ import annotations
import json
from pathlib import Path
import re
import shutil
import subprocess
from oci_ui import translate
STORAGE_RE = re.compile(r'[A-Za-z0-9._-]{1,64}')
def _storage(storage):
if not STORAGE_RE.fullmatch(storage or ''):
raise ValueError(translate('Invalid storage name'))
result = subprocess.run(['pvesh', 'get', f'/storage/{storage}', '--output-format', 'json'],
capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise ValueError(f"{translate('The backup storage does not exist:')} {storage}")
info = json.loads(result.stdout)
if 'backup' not in str(info.get('content', '')).split(','):
raise ValueError(f"{translate('The storage does not accept backups:')} {storage}")
return info
def dump_dir(storage):
"""The directory vzdump writes to on a file storage, or None."""
info = _storage(storage)
path = info.get('path')
return Path(path) / 'dump' if path else None
def validate(storage):
_storage(storage)
def before_update(vmid, storage):
"""A storage that cannot receive the file gets its own backup first."""
if dump_dir(storage) is not None:
return None
subprocess.run(['vzdump', str(vmid), '--storage', storage, '--mode', 'snapshot',
'--compress', 'zstd', '--notes-template', 'ProxMenux OCI: before the image update'],
check=True)
return storage
def keep(archive, storage):
"""Move the update's own backup into the storage; returns where it went,
or None when the storage received its backup before the update."""
directory = dump_dir(storage)
if directory is None:
return None
archive = Path(archive)
directory.mkdir(parents=True, exist_ok=True)
target = directory / archive.name
shutil.move(str(archive), str(target))
target.chmod(0o644)
stem = archive.name.split('.tar')[0]
log = archive.with_name(stem + '.log')
if log.is_file():
shutil.move(str(log), str(directory / log.name))
(directory / (archive.name + '.notes')).write_text('ProxMenux OCI: before the image update\n')
return str(target)
+24 -4
View File
@@ -5,9 +5,11 @@ import copy
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import oci_console
import oci_instances as instances
from oci_installation_state import command, image_from_archive, sha
import oci_stack_replay
@@ -63,17 +65,21 @@ def create(root, args):
if record['status'] != 'installing' or args[1] != record['deployment']['archive_volume']:
raise ValueError(translate('The container creation does not match the prepared instance'))
argv = list(args)
description = ''
if '--description' in argv:
index = argv.index('--description')
description = argv[index + 1]
del argv[index:index + 2]
argv += ['--description', description + '; ' + instances.MARKER + record['installation_id']]
argv += ['--description', instances.MARKER + record['installation_id']]
record['deployment']['create_arguments'] = argv
instances.write(instances.location(root, vmid), record)
# No inherited registry/network locks in long-lived Proxmox processes.
# Keep PVE extraction directories traversable inside its standard idmap.
return subprocess.run(['pct', 'create', *argv], close_fds=True, umask=0o022).returncode
code = subprocess.run(['pct', 'create', *argv], close_fds=True, umask=0o022).returncode
if code == 0:
# Every member keeps its console as its own log and opens a Proxmox
# console as a shell, set before the stack records its configuration
# so an update rebuilds them the same way.
oci_console.configure(vmid)
return code
def capture_rootfs(root, vmid):
@@ -102,6 +108,20 @@ def finalize(root, primary):
vmid = member['vmid']
record = instances.read(root, vmid)
plan = record['deployment']
from oci_description import render
presentation = copy.deepcopy(record['template'])
if vmid == primary:
stack_ui = intent['template'].get('catalog_ui') or {}
presentation['catalog_ui'] = {**stack_ui, **(presentation.get('catalog_ui') or {})}
if not (presentation.get('first_run') or {}).get('endpoints'):
presentation['first_run'] = intent['template'].get('first_run') or {}
ip_result = subprocess.run(['lxc-info', '-n', str(vmid), '-iH'],
capture_output=True, text=True, timeout=5)
ip = next((line.strip() for line in ip_result.stdout.splitlines()
if re.fullmatch(r'[0-9]+(?:\.[0-9]+){3}', line.strip())), '')
description = render(presentation, plan['image']['manifest_digest'],
record['installation_id'], ip)
subprocess.run(['pct', 'set', str(vmid), '--description', description], check=True)
instances.finish(root, vmid, plan['archive_path'], plan['image']['manifest_digest'])
record = instances.read(root, vmid)
plan = record['deployment']
+1 -1
View File
@@ -2,7 +2,7 @@
oci_native_begin() {
OCI_NATIVE_PRIMARY=$1
shift
local root=/usr/local/share/proxmenux/oci/apps
local root=/usr/local/share/proxmenux/oci/instances
[[ ! -L $root && ! -L $root/.lock ]] || die "$(translate "The instance registry is not safe")"
oci_quiet install -d -m 0700 "$root"
exec 8>>"$root/.lock"
+21 -2
View File
@@ -160,6 +160,8 @@ class NativeAdapter:
primary = self.records[plan['primary_vmid']]
self.services = {s['vmid']: s for s in primary['stack']['deployment']['services']}
self.acknowledge = acknowledge_external_data
# Storage where the verified backups of this update are kept.
self.keep_backup = None
def state(self):
return json.loads(self.journal.read_text())
@@ -558,6 +560,12 @@ class NativeAdapter:
instances.write(instances.location(self.root, vmid), record)
try:
self.release_stages()
if self.keep_backup and state['phase'] == 'committed':
import oci_keep_backup
for backup in sorted(self.journal.parent.glob('backup-*/vzdump-lxc-*.tar.zst')):
kept = oci_keep_backup.keep(backup, self.keep_backup)
if kept:
msg_ok(f"{translate('Backup kept in')} {self.keep_backup}: {Path(kept).name}")
self.prune_backups(include_current=state['phase'] == 'committed')
for path, _ in image_cache.prune(self.root, lock=False):
member_tx.log(f'removed unused image archive: {path}')
@@ -598,7 +606,7 @@ class NativeAdapter:
_current = {'journal': None, 'primary': None}
def run(vmid, recover=False, acknowledge_external_data=False):
def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None):
root = instances.ROOT
msg_info(translate('Checking the interrupted stack operation...') if recover
else translate('Checking the stack before the update...'))
@@ -658,6 +666,16 @@ def run(vmid, recover=False, acknowledge_external_data=False):
adapter = NativeAdapter(root, journal, plan, acknowledge_external_data)
adapter.preflight()
msg_ok(f"{translate('Stack checked:')} {len(plan['members'])} {translate('containers')}")
if keep_backup:
import oci_keep_backup
oci_keep_backup.validate(keep_backup)
if oci_keep_backup.dump_dir(keep_backup) is None:
for member in plan['members']:
msg_info(f"{translate('Creating a backup in')} {keep_backup}: CT {member['vmid']}...")
oci_keep_backup.before_update(member['vmid'], keep_backup)
msg_ok(f"{translate('Backup created in')} {keep_backup}")
else:
adapter.keep_backup = keep_backup
if any(mount['type'] == 'host-bind' for member in plan['members']
for mount in member.get('deployment', {}).get('mounts', [])):
msg_warn(translate('Host directories are not included in the backups and are not reverted by a recovery.'))
@@ -699,11 +717,12 @@ def main():
parser.add_argument('vmid', type=int)
parser.add_argument('--recover', action='store_true')
parser.add_argument('--acknowledge-external-data', action='store_true')
parser.add_argument('--keep-backup', metavar='STORAGE')
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges on the Proxmox node are required'))
try:
run(args.vmid, args.recover, args.acknowledge_external_data)
run(args.vmid, args.recover, args.acknowledge_external_data, args.keep_backup)
return 0
except BlockingIOError:
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))
+4 -1
View File
@@ -561,8 +561,11 @@ def normalize(record):
preserved = {key: single(key) for key in ('arch', 'ostype', 'cmode', 'console', 'tty', 'cpuunits',
'net0', 'net1', 'startup', 'hookscript', 'features', 'tags') if key in values}
devices = [{'key': key, 'value': single(key)} for key in values if re.fullmatch(r'dev[0-9]+', key)]
# The console log line is not replayed: the installer that rebuilds the
# member sets it itself, and replaying it too would leave two of them.
raw_runtime = [line for line in config.splitlines() if line.startswith('lxc.')
and line.partition(': ')[0] not in ('lxc.environment.runtime', 'lxc.init.cwd', 'lxc.signal.halt')]
and line.partition(': ')[0] not in ('lxc.environment.runtime', 'lxc.init.cwd',
'lxc.signal.halt', 'lxc.console.logfile')]
return {'schema_version': 1, 'deployment': plan, 'runtime': runtime,
'preserved_native': preserved, 'generated_files': copy.deepcopy(files),
'native_devices': devices, 'preserved_raw_runtime': raw_runtime,
+48 -20
View File
@@ -13,7 +13,7 @@ import tempfile
import oci_instances as instances
import oci_instance_transaction as transaction
from oci_installation_state import image_from_archive
from oci_ui import translate, msg_info, msg_ok, msg_error, msg_info2
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2
def repository(reference):
@@ -78,9 +78,28 @@ def resolve_archive(desired, config, current=None, check=None):
if archive.is_symlink():
raise ValueError(translate('Unsafe OCI archive path'))
verifier = Path(__file__).with_name('verify_oci_archive.py')
cached = archive.exists()
if not cached:
def intact(path):
try:
run_quiet([sys.executable, str(verifier), str(path)],
translate('The image did not pass the integrity check'))
except RuntimeError:
return False
return image_from_archive(str(path))['manifest_digest'] == digest
if archive.exists():
msg_info(translate('Verifying the image integrity...'))
if intact(archive):
msg_ok(translate('Using the verified image from the cache'))
return archive, digest
msg_warn(translate('The cached image is damaged; it will be downloaded again.'))
archive.unlink()
# A download can come back complete yet damaged when the connection drops
# and the transfer resumes; the integrity check catches it, and a second
# download is what repairs it.
for attempt in (1, 2):
msg_info(transaction.fit(f"{translate('Downloading the image:')} {reference}"))
transaction.log(f'download attempt {attempt}/2')
fd, name = tempfile.mkstemp(prefix='.proxmenux-update-', suffix='.tar', dir=archive.parent)
os.close(fd)
partial = Path(name)
@@ -90,22 +109,19 @@ def resolve_archive(desired, config, current=None, check=None):
'oci-archive:' + str(partial)], translate('Could not download the image'))
msg_ok(translate('Image downloaded'))
msg_info(translate('Verifying the image integrity...'))
run_quiet([sys.executable, str(verifier), str(partial)],
translate('The image did not pass the integrity check'))
if image_from_archive(str(partial))['manifest_digest'] != digest:
raise ValueError(translate('The downloaded image does not match its manifest'))
partial.chmod(0o644)
os.replace(partial, archive)
if intact(partial):
partial.chmod(0o644)
os.replace(partial, archive)
msg_ok(translate('Image integrity verified'))
return archive, digest
except RuntimeError:
if attempt == 2:
raise
finally:
partial.unlink(missing_ok=True)
else:
msg_info(translate('Verifying the image integrity...'))
run_quiet([sys.executable, str(verifier), str(archive)],
translate('The image did not pass the integrity check'))
if image_from_archive(str(archive))['manifest_digest'] != digest:
raise ValueError(translate('The cached image does not match the current digest'))
msg_ok(translate('Using the verified image from the cache') if cached else translate('Image integrity verified'))
return archive, digest
if attempt == 2:
raise RuntimeError(translate('Could not obtain an intact image after two attempts'))
msg_warn(translate('The image download did not complete correctly; downloading it again...'))
def kept_settings(changes, deployment):
@@ -121,7 +137,7 @@ def kept_settings(changes, deployment):
return kept
def update(vmid, acknowledge_external_data=False, proposal=None):
def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=None):
operation = 'recreate' if proposal is not None else 'update'
msg_info(translate('Checking the container before the update...') if operation == 'update'
else translate('Checking the container before recreating it...'))
@@ -140,11 +156,22 @@ def update(vmid, acknowledge_external_data=False, proposal=None):
if archive is None:
msg_ok(translate('The image is already up to date; nothing was changed.'))
return
file_storage = None
if keep_backup:
import oci_keep_backup
oci_keep_backup.validate(keep_backup)
if oci_keep_backup.dump_dir(keep_backup) is None:
msg_info(f"{translate('Creating a backup in')} {keep_backup}...")
oci_keep_backup.before_update(vmid, keep_backup)
msg_ok(f"{translate('Backup created in')} {keep_backup}")
else:
file_storage = keep_backup
kept = kept_settings(changes, desired['deployment'])
if kept:
msg_info2(f"{translate('Keeping the settings changed in Proxmox:')} {', '.join(kept)}")
transaction.apply(instances.ROOT, vmid, archive, operation, proposal=proposal,
registry_digest=digest, acknowledge_external_data=acknowledge_external_data)
registry_digest=digest, acknowledge_external_data=acknowledge_external_data,
keep_backup=file_storage)
def main():
@@ -152,12 +179,13 @@ def main():
parser.add_argument('vmid', type=int)
parser.add_argument('--acknowledge-external-data', action='store_true')
parser.add_argument('--proposal', type=Path)
parser.add_argument('--keep-backup', metavar='STORAGE')
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
try:
proposal = json.loads(args.proposal.read_text()) if args.proposal else None
update(args.vmid, args.acknowledge_external_data, proposal)
update(args.vmid, args.acknowledge_external_data, proposal, args.keep_backup)
return 0
except BlockingIOError:
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))