mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-03 04:00:44 +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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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> {' · '.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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}')
|
||||
|
||||
@@ -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'}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
Reference in New Issue
Block a user