feat(oci): run official container images as native LXC containers

Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-22 18:24:59 +02:00
co-authored by Claude Opus 5
parent b36498f215
commit bcabcb618c
670 changed files with 221410 additions and 215 deletions
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
from __future__ import annotations
import ipaddress
import json
import re
import subprocess
import sys
from pathlib import Path
from oci_ui import translate
def command_output(*command: str) -> str:
result = subprocess.run(command, text=True, capture_output=True, check=False)
return result.stdout
def existing_bridges() -> set[str]:
bridges: set[str] = set()
for line in command_output("ip", "-o", "link", "show").splitlines():
match = re.match(r"^\d+: ([^:@]+)", line)
if match:
bridges.add(match.group(1))
for path in Path("/etc/pve/lxc").glob("*.conf"):
text = path.read_text(encoding="utf-8", errors="ignore")
bridges.update(re.findall(r"(?:^|,)bridge=([^,\s]+)", text, re.MULTILINE))
interface_paths = [Path("/etc/network/interfaces")]
interface_paths.extend(Path("/etc/network/interfaces.d").glob("*"))
for path in interface_paths:
if not path.is_file():
continue
text = path.read_text(encoding="utf-8", errors="ignore")
bridges.update(
re.findall(r"^(?:auto|iface)\s+(vmbr\d+)\b", text, re.MULTILINE)
)
return bridges
def existing_networks() -> list[ipaddress.IPv4Network]:
networks: list[ipaddress.IPv4Network] = []
for line in command_output("ip", "-4", "route", "show").splitlines():
token = line.split(maxsplit=1)[0]
if token == "default":
continue
try:
networks.append(ipaddress.ip_network(token, strict=False))
except ValueError:
pass
for path in Path("/etc/pve/lxc").glob("*.conf"):
text = path.read_text(encoding="utf-8", errors="ignore")
for address in re.findall(r"(?:^|,)ip=(\d+\.\d+\.\d+\.\d+/\d+)", text, re.MULTILINE):
try:
networks.append(ipaddress.ip_interface(address).network)
except ValueError:
pass
return networks
def allocate_network(
deployment: dict,
bridges: set[str],
occupied: list[ipaddress.IPv4Network],
) -> tuple[str, ipaddress.IPv4Network] | None:
network = deployment.get("network", {})
if network.get("private_allocation") != "automatic":
return None
original = ipaddress.ip_network(network["private_subnet"], strict=True)
if original.prefixlen != 24:
raise ValueError(translate("Automatic private network allocation requires a /24 subnet"))
selected: tuple[str, ipaddress.IPv4Network] | None = None
for index in range(0, 178):
bridge = f"vmbr{10 + index}"
candidate = ipaddress.ip_network(f"10.77.{index}.0/24")
if bridge in bridges or any(candidate.overlaps(item) for item in occupied):
continue
selected = bridge, candidate
break
if selected is None:
raise SystemExit(translate("No free ProxMenux private /24 network is available"))
bridge, candidate = selected
for key, value in list(network.items()):
if not key.endswith("_address") or not isinstance(value, str):
continue
interface = ipaddress.ip_interface(value)
if interface.ip not in original:
continue
host_offset = int(interface.ip) - int(original.network_address)
network[key] = f"{candidate.network_address + host_offset}/{candidate.prefixlen}"
network["private_bridge"] = bridge
network["private_subnet"] = str(candidate)
network["private_allocation"] = "allocated"
return bridge, candidate
def main() -> int:
if len(sys.argv) != 2:
raise SystemExit("usage: allocate_private_network.py DEPLOYMENT.json")
path = Path(sys.argv[1])
deployment = json.loads(path.read_text(encoding="utf-8"))
try:
selected = allocate_network(deployment, existing_bridges(), existing_networks())
except ValueError as exc:
raise SystemExit(str(exc)) from exc
if selected is None:
return 0
bridge, candidate = selected
path.write_text(json.dumps(deployment, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
print(f"{translate('Private network assigned automatically:')} {bridge} ({candidate})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import shutil
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
from oci_ui import translate
def fail(message: str) -> None:
raise SystemExit(message)
def resolve_path(rootfs: Path, candidates: list[str]) -> Path:
rootfs = rootfs.resolve()
for candidate in candidates:
if not candidate.startswith("/") or "\x00" in candidate:
fail(f"{translate('Invalid configuration path:')} {candidate!r}")
resolved = (rootfs / candidate.lstrip("/")).resolve()
if rootfs not in resolved.parents:
fail(f"{translate('The path escapes the rootfs:')} {candidate}")
if resolved.is_file():
return resolved
fail(translate("Jellyfin has not created encoding.xml in any declared path"))
def update_encoding(rootfs: Path, configuration: dict[str, object]) -> tuple[Path, bool]:
path = resolve_path(rootfs, list(configuration.get("candidate_paths", [])))
tree = ET.parse(path)
root = tree.getroot()
changed = False
for tag, requested in dict(configuration.get("settings", {})).items():
if not isinstance(requested, str):
fail(f"{translate('The setting has no final value:')} {tag}")
element = root.find(tag)
if element is None:
element = ET.SubElement(root, tag)
changed = True
if (element.text or "") != requested:
element.text = requested
changed = True
for tag, requested_values in dict(configuration.get("lists", {})).items():
if not isinstance(requested_values, list) or not all(
isinstance(value, str) for value in requested_values
):
fail(f"{translate('Invalid list:')} {tag}")
element = root.find(tag)
if element is None:
element = ET.SubElement(root, tag)
changed = True
existing = [child.text or "" for child in list(element)]
if existing != requested_values:
for child in list(element):
element.remove(child)
for value in requested_values:
ET.SubElement(element, "string").text = value
changed = True
if not changed:
return path, False
backup = path.with_name(f"{path.name}.bak-proxmenux")
if not backup.exists():
shutil.copy2(path, backup)
os.chown(backup, path.stat().st_uid, path.stat().st_gid)
stat = path.stat()
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary = Path(temporary_name)
try:
with os.fdopen(fd, "wb") as stream:
tree.write(stream, encoding="utf-8", xml_declaration=True)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, stat.st_mode)
os.chown(temporary, stat.st_uid, stat.st_gid)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
return path, True
def main() -> None:
if len(sys.argv) != 3:
fail(f"{translate('Usage:')} configure_jellyfin_encoding.py ROOTFS CONFIGURATION_JSON")
rootfs = Path(sys.argv[1])
configuration = json.loads(sys.argv[2])
path, changed = update_encoding(rootfs, configuration)
state = "updated" if changed else "already applied"
print(f"Jellyfin configuration {state}: /{path.relative_to(rootfs.resolve())}")
if __name__ == "__main__":
main()
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
set -Eeuo pipefail
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
DRY_RUN=${3:-0}
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SCRIPT_DIR/oci_ui.sh"
PUBLISHER_SOURCE="${SCRIPT_DIR}/rclone_mount_publish.py"
die() {
stop_spinner
msg_error "$*"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
exit 1
}
UNEXPECTED_FAILURE=0
report_unexpected_failure() {
local status=${1:-$?}
stop_spinner
if (( status != 0 && UNEXPECTED_FAILURE == 1 )); then
msg_error "$(translate "The configuration stopped because of an unexpected error")"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
fi
return 0
}
trap 'report_unexpected_failure' EXIT
trap 'UNEXPECTED_FAILURE=1; oci_log "Command failed at line $LINENO (${FUNCNAME[0]:-main})"' ERR
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$(translate "Missing required command:") $1"
}
jqr() {
jq -er "$1" "$DEPLOYMENT_FILE"
}
safe_absolute_path() {
local path=$1
[[ $path == /* && $path != / && $path != *[[:space:],]* && $path != *..* ]]
}
[[ $EUID -eq 0 ]] || die "$(translate "The configuration must run as root on Proxmox VE")"
oci_log_init "rclone-mount"
for command in pct jq python3 mountpoint findmnt systemctl systemd-run lxc-info base64; do
require_command "$command"
done
[[ -r $PUBLISHER_SOURCE ]] || die "$(translate "The FUSE publication helper was not found")"
VMID=$(jqr '.vmid')
REMOTE_NAME=$(jqr '.remote_name')
REMOTE_PATH=$(jq -r '.remote_path // ""' "$DEPLOYMENT_FILE")
MOUNT_NAME=$(jqr '.mount_name')
VFS_CACHE_MODE=$(jqr '.vfs_cache_mode')
SHARED_PARENT=$(jqr '.shared_mount_root_parent')
SHARED_RW=$(jqr '.shared_mount_root')
SHARED_RO=$(jqr '.shared_mount_read_only_root')
[[ $VMID =~ ^[0-9]+$ ]] || die "$(translate "Invalid VMID")"
[[ $REMOTE_NAME =~ ^[A-Za-z0-9._-]{1,64}$ ]] || die "$(translate "Invalid remote name")"
[[ $MOUNT_NAME =~ ^[A-Za-z0-9._-]{1,64}$ ]] || die "$(translate "Invalid mount name")"
[[ $REMOTE_PATH != /* && $REMOTE_PATH != *$'\n'* && $REMOTE_PATH != *$'\r'* ]] \
|| die "$(translate "Invalid remote path")"
case "$VFS_CACHE_MODE" in off|minimal|writes|full) ;; *) die "$(translate "Invalid VFS cache mode")" ;; esac
for path in "$SHARED_PARENT" "$SHARED_RW" "$SHARED_RO"; do
safe_absolute_path "$path" || die "$(translate "Invalid host path:") $path"
done
[[ $SHARED_RW == "$SHARED_PARENT"/* && $SHARED_RO == "$SHARED_PARENT"/* ]] \
|| die "$(translate "The published views must be inside the common root")"
pct config "$VMID" >/dev/null 2>&1 || die "$(translate "The container does not exist:") CT $VMID"
CONFIG=$(cat "/etc/pve/lxc/${VMID}.conf")
grep -q '^unprivileged: 0$' <<<"$CONFIG" || die "$(translate "Rclone mount requires a privileged container")"
grep -Eq '^features: .*(^|,)fuse=1(,|$)' <<<"$CONFIG" \
|| grep -q 'fuse=1' <<<"$CONFIG" \
|| die "$(translate "The container does not have the fuse=1 feature enabled")"
msg_info "$(translate "Checking the remote...")"
STATUS=$(pct status "$VMID" | awk '{print $2}')
if [[ $STATUS != running ]]; then
oci_log "Starting CT $VMID temporarily to check the remote"
oci_quiet pct start "$VMID"
for _ in $(seq 1 30); do
pct exec "$VMID" -- /usr/local/bin/rclone version >/dev/null 2>&1 && break
sleep 1
done
fi
REMOTES=$(pct exec "$VMID" -- /usr/local/bin/rclone listremotes \
--config /config/rclone/rclone.conf 2>/dev/null || true)
grep -Fxq "${REMOTE_NAME}:" <<<"$REMOTES" \
|| die "$(translate "The remote does not exist; create and authorize it first in the WebUI:") ${REMOTE_NAME}:"
msg_ok "$(translate "Remote verified:") ${REMOTE_NAME}:"
if [[ $DRY_RUN == 1 ]]; then
msg_ok "$(translate "Dry run completed; the container and the mounts were not changed.")"
exit 0
fi
RC_USER=$(sed -n 's/^lxc\.environment\.runtime: RCLONE_RC_USER=//p' "/etc/pve/lxc/${VMID}.conf" | tail -n1)
RC_PASS=$(sed -n 's/^lxc\.environment\.runtime: RCLONE_RC_PASS=//p' "/etc/pve/lxc/${VMID}.conf" | tail -n1)
if [[ -z $RC_USER || -z $RC_PASS ]]; then
RC_USER=$(pct exec "$VMID" -- sh -c "sed -n 's/^username=//p' /config/rclone/webui.credentials" 2>/dev/null || true)
RC_PASS=$(pct exec "$VMID" -- sh -c "sed -n 's/^password=//p' /config/rclone/webui.credentials" 2>/dev/null || true)
fi
[[ -n $RC_USER && -n $RC_PASS ]] || die "$(translate "The persistent WebUI credentials were not found")"
msg_info "$(translate "Applying the mount mode...")"
oci_quiet pct exec "$VMID" -- mkdir -p "/data/mounts/${MOUNT_NAME}"
oci_quiet pct stop "$VMID"
BACKUP_CONF=$(mktemp "/tmp/proxmenux-rclone-${VMID}.conf.XXXXXX")
cp "/etc/pve/lxc/${VMID}.conf" "$BACKUP_CONF"
ROLLBACK=1
rollback() {
local status=$?
report_unexpected_failure "$status"
if (( status != 0 && ROLLBACK == 1 )); then
msg_info "$(translate "Restoring the previous Rclone configuration...")"
systemctl stop "proxmenux-rclone-publish-${VMID}.service" >/dev/null 2>&1 || true
mountpoint -q "$SHARED_RO/$MOUNT_NAME" && umount -l "$SHARED_RO/$MOUNT_NAME" >>"$OCI_LOG" 2>&1 || true
mountpoint -q "$SHARED_RW/$MOUNT_NAME" && umount -l "$SHARED_RW/$MOUNT_NAME" >>"$OCI_LOG" 2>&1 || true
cp "$BACKUP_CONF" "/etc/pve/lxc/${VMID}.conf" || true
pct start "$VMID" >/dev/null 2>&1 || true
msg_ok "$(translate "Previous Rclone configuration restored")"
fi
rm -f "$BACKUP_CONF"
exit "$status"
}
trap rollback EXIT
install -d -m 0755 /usr/local/libexec /var/lib/vz/snippets \
"$SHARED_PARENT" "$SHARED_RW/$MOUNT_NAME" "$SHARED_RO/$MOUNT_NAME"
install -m 0755 "$PUBLISHER_SOURCE" /usr/local/libexec/proxmenux-oci-mount-publish
WAITER=$(jq -er '.proxmox.laboratory_contract.generated_assets["mount-publication-waiter"].content' "$TEMPLATE_FILE")
printf '%s\n' "$WAITER" >/usr/local/libexec/proxmenux-oci-mount-wait
chmod 0755 /usr/local/libexec/proxmenux-oci-mount-wait
HOOK=$(jq -er '.proxmox.laboratory_contract.generated_assets["proxmox-hookscript"].content_template' "$TEMPLATE_FILE")
HOOK=${HOOK//\{\{mount_name\}\}/$MOUNT_NAME}
HOOK=${HOOK//\{\{shared_mount_root\}\}/$SHARED_RW}
HOOK=${HOOK//\{\{shared_mount_read_only_root\}\}/$SHARED_RO}
HOOK=${HOOK//\{\{shared_mount_root_parent\}\}/$SHARED_PARENT}
HOOK_PATH="/var/lib/vz/snippets/proxmenux-rclone-${VMID}-fuse-hook.sh"
printf '%s\n' "$HOOK" >"$HOOK_PATH"
chmod 0755 "$HOOK_PATH"
oci_quiet pct mount "$VMID"
ROOTFS="/var/lib/lxc/${VMID}/rootfs"
install -d -m 0755 "$ROOTFS/usr/local/bin" "$ROOTFS/config/rclone"
printf 'username=%s\npassword=%s\n' "$RC_USER" "$RC_PASS" \
>"$ROOTFS/config/rclone/webui.credentials"
chmod 0600 "$ROOTFS/config/rclone/webui.credentials"
WRAPPER=$(jq -er '.proxmox.laboratory_contract.generated_assets["mount-mode-wrapper"].content_template' "$TEMPLATE_FILE")
REMOTE_NAME_B64=$(printf '%s' "$REMOTE_NAME" | base64 -w0)
REMOTE_PATH_B64=$(printf '%s' "$REMOTE_PATH" | base64 -w0)
WRAPPER=${WRAPPER//\{\{remote_name_base64\}\}/$REMOTE_NAME_B64}
WRAPPER=${WRAPPER//\{\{remote_path_base64\}\}/$REMOTE_PATH_B64}
WRAPPER=${WRAPPER//\{\{mount_name\}\}/$MOUNT_NAME}
WRAPPER=${WRAPPER//\{\{vfs_cache_mode\}\}/$VFS_CACHE_MODE}
WRAPPER=${WRAPPER//\{\{webui_port\}\}/5572}
printf '%s\n' "$WRAPPER" >"$ROOTFS/usr/local/bin/rclone-mount-lxc-start"
chmod 0755 "$ROOTFS/usr/local/bin/rclone-mount-lxc-start"
oci_quiet pct unmount "$VMID"
sed -i -E '/^lxc\.environment\.runtime: RCLONE_RC_(USER|PASS)=/d' "/etc/pve/lxc/${VMID}.conf"
oci_quiet pct set "$VMID" --entrypoint /usr/local/bin/rclone-mount-lxc-start
sed -i -E '/^hookscript:/d' "/etc/pve/lxc/${VMID}.conf"
oci_quiet pct set "$VMID" --hookscript "local:snippets/$(basename "$HOOK_PATH")"
msg_ok "$(translate "Mount mode applied")"
msg_info "$(translate "Starting Rclone and waiting for the FUSE mount...")"
oci_quiet pct start "$VMID"
PUBLISHED_RW="$SHARED_RW/$MOUNT_NAME"
PUBLISHED_RO="$SHARED_RO/$MOUNT_NAME"
for attempt in $(seq 1 120); do
if mountpoint -q "$PUBLISHED_RW" && mountpoint -q "$PUBLISHED_RO"; then
break
fi
[[ $(pct status "$VMID" 2>/dev/null) == 'status: running' ]] \
|| die "$(translate "The container stopped before publishing the mount")"
msg_progress "$(translate "Waiting for the FUSE mount:") ${attempt}/120 s"
sleep 1
done
mountpoint -q "$PUBLISHED_RW" || die "$(translate "The read/write view was not published")"
mountpoint -q "$PUBLISHED_RO" || die "$(translate "The read-only view was not published")"
findmnt -T "$PUBLISHED_RO" -n -o VFS-OPTIONS | tr ',' '\n' | grep -qx ro \
|| die "$(translate "The read-only view does not apply the expected protection")"
IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
RESULT=$(jq -nc --argjson vmid "$VMID" --arg ip "$IP" --arg remote "$REMOTE_NAME" \
--arg mount "$MOUNT_NAME" --arg rw "$PUBLISHED_RW" --arg ro "$PUBLISHED_RO" \
--arg log "$OCI_LOG" \
'{vmid:$vmid,ip:(if $ip == "" then null else $ip end),remote:$remote,mount_name:$mount,read_write_path:$rw,read_only_path:$ro,log:$log}')
ROLLBACK=0
msg_ok "$(translate "Rclone mount active")"
printf 'PROXMENUX_RESULT=%s\n' "$(printf '%s' "$RESULT" | base64 -w0)"
+172
View File
@@ -0,0 +1,172 @@
"""Verify the nested HAOS runtime without accepting its temporary landing page."""
import argparse
import ipaddress
import json
import os
import shutil
import subprocess
import sys
import time
import traceback
import urllib.error
import urllib.request
from oci_ui import log, msg_progress, translate
REQUIRED = ('hassio_supervisor', 'homeassistant', 'hassio_cli', 'hassio_dns',
'hassio_audio', 'hassio_multicast', 'hassio_observer')
OCI_LOG = os.environ.get('OCI_LOG')
class Pending(RuntimeError):
"""A known first-boot stage, safe to show without printing container secrets."""
def note(text):
"""Detail for the run log; without one, for stderr."""
try:
if OCI_LOG:
log(OCI_LOG, text)
return
except OSError:
pass
print(text, file=sys.stderr, flush=True)
def show_progress(elapsed, timeout, reason):
text = f"{translate('Waiting for Home Assistant OS...')} {elapsed}/{timeout} s · {reason}"
width = max(20, shutil.get_terminal_size((80, 24)).columns - 6)
if len(text) > width:
text = text[:width - 1] + '…'
msg_progress(text)
def pending_reason(items, supervisor_logs=''):
if 'No Supervisor connectivity' in supervisor_logs:
return translate('Supervisor reports no connectivity; retrying to get versions and install components')
running = {i.get('Name', '').lstrip('/') for i in items
if isinstance(i, dict) and i.get('State', {}).get('Running') is True}
missing = [name for name in REQUIRED if name not in running]
if missing:
return translate('Pending components:') + ' ' + ', '.join(missing)
return translate('Core is still on the initial installation page')
def capture(argv, timeout=15, merge_stderr=False):
return subprocess.run(argv, check=True, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE, text=True,
timeout=timeout).stdout
def supervisor_ready(value):
return (isinstance(value, dict) and value.get('result') == 'ok'
and isinstance(value.get('data'), dict)
and value.get('data', {}).get('healthy') is True
and value.get('data', {}).get('supported') is True)
def containers_ready(items):
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
return False
by_name = {item.get('Name', '').lstrip('/'): item for item in items}
return (all(by_name.get(name, {}).get('State', {}).get('Running') is True
for name in REQUIRED)
and 'landingpage' not in by_name['homeassistant'].get('Config', {}).get('Image', '').lower()
and bool(by_name['homeassistant'].get('Config', {}).get('Image')))
def http_ready(url, timeout=5):
# Do not route private healthchecks through an environment HTTP proxy.
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
with opener.open(url, timeout=timeout) as response:
return response.status == 200
except (OSError, urllib.error.URLError):
return False
def probe(vmid, ip, remaining):
def run(args):
return capture(args, timeout=max(0.1, min(15, remaining())))
if run(['pct', 'status', str(vmid)]).strip() != 'status: running':
raise RuntimeError(translate('The LXC has stopped'))
docker = ['pct', 'exec', str(vmid), '--', 'docker', '-H', 'unix:///run/docker-real.sock']
try:
containers = json.loads(run(docker + ['inspect', *REQUIRED]))
except subprocess.CalledProcessError as error:
# docker inspect returns existing objects and exit 1 for missing names.
containers = json.loads(error.stdout or '[]')
if not containers_ready(containers):
logs = ''
try:
logs = capture(docker + ['logs', '--tail', '25', 'hassio_supervisor'],
timeout=max(0.1, min(15, remaining())), merge_stderr=True)
except subprocess.SubprocessError:
pass
raise Pending(pending_reason(containers, logs))
supervisor = json.loads(run(docker + ['exec', 'hassio_cli', 'ha', '--raw-json', 'supervisor', 'info']))
if not supervisor_ready(supervisor):
raise Pending(translate('Supervisor does not confirm healthy and supported yet'))
if not http_ready(f'http://{ip}:4357/', max(0.1, min(5, remaining()))):
raise Pending(translate('Observer is not responding on port 4357'))
for port in (80, 8123):
if http_ready(f'http://{ip}:{port}/', max(0.1, min(5, remaining()))):
return [{'label': 'Home Assistant', 'url': f'http://{ip}:{port}/'},
{'label': 'Home Assistant Observer', 'url': f'http://{ip}:4357/'}]
raise Pending(translate('Core is running, but not responding over HTTP on 80/8123'))
def wait_ready(vmid, ip, timeout):
started = time.monotonic()
remaining = lambda: timeout - (time.monotonic() - started)
last_reason = None
while remaining() > 0:
reason = translate('Docker/CLI not available yet or no valid answer')
try:
urls = probe(vmid, ip, remaining)
if urls and remaining() > 0:
note('HAOS: Supervisor healthy/supported, Core and internal services running.')
return urls
except Pending as error:
reason = str(error)
except (subprocess.SubprocessError, ValueError, KeyError, TypeError):
# Missing CLI/containers are expected while upstream pulls its images.
pass
elapsed = int(time.monotonic() - started)
if reason != last_reason:
note(f'Waiting for HAOS: {elapsed}/{timeout}s; {reason}.')
last_reason = reason
show_progress(elapsed, timeout, reason)
time.sleep(max(0, min(5, remaining())))
raise RuntimeError(translate('Time is up; Home Assistant OS could not be confirmed as running'))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--vmid', type=int, required=True)
parser.add_argument('--ip', required=True)
parser.add_argument('--timeout', type=int, default=1200)
args = parser.parse_args()
# stdout carries only the JSON result; progress lines go to stderr.
result_stream = sys.stdout
sys.stdout = sys.stderr
try:
ipaddress.IPv4Address(args.ip)
if args.vmid < 100 or not 60 <= args.timeout <= 3600:
parser.error(translate('Invalid VMID or timeout'))
urls = wait_ready(args.vmid, args.ip, args.timeout)
except RuntimeError as error:
note(str(error))
return 1
except Exception:
note(traceback.format_exc())
return 1
finally:
sys.stdout = result_stream
print(json.dumps(urls))
return 0
if __name__ == '__main__':
sys.exit(main())
+775
View File
@@ -0,0 +1,775 @@
#!/usr/bin/env python3
"""Host-side orchestration; only standard-library dependencies are needed on Proxmox."""
from __future__ import annotations
import base64
import copy
import configparser
import fcntl
import hashlib
import http.cookiejar
import ipaddress
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import tempfile
import time
import urllib.request
import urllib.parse
import uuid
import xml.etree.ElementTree as ET
from allocate_private_network import allocate_network, existing_bridges, existing_networks
import oci_instances
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2, stop_spinner, log
HERE = Path(__file__).resolve().parent
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
LOG = os.environ.get('OCI_LOG') or None
RESULT_MARKER = b'PROXMENUX_RESULT='
ERROR_REPORTED = False
class ServiceFailed(RuntimeError):
"""The child installer already printed its own error and log tail."""
def access_address(address, gateway):
"""ip= and gw= options of an access interface: DHCP or a static IPv4."""
if address == 'dhcp':
return 'ip=dhcp'
try:
interface = ipaddress.IPv4Interface(address) if '/' in address else None
router = ipaddress.IPv4Address(gateway) if gateway else None
except ValueError:
interface = None
if interface is None or (router is not None and (router not in interface.network or router == interface.ip)):
raise RuntimeError(f"{translate('Invalid access address:')} {address} {gateway or ''}".rstrip())
return f'ip={interface}' + (f',gw={router}' if router else '')
def init_log(name):
"""Private run log shared with every child installer through OCI_LOG."""
global LOG
if not LOG:
LOG_DIR.mkdir(parents=True, exist_ok=True)
try:
LOG_DIR.chmod(0o700)
except OSError:
pass
safe = re.sub(r'[^A-Za-z0-9._-]', '_', name or 'stack')
path = LOG_DIR / f"{safe}-{time.strftime('%Y%m%d-%H%M%S')}.log"
os.close(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600))
path.chmod(0o600)
LOG = str(path)
os.environ['OCI_LOG'] = LOG
def log_tail(lines=12):
if not LOG:
return []
try:
content = Path(LOG).read_text(errors='replace').splitlines()
except OSError:
return []
return content[-lines:]
def report_error(text, tail=True):
"""msg_error plus the end of the run log, like die() in the bash installers."""
global ERROR_REPORTED
ERROR_REPORTED = True
msg_error(text)
if not LOG:
return
if tail:
for line in log_tail():
sys.stderr.write(' '+line+'\n')
sys.stderr.write(f" {translate('Full log:')} {LOG}\n")
sys.stderr.flush()
def persist_instances(deployment, services, primary_id=None):
template = json.loads(Path(sys.argv[1]).read_text()) if primary_id is not None else {}
with oci_instances.locked(oci_instances.ROOT):
if primary_id is not None:
oci_instances.save_assembly(oci_instances.ROOT, primary_id, template, deployment, services)
oci_instances.resume_assembly(oci_instances.ROOT, primary_id)
else:
oci_instances.publish_stack(oci_instances.ROOT, None, template, deployment, services)
def run(*args, capture=True, timeout=180):
argv = list(map(str,args))
if capture:
try:
return subprocess.run(argv, check=True, text=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout).stdout
except subprocess.CalledProcessError as error:
log(LOG, '$ '+' '.join(argv)+'\n'+(error.stdout or '')+(error.stderr or ''))
raise
# Command output belongs to the run log, never to the terminal.
with open(LOG or os.devnull, 'a') as output:
return subprocess.run(argv, check=True, text=True, stdout=output,
stderr=subprocess.STDOUT, timeout=timeout).stdout
def exists(vmid):
return any(Path('/etc/pve/nodes').glob(f'*/lxc/{vmid}.conf')) or any(Path('/etc/pve/nodes').glob(f'*/qemu-server/{vmid}.conf'))
def retire_stale_contract(path, primary_id):
if not path.exists():
return
if path.is_symlink():
raise RuntimeError(translate('The previous stack contract is not safe; review it before reusing it'))
try:
contract = json.loads(path.read_text())
if contract.get('schema') != 1 or not isinstance(contract.get('dependencies'),list):
raise ValueError('unrecognized structure')
ids = {primary_id}
for dependency in contract['dependencies']:
vmid = dependency['vmid']
if type(vmid) is not int or vmid < 100:
raise ValueError('invalid VMID')
ids.add(vmid)
except (ValueError, KeyError, TypeError, AttributeError) as error:
raise RuntimeError(translate('The previous stack contract is not valid; it is not archived automatically')) from error
live = sorted(vmid for vmid in ids if exists(vmid))
if live:
raise RuntimeError(f"{translate('The previous stack contract still has containers or VMs:')} {', '.join(map(str,live))}")
archive = path.with_name(f'proxmenux-retired-stack-{primary_id}-{uuid.uuid4().hex}.json')
path.rename(archive)
msg_info2(f"{translate('Orphan stack contract archived:')} CT {primary_id} → {archive}")
log(LOG, 'The shared hookscript is kept.')
def write_json(path, value):
path.write_text(json.dumps(value,indent=2)+'\n')
path.chmod(0o600)
def create_service(service, directory):
template = directory / 'template.json'
deployment = directory / 'deployment.json'
write_json(template, service['template'])
child_plan = copy.deepcopy(service['deployment'])
child_plan['mounts'] = []
child_plan['stack_managed'] = True
write_json(deployment, child_plan)
# The child shares this run log; its steps are relayed as they are drawn
# (spinner frames included) and its result line is kept (one stack result).
environment = dict(os.environ, OCI_LOG=LOG or '',
OCI_SPINNER='1' if sys.stdout.isatty() or os.environ.get('OCI_SPINNER') == '1' else '0')
process = subprocess.Popen(['bash', str(HERE/'install_oci.sh'),str(template),str(deployment),'0'],
stdout=subprocess.PIPE,stderr=subprocess.STDOUT,env=environment)
result = None
try:
result = relay_child_output(process.stdout)
if process.wait() or result is None:
raise ServiceFailed(f"{translate('Could not create the service:')} {service['name']}")
finally:
if process.poll() is None:
process.terminate()
process.wait(timeout=30)
return result
def relay_child_output(stream):
"""Copy the child output to the terminal as it arrives; return its PROXMENUX_RESULT."""
output = sys.stdout.buffer
sys.stdout.flush()
pending = b''
line_start = True
result = None
def parse(line):
return json.loads(base64.b64decode(line[len(RESULT_MARKER):].strip()))
while True:
chunk = os.read(stream.fileno(), 4096)
if not chunk:
break
pending += chunk
while pending:
if line_start and pending.startswith(RESULT_MARKER):
end = pending.find(b'\n')
if end < 0:
break
result = parse(pending[:end])
pending = pending[end+1:]
continue
if line_start and RESULT_MARKER.startswith(pending):
break
cuts = [index for index in (pending.find(b'\n'), pending.find(b'\r')) if index >= 0]
if cuts:
cut = min(cuts)+1
output.write(pending[:cut])
pending = pending[cut:]
line_start = True
else:
output.write(pending)
pending = b''
line_start = False
output.flush()
if pending:
if line_start and pending.startswith(RESULT_MARKER):
result = parse(pending)
else:
output.write(pending)
output.flush()
return result
def lan_access_urls(services, subnet, strict=True):
urls = []
for service in services:
endpoint = service['healthcheck'].get('endpoint')
if not endpoint or not (service['main'] or service.get('frontend')):
continue
try:
addresses = run('lxc-info','-n',service['vmid'],'-iH').splitlines()
candidates = []
for value in addresses:
try:
address = ipaddress.ip_address(value.strip())
except ValueError:
continue
if address.version==4 and address not in subnet and not (
address.is_loopback or address.is_link_local or address.is_unspecified or address.is_multicast):
candidates.append(str(address))
if not candidates:
raise RuntimeError(f"{translate('No LAN address was obtained for the service:')} {service['name']}")
urls.append({'label':service['name'],
'url':f"{endpoint['scheme']}://{candidates[0]}:{endpoint['port']}{endpoint['path']}"})
except Exception:
if strict:
raise
return urls
def attach_mounts(service, temporary):
"""Populate new managed volumes from the image, preserving its ownership."""
vmid = service['vmid']
root = Path(f'/var/lib/lxc/{vmid}/rootfs')
for index, mount in enumerate(service['deployment']['mounts']):
target = root / mount['container_path'].lstrip('/')
seed = temporary / f'seed-{vmid}-{index}'
seed.mkdir()
run('pct','mount',vmid)
try:
if not target.resolve().is_relative_to(root.resolve()) or target.is_symlink():
raise RuntimeError(translate('Unsafe volume path'))
if target.is_dir():
stat = target.stat()
owner = (stat.st_uid,stat.st_gid,stat.st_mode & 0o777)
if mount['type']=='managed-volume':
run('cp','-a',str(target)+'/.',str(seed))
else:
owner = (100000,100000,0o755)
finally:
run('pct','unmount',vmid)
if mount['type']=='managed-volume':
value=f"{mount['source']}:{mount['size_gb']},mp={mount['container_path']},backup=1"
else:
source=Path(mount['source'])
if not source.is_absolute() or ',' in str(source) or '\n' in str(source):
raise RuntimeError(translate('Invalid shared path'))
if not source.exists():
source.mkdir(parents=True)
os.chown(source,*owner[:2])
source.chmod(owner[2])
if not source.is_dir():
raise RuntimeError(translate('The shared destination is not a directory'))
value=f"{source},mp={mount['container_path']},backup=0"
run('pct','set',vmid,f'--mp{index}',value)
if mount['type']=='managed-volume':
run('pct','mount',vmid)
try:
lost=target/'lost+found'
if lost.is_dir() and not lost.is_symlink():
lost.rmdir()
run('cp','-a',str(seed)+'/.',str(target))
os.chown(target,*owner[:2])
target.chmod(owner[2])
finally:
run('pct','unmount',vmid)
if mount.get('read_only'):
config = run('pct', 'config', vmid)
current = next(line.split(': ', 1)[1] for line in config.splitlines()
if line.startswith(f'mp{index}: '))
run('pct', 'set', vmid, f'--mp{index}', current + ',ro=1')
def wait_web(primary, url):
deadline = time.monotonic()+primary['healthcheck']['timeout_seconds']
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
while True:
if run('pct','status',primary['vmid']).strip()!='status: running':
raise RuntimeError(f"{translate('The container stopped:')} {primary['name']} (CT {primary['vmid']})")
try:
with opener.open(url,timeout=4) as response:
if response.status<400:
return
except Exception:
pass
if time.monotonic()>=deadline:
raise RuntimeError(f"{translate('The application did not pass its HTTP check:')} {primary['name']}")
time.sleep(3)
def qbittorrent_password_hash(password):
salt = os.urandom(16)
digest = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000, 64)
return base64.b64encode(salt).decode()+':'+base64.b64encode(digest).decode()
def seed_qbittorrent(service):
"""Seed the image's official defaults in its new persistent config volume."""
vmid = service['vmid']
root = Path(f'/var/lib/lxc/{vmid}/rootfs')
run('pct','mount',vmid)
try:
directory = root/'config/qBittorrent'
defaults = root/'defaults/qBittorrent.conf'
target = directory/'qBittorrent.conf'
for path in (directory, target, defaults):
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise RuntimeError(translate('Unsafe qBittorrent configuration path'))
if target.exists():
raise RuntimeError(translate('qBittorrent already has a configuration; it is not overwritten'))
config = configparser.ConfigParser(interpolation=None)
config.optionxform = str
config.read_string(defaults.read_text())
if not config.has_section('Preferences'):
raise RuntimeError(translate('Unrecognized qBittorrent configuration format'))
config['Preferences'][r'WebUI\Username'] = service['setup_credentials']['username']
config['Preferences'][r'WebUI\Password_PBKDF2'] = '"@ByteArray('+qbittorrent_password_hash(service['setup_credentials']['password'])+')"'
directory.mkdir(exist_ok=True,mode=0o700)
owner = 101000 if service['deployment']['security']['unprivileged'] else 1000
os.chown(directory,owner,owner)
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd,'w') as output:
config.write(output,space_around_delimiters=False)
os.chown(target,owner,owner)
finally:
run('pct','unmount',vmid)
def same_download_path(actual, expected):
return isinstance(actual,str) and actual.startswith('/') and actual.rstrip('/')==expected.rstrip('/')
def configure_qbittorrent(service, selected):
port = service['healthcheck']['endpoint']['port']
base = f"http://{service['ip']}:{port}"
cookies = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}),urllib.request.HTTPCookieProcessor(cookies))
def api(path, values=None, with_status=False):
request = urllib.request.Request(base+'/api/v2/'+path,
data=urllib.parse.urlencode(values).encode() if values is not None else None,
headers={'Referer':base+'/'})
with opener.open(request,timeout=30) as response:
body = response.read()
return (getattr(response,'status',200),body) if with_status else body
status, body = api('auth/login',service['setup_credentials'],with_status=True)
# 5.2 uses HTTP 204 and a port-scoped cookie; older releases return "Ok.".
legacy = status==200 and body.strip()==b'Ok.'
modern = status==204 and not body.strip()
cookie_name = f'QBT_SID_{port}' if modern else 'SID'
if not (legacy or modern) or not any(c.name==cookie_name and c.value for c in cookies):
raise RuntimeError(translate('qBittorrent: invalid login response or missing session cookie'))
try:
probe = json.loads(api('app/preferences'))
if not isinstance(probe,dict) or not isinstance(probe.get('save_path'),str):
raise RuntimeError(translate('qBittorrent: authenticated access to the preferences could not be verified'))
preferences = {'save_path':'/data/downloads/','temp_path':'/data/downloads/incomplete/', 'temp_path_enabled':True}
api('app/setPreferences',{'json':json.dumps(preferences)})
actual = json.loads(api('app/preferences'))
if (not all(same_download_path(actual.get(k),preferences[k]) for k in ('save_path','temp_path'))
or actual.get('temp_path_enabled') is not True):
raise RuntimeError(translate('qBittorrent did not apply the download paths'))
categories = json.loads(api('torrents/categories'))
for app,category in [('sonarr','tv'),('radarr','movies')]:
if app not in selected:
continue
path = '/data/downloads/'+category
action = 'editCategory' if category in categories else 'createCategory'
api('torrents/'+action,{'category':category,'savePath':path})
if not same_download_path(json.loads(api('torrents/categories')).get(category,{}).get('savePath'),path):
raise RuntimeError(f"{translate('qBittorrent did not apply the category:')} {category}")
finally:
api('auth/logout',{})
def configure_arr(services):
"""Use generated API keys and upstream schemas, without changing image files."""
apps = {s['name']:s for s in services}
keys = {}
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
for name, service in apps.items():
if name == 'qbittorrent':
msg_info(translate('Configuring qBittorrent...'))
configure_qbittorrent(service,apps)
msg_ok(translate('qBittorrent configured'))
continue
if name not in ('prowlarr','sonarr','radarr','lidarr'):
continue
config = run('pct','exec',service['vmid'],'--','cat','/config/config.xml')
keys[name] = ET.fromstring(config).findtext('ApiKey')
if not keys[name]:
raise RuntimeError(f"{name}: {translate('the API key was not generated on the first start')}")
def api(name, path, payload=None):
service = apps[name]
port = service['healthcheck']['endpoint']['port']
request = urllib.request.Request(f"http://{service['ip']}:{port}{path}",
data=json.dumps(payload).encode() if payload is not None else None,
headers={'X-Api-Key':keys[name],'Content-Type':'application/json'})
with opener.open(request, timeout=30) as response:
body = response.read()
return json.loads(body) if body else None
for name, folder in (('sonarr','series'),('radarr','movies')):
if name not in apps:
continue
text = arr_texts(name)
msg_info(text['root_info'])
api(name,'/api/v3/system/status')
root = '/data/media/'+folder
if not any(x.get('path') == root for x in api(name,'/api/v3/rootfolder')):
api(name,'/api/v3/rootfolder',{'path':root})
msg_ok(f"{text['root_ok']}: {root}")
if 'qbittorrent' in apps:
msg_info(text['client_info'])
qbit = apps['qbittorrent']
schema = next((x for x in api(name,'/api/v3/downloadclient/schema')
if x.get('implementation')=='QBittorrent'),None)
if schema is None:
raise RuntimeError(f"{name}: {translate('the qBittorrent schema is not available')}")
schema.pop('id',None)
schema.update(name='qBittorrent',enable=True,priority=1)
category_key = 'tvCategory' if name=='sonarr' else 'movieCategory'
values = dict(qbit['setup_credentials'],host=qbit['ip'],port=qbit['healthcheck']['endpoint']['port'],
useSsl=False,urlBase='',apiKey='')
values[category_key] = 'tv' if name=='sonarr' else 'movies'
if not {'host','port','username','password',category_key}.issubset({f['name'] for f in schema['fields']}):
raise RuntimeError(f"{name}: {translate('incompatible qBittorrent schema')}")
for field in schema['fields']:
if field['name'] in values:
field['value'] = values[field['name']]
api(name,'/api/v3/downloadclient/test',schema)
api(name,'/api/v3/downloadclient',schema)
msg_ok(text['client_ok'])
if 'prowlarr' not in apps:
continue
msg_info(text['prowlarr_info'])
if any(x.get('name') == name for x in api('prowlarr','/api/v1/applications')):
msg_ok(text['prowlarr_ok'])
continue
schema = next((x for x in api('prowlarr','/api/v1/applications/schema')
if x.get('implementation','').lower()==name),None)
if schema is None:
raise RuntimeError(f"{translate('Prowlarr does not offer the application schema:')} {name}")
schema.pop('id',None)
schema.update(name=name,syncLevel='fullSync')
values = {'apiKey':keys[name],
'baseUrl':f"http://{apps[name]['ip']}:{apps[name]['healthcheck']['endpoint']['port']}",
'prowlarrUrl':f"http://{apps['prowlarr']['ip']}:{apps['prowlarr']['healthcheck']['endpoint']['port']}"}
for field in schema['fields']:
if field['name'] in values:
field['value'] = values[field['name']]
api('prowlarr','/api/v1/applications',schema)
msg_ok(text['prowlarr_ok'])
if 'qbittorrent' not in apps:
msg_info2(translate('The download client still needs to be configured.'))
msg_info2(translate('Indexers and quality profiles still need to be configured.'))
return keys
def arr_texts(name):
"""Visible steps of the Sonarr/Radarr wiring, one literal per application."""
if name == 'sonarr':
return {'root_info': translate('Configuring the Sonarr root folder...'),
'root_ok': translate('Sonarr root folder configured'),
'client_info': translate('Connecting Sonarr to qBittorrent...'),
'client_ok': translate('Sonarr connected to qBittorrent'),
'prowlarr_info': translate('Adding Sonarr to Prowlarr...'),
'prowlarr_ok': translate('Sonarr added to Prowlarr')}
return {'root_info': translate('Configuring the Radarr root folder...'),
'root_ok': translate('Radarr root folder configured'),
'client_info': translate('Connecting Radarr to qBittorrent...'),
'client_ok': translate('Radarr connected to qBittorrent'),
'prowlarr_info': translate('Adding Radarr to Prowlarr...'),
'prowlarr_ok': translate('Radarr added to Prowlarr')}
def prepare_suite_config(service):
"""Prepare only newly allocated configuration volumes, never image binaries."""
if service['name'] not in ('sabnzbd','seerr','unpackerr'):
return
vmid = service['vmid']
root = Path(f'/var/lib/lxc/{vmid}/rootfs')
path = root/('app/config' if service['name']=='seerr' else 'config')
run('pct','mount',vmid)
try:
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise RuntimeError(translate('Unsafe private configuration path'))
if service.get('config_owner') is not None:
owner = service['config_owner'] + (100000 if service['deployment']['security']['unprivileged'] else 0)
os.chown(path,owner,owner)
if service['name']=='sabnzbd':
target = path/'sabnzbd.ini'
fd = os.open(target,os.O_WRONLY | os.O_CREAT | os.O_EXCL,0o600)
with os.fdopen(fd,'w') as output:
output.write('[misc]\ndownload_dir = /data/downloads/usenet-incomplete\ncomplete_dir = /data/downloads/usenet\n')
os.chown(target,101000,101000)
finally:
run('pct','unmount',vmid)
def configure_unpackerr(services, keys):
worker = next((s for s in services if s['name']=='unpackerr'),None)
if worker is None:
return
msg_info(translate('Configuring Unpackerr...'))
variables = {}
for service in services:
name = service['name']
if name not in ('sonarr','radarr','lidarr'):
continue
prefix = 'UN_'+name.upper()+'_0_'
variables[prefix+'URL'] = f"http://{service['ip']}:{service['healthcheck']['endpoint']['port']}"
variables[prefix+'API_KEY'] = keys[name]
variables[prefix+'PATHS_0'] = '/data/downloads'
variables[prefix+'DELETE_ORIG'] = 'false'
config = Path(f"/etc/pve/lxc/{worker['vmid']}.conf")
content = config.read_text()
for key,value in variables.items():
if '\n' in value or '\r' in value:
raise RuntimeError(translate('Invalid Unpackerr variable'))
content = re.sub(r'^lxc\.environment\.runtime: '+re.escape(key)+r'=.*\n','',content,flags=re.M)
content += 'lxc.environment.runtime: '+key+'='+value+'\n'
config.write_text(content)
run('pct','start',worker['vmid'],capture=False)
for _ in range(3):
time.sleep(1)
if run('pct','status',worker['vmid']).strip()!='status: running':
raise RuntimeError(translate('Unpackerr stopped during its first start'))
msg_ok(translate('Unpackerr configured'))
def finish_independent_suite(services, subnet):
log(LOG, 'First start to configure the applications; each container is independent.')
for service in services:
if service.get('deferred_setup'):
continue
msg_info(f"{translate('Starting the service:')} {service['name']}")
run('pct','start',service['vmid'],capture=False,timeout=600)
if service['healthcheck']['type']=='http':
wait_web(service,service['healthcheck']['url'])
msg_ok(f"{translate('Service ready:')} {service['name']}")
keys = configure_arr(services)
configure_unpackerr(services,keys)
result = {'suite_arr':True,'lifecycle_mode':'independent',
'stack_vmids':{s['name']:s['vmid'] for s in services},
'urls':lan_access_urls(services,subnet),'credentials':[]}
for service in services:
if service['name']=='qbittorrent':
result['credentials'].append(dict(service['setup_credentials'],label='qBittorrent',change_required=False))
return result
def main():
deployment = json.loads(Path(sys.argv[2]).read_text())
if deployment.get('deployment_kind') != 'generic-multi-lxc-stack':
raise RuntimeError(translate('Invalid stack contract'))
if len(sys.argv)>3 and sys.argv[3]=='1':
msg_info2(f"{translate('Containers:')} {len(deployment['services'])}")
msg_info2(translate('Startup: independent, without hookscript') if deployment.get('suite_arr')
else translate('Startup: coordinated by the stack startup hook'))
msg_ok(translate('Dry run completed; no changes were made.'))
return
if os.geteuid()!=0:
raise RuntimeError(translate('The installer must run as root on Proxmox VE'))
init_log(deployment.get('stack_name') or 'stack')
services = copy.deepcopy(deployment['services'])
independent = bool(deployment.get('suite_arr'))
primary = None if independent else next(s for s in services if s['main'])
created = []
bridge_created = False
lifecycle = None
node = socket.gethostname()
# Serialize this installer's allocation through creation, and let pct enforce ownership.
msg_info(translate('Reserving a private network...'))
with open('/run/lock/proxmenux-private-network.lock','w') as lock, tempfile.TemporaryDirectory(prefix='proxmenux-stack-') as tmp:
fcntl.flock(lock,fcntl.LOCK_EX)
base = deployment.get('base_vmid') or int(run('pvesh','get','/cluster/nextid').strip())
while any(exists(base+s['offset']) for s in services):
if deployment.get('base_vmid'):
raise RuntimeError(translate('The requested VMID block is already in use'))
base += 1
selection = allocate_network(deployment,existing_bridges(),existing_networks())
if selection is None:
raise RuntimeError(translate('The private network must be assigned automatically'))
bridge, subnet = selection
deployment['network'].update(private_bridge=bridge, private_subnet=str(subnet),
private_host_address=str(subnet.network_address+1)+'/24')
primary_id = base
aliases = []
for s in services:
s['vmid'] = base+s['offset']
s['ip'] = str(subnet.network_address+30+s['offset'])
for alias in s['aliases']:
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]*',alias):
raise RuntimeError(translate('Invalid service alias'))
aliases.append({'hostname':alias,'address':s['ip']})
if not independent:
lifecycle = Path(f'/etc/pve/priv/proxmenux-stack-{primary_id}.json')
retire_stale_contract(lifecycle,primary_id)
try:
log(LOG, f"Stack {deployment['stack_name']}: CT {base}-{base+len(services)-1}; network {subnet} on {bridge}")
run('pvesh','create',f'/nodes/{node}/network','--iface',bridge,'--type','bridge','--autostart','1','--cidr',str(subnet.network_address+1)+'/24')
bridge_created = True
run('ip','link','add','name',bridge,'type','bridge')
run('ip','address','add',str(subnet.network_address+1)+'/24','dev',bridge)
run('ip','link','set',bridge,'up')
msg_ok(f"{translate('Private network:')} {bridge} ({subnet})")
if deployment.get('suite_arr') and deployment.get('shared_media'):
shared = Path(deployment['shared_media'])
for folder in [shared, shared/'downloads', shared/'downloads/incomplete', shared/'downloads/tv',
shared/'downloads/movies',shared/'downloads/music',shared/'downloads/usenet',shared/'downloads/usenet-incomplete',
shared/'media', shared/'media/series', shared/'media/movies',shared/'media/music']:
if not folder.exists():
folder.mkdir(parents=True)
os.chown(folder,101000,101000)
folder.chmod(0o775)
results = {}
for order,s in enumerate(services,1):
plan = s['deployment']
s['public_environment'] = {e['name']:e['value'] for e in plan['environment'] if '@STACK_LAN_IP@' in e['value']}
for e in plan['environment']:
e['value'] = e['value'].replace('@STACK_LAN_IP@',s['ip'])
plan['vmid'] = s['vmid']
plan['extra_hosts'] = aliases
plan['network'].update(bridge=bridge,ipv4=s['ip']+'/24',gateway=None)
if exists(s['vmid']):
raise RuntimeError(f"{translate('The VMID was taken during the installation:')} {s['vmid']}")
msg_info2(f"{translate('Service:')} {s['name']}")
with tempfile.TemporaryDirectory(dir=tmp) as service_tmp:
results[s['name']] = create_service(s,Path(service_tmp))
created.append(s['vmid'])
if s['deployment']['mounts']:
msg_info(translate('Attaching the volumes...'))
attach_mounts(s,Path(tmp))
if s['deployment']['mounts']:
msg_ok(translate('Volumes attached'))
if deployment.get('suite_arr'):
prepare_suite_config(s)
if deployment.get('suite_arr') and s['name']=='qbittorrent':
seed_qbittorrent(s)
if not independent:
run('pct','set',s['vmid'],'--startup',f'order={order*10},up=5,down=30')
if s['main'] or s.get('frontend'):
address = access_address(s.get('frontend_ipv4') or 'dhcp', deployment['network'].get('frontend_gateway'))
run('pct','set',s['vmid'],'--net1',f"name=eth1,bridge={deployment['network']['frontend_bridge']},{address},host-managed=1,firewall=1,type=veth")
if s['healthcheck']['type']=='http':
endpoint = s['healthcheck']['endpoint']
s['healthcheck']['url'] = f"{endpoint['scheme']}://{s['ip']}:{endpoint['port']}{endpoint['path']}"
if independent:
fcntl.flock(lock,fcntl.LOCK_UN)
result = finish_independent_suite(services,subnet)
persist_instances(deployment, services)
result.update(completion_notes=list(deployment.get('completion_notes',[])), log=LOG)
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
return
hook_spec = Path(tmp)/'lifecycle.json'
write_json(hook_spec,{'schema':1,'stack':deployment['stack_name'],'dependencies':[
{'vmid':s['vmid'],'label':s['name'],'healthcheck':s['healthcheck']} for s in services if not s['main'] and not s.get('deferred_setup')]})
if len(services) > 1:
msg_info(translate('Installing the stack startup hook...'))
run('bash',HERE/'stack_dependency_hook.sh','--install',primary_id,hook_spec,capture=False)
msg_ok(translate('Stack startup hook installed'))
fcntl.flock(lock,fcntl.LOCK_UN)
msg_info(translate('Starting the main container and its dependencies...') if len(services) > 1
else translate('Starting the container...'))
run('pct','start',primary_id,capture=False,timeout=600)
msg_ok(f"{translate('Container started')}: CT {primary_id} ({primary['name']})")
endpoint = primary['healthcheck']['endpoint']
url = f"{endpoint['scheme']}://{primary['ip']}:{endpoint['port']}{endpoint['path']}"
msg_info(translate('Waiting for the application to respond...'))
wait_web(primary,url)
addresses=run('lxc-info','-n',primary_id,'-iH').splitlines()
lan = next((a for a in addresses if re.fullmatch(r'\d+\.\d+\.\d+\.\d+',a) and ipaddress.ip_address(a) not in subnet),None)
if not lan:
raise RuntimeError(translate('No LAN address was obtained'))
public_url = f"{endpoint['scheme']}://{lan}:{endpoint['port']}{endpoint['path']}"
msg_ok(f"{translate('Application responding:')} {public_url}")
if primary['public_environment']:
msg_info(translate('Applying the LAN address to the application URLs...'))
run('pct','shutdown',primary_id,'--timeout','180',timeout=200)
config=Path(f'/etc/pve/lxc/{primary_id}.conf')
text=config.read_text()
for key,value in primary['public_environment'].items():
if '\n' in value or '\r' in value:
raise RuntimeError(translate('Multi-line variables are not supported'))
text=re.sub(r'^lxc\.environment\.runtime: '+re.escape(key)+r'=.*\n','',text,flags=re.M)
text+='lxc.environment.runtime: '+key+'='+value.replace('@STACK_LAN_IP@',lan)+'\n'
config.write_text(text)
run('pct','start',primary_id,capture=False,timeout=600)
wait_web(primary,url)
msg_ok(translate('LAN address applied to the application URLs'))
result=results[primary['name']]
persist_instances(deployment, services, primary_id)
# The credentials of the main service come from its own installation.
result.update(vmid=primary_id,ip=lan,stack_vmids={s['name']:s['vmid'] for s in services},
urls=[{'label':'Web UI','url':public_url}],
credentials=result.get('credentials') or [])
result.update(completion_notes=[note.replace('{main_vmid}',str(primary_id))
for note in deployment.get('completion_notes', [])], log=LOG)
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
except BaseException as error:
# Keep volumes and configs for diagnosis; never delete a database on a late startup failure.
stop_spinner()
if isinstance(error, ServiceFailed):
report_error(str(error), tail=False)
elif isinstance(error, SystemExit):
if isinstance(error.code, str):
report_error(error.code)
else:
report_error(str(error) or type(error).__name__)
if created:
msg_warn(f"{translate('Installation incomplete. These containers and their data are kept:')} "
f"{', '.join('CT '+str(vmid) for vmid in created)}")
accesses = lan_access_urls([s for s in services if s.get('vmid') in created],subnet,strict=False)
if accesses:
msg_warn(translate('LAN access to the kept containers (stack configuration incomplete):'))
for access in accesses:
msg_info2(access['label']+': '+access['url'])
if not created and bridge_created:
with open(LOG or os.devnull, 'a') as output:
subprocess.run(['ip','link','delete',bridge,'type','bridge'],check=False,stdout=output,stderr=output)
subprocess.run(['pvesh','delete',f'/nodes/{node}/network/{bridge}'],check=False,stdout=output,stderr=output)
raise
if __name__=='__main__':
try:
main()
except (Exception, KeyboardInterrupt) as error:
if not ERROR_REPORTED:
report_error(str(error) or type(error).__name__)
sys.exit(1)
except SystemExit as error:
if isinstance(error.code, str):
if not ERROR_REPORTED:
report_error(error.code)
sys.exit(1)
raise
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
exec python3 "$SCRIPT_DIR/install_generic_stack.py" "$@"
+570
View File
@@ -0,0 +1,570 @@
#!/usr/bin/env bash
set -Eeuo pipefail
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
DRY_RUN=${3:-0}
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SCRIPT_DIR/oci_ui.sh"
VERIFY_OCI_ARCHIVE="${SCRIPT_DIR}/verify_oci_archive.py"
ALLOCATE_PRIVATE_NETWORK="${SCRIPT_DIR}/allocate_private_network.py"
STACK_DEPENDENCY_HOOK="${SCRIPT_DIR}/stack_dependency_hook.sh"
die() {
stop_spinner
msg_error "$*"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$(translate "Missing required command:") $1"
}
jqr() {
jq -er "$1" "$DEPLOYMENT_FILE"
}
set_runtime_env() {
local id=$1 key=$2 value=$3 config="/etc/pve/lxc/${1}.conf"
sed -i -E "/^lxc\.environment\.runtime: ${key}=/d" "$config"
printf 'lxc.environment.runtime: %s=%s\n' "$key" "$value" >>"$config"
}
unset_runtime_env() {
local id=$1 key=$2 config="/etc/pve/lxc/${1}.conf"
sed -i -E "/^lxc\.environment\.runtime: ${key}=/d" "$config"
}
set_lxc_directive() {
local id=$1 key=$2 value=$3 config="/etc/pve/lxc/${1}.conf" escaped_key
escaped_key=${key//./\.}
sed -i -E "/^${escaped_key}:/d" "$config"
printf '%s: %s\n' "$key" "$value" >>"$config"
}
created_ids=()
INSTALL_COMPLETE=0
PRIVATE_BRIDGE_CREATED=0
LIFECYCLE_CONFIG_PATH=""
UNEXPECTED_FAILURE=0
rollback() {
local status=$? index id
stop_spinner
if (( status != 0 && UNEXPECTED_FAILURE == 1 )); then
msg_error "$(translate "The installation stopped because of an unexpected error")"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
fi
if (( status != 0 && INSTALL_COMPLETE == 0 )); then
if declare -F oci_native_failed >/dev/null; then oci_native_failed; fi
if (( ${#created_ids[@]} > 0 || PRIVATE_BRIDGE_CREATED == 1 )); then
msg_info "$(translate "Removing the incomplete stack...")"
fi
for ((index=${#created_ids[@]}-1; index>=0; index--)); do
id=${created_ids[index]}
pct stop "$id" --skiplock 1 >/dev/null 2>&1 || true
pct destroy "$id" --force 1 --purge 1 >/dev/null 2>&1 \
|| pct destroy "$id" --purge 1 >/dev/null 2>&1 \
|| true
done
if (( PRIVATE_BRIDGE_CREATED == 1 )); then
oci_log "Removing the private bridge created by this installation"
ip link delete "$PRIVATE_BRIDGE" type bridge >/dev/null 2>&1 || true
pvesh delete "/nodes/${NODE}/network/${PRIVATE_BRIDGE}" >/dev/null 2>&1 || true
fi
[[ -z $LIFECYCLE_CONFIG_PATH ]] || rm -f "$LIFECYCLE_CONFIG_PATH"
if (( ${#created_ids[@]} > 0 || PRIVATE_BRIDGE_CREATED == 1 )); then
msg_ok "$(translate "Incomplete stack removed")"
fi
fi
exit "$status"
}
trap rollback EXIT
trap 'UNEXPECTED_FAILURE=1; oci_log "Command failed at line $LINENO (${FUNCNAME[0]:-main})"' ERR
[[ $EUID -eq 0 ]] || die "$(translate "The installer must run as root on Proxmox VE")"
oci_log_init "$(jq -r '.stack_name // "immich"' "$DEPLOYMENT_FILE" 2>/dev/null || printf immich)"
for command in pct qm pvesh pvesm skopeo jq openssl python3 curl ip stat dpkg base64 mktemp flock; do
require_command "$command"
done
[[ -r $VERIFY_OCI_ARCHIVE ]] || die "$(translate "The OCI archive verifier was not found")"
[[ -r $ALLOCATE_PRIVATE_NETWORK ]] || die "$(translate "The private network allocator was not found")"
[[ -r $STACK_DEPENDENCY_HOOK ]] || die "$(translate "The stack startup hook was not found")"
ML_ACCELERATION=$(jq -er '.machine_learning.acceleration // "cpu"' "$DEPLOYMENT_FILE")
source "$SCRIPT_DIR/oci_nvidia_setup.sh"
source "$SCRIPT_DIR/oci_immich_ml.sh"
validate_immich_ml_profile
msg_info "$(translate "Reserving a private network...")"
exec 9>/run/lock/proxmenux-private-network.lock
flock 9
oci_quiet python3 "$ALLOCATE_PRIVATE_NETWORK" "$DEPLOYMENT_FILE" \
|| die "$(translate "Could not reserve a private network for the stack")"
STACK_NAME=$(jqr '.stack_name')
[[ $STACK_NAME =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || die "$(translate "Invalid stack name")"
BASE_VMID=$(jq -r '.base_vmid // empty' "$DEPLOYMENT_FILE")
TEMPLATE_STORAGE=$(jqr '.template_storage')
ROOTFS_STORAGE=$(jqr '.rootfs_storage')
DATABASE_STORAGE=$(jqr '.database_storage')
DATABASE_SIZE=$(jqr '.database_size_gb')
MEDIA_MODE=$(jqr '.media.mode')
MEDIA_STORAGE=$(jq -r '.media.storage // empty' "$DEPLOYMENT_FILE")
MEDIA_SIZE=$(jq -r '.media.size_gb // empty' "$DEPLOYMENT_FILE")
MEDIA_ROOT=$(jq -r '.media.host_path // empty' "$DEPLOYMENT_FILE")
TIMEZONE=$(jqr '.timezone')
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
FRONTEND_IPV4=$(jq -r '.network.frontend_ipv4 // "dhcp"' "$DEPLOYMENT_FILE")
ML_FRONTEND_IPV4=$(jq -r '.network.machine_learning_frontend_ipv4 // "dhcp"' "$DEPLOYMENT_FILE")
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
FRONTEND_NET=$OCI_ACCESS_NET
oci_access_net "$ML_FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|| die "$(translate "Invalid access address:") $ML_FRONTEND_IPV4 $FRONTEND_GATEWAY"
ML_FRONTEND_NET=$OCI_ACCESS_NET
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
SERVER_ADDRESS=$(jqr '.network.server_address')
ML_ADDRESS=$(jqr '.network.machine_learning_address')
DB_ADDRESS=$(jqr '.network.database_address')
VALKEY_ADDRESS=$(jqr '.network.valkey_address')
SERVER_IP=${SERVER_ADDRESS%/*}
ML_IP=${ML_ADDRESS%/*}
DB_IP=${DB_ADDRESS%/*}
VALKEY_IP=${VALKEY_ADDRESS%/*}
VIDEO_ACCELERATION=$(jqr '.video_transcoding.acceleration')
RENDER_DEVICE=$(jq -r '.video_transcoding.render_device // empty' "$DEPLOYMENT_FILE")
VAAPI_DRIVER=$(jqr '.video_transcoding.driver')
MODEL_CACHE_SIZE=$(jqr '.machine_learning.model_cache_size_gb')
[[ $DATABASE_SIZE =~ ^[0-9]+$ ]] && (( DATABASE_SIZE >= 8 )) \
|| die "$(translate "The PostgreSQL volume needs at least 8 GB")"
case "$MEDIA_MODE" in
managed-volume)
[[ -n $MEDIA_STORAGE && $MEDIA_SIZE =~ ^[0-9]+$ ]] && (( MEDIA_SIZE >= 8 )) \
|| die "$(translate "Invalid media volume")"
;;
host-bind)
[[ $MEDIA_ROOT == /* && $MEDIA_ROOT != *","* && $MEDIA_ROOT != *$'\n'* ]] \
|| die "$(translate "Invalid media path")"
;;
*) die "$(translate "Unsupported media storage mode:") $MEDIA_MODE" ;;
esac
[[ $PRIVATE_BRIDGE =~ ^vmbr[0-9]+$ ]] || die "$(translate "Invalid private bridge")"
[[ $PRIVATE_SUBNET =~ ^10\.77\.[0-9]{1,3}\.0/24$ ]] || die "$(translate "Invalid private network")"
vmid_block_free() {
local candidate=$1 offset
for offset in 0 1 2 3; do
pct config "$((candidate + offset))" >/dev/null 2>&1 && return 1
qm config "$((candidate + offset))" >/dev/null 2>&1 && return 1
done
return 0
}
if [[ -z $BASE_VMID ]]; then
BASE_VMID=$(pvesh get /cluster/nextid)
while ! vmid_block_free "$BASE_VMID"; do
BASE_VMID=$((BASE_VMID + 1))
done
fi
[[ $BASE_VMID =~ ^[0-9]+$ ]] || die "$(translate "Invalid base VMID")"
vmid_block_free "$BASE_VMID" || die "$(translate "These VMIDs are not free:") ${BASE_VMID}-$((BASE_VMID + 3))"
SERVER_ID=$BASE_VMID
ML_ID=$((BASE_VMID + 1))
DB_ID=$((BASE_VMID + 2))
VALKEY_ID=$((BASE_VMID + 3))
oci_log "Stack: $STACK_NAME; VMIDs: server=$SERVER_ID, machine-learning=$ML_ID, PostgreSQL=$DB_ID, Valkey=$VALKEY_ID"
oci_log "Private network: $PRIVATE_SUBNET on $PRIVATE_BRIDGE"
if [[ $DRY_RUN == 1 ]]; then
msg_info2 "$(translate "Stack:") $STACK_NAME · CT ${SERVER_ID}-${VALKEY_ID}"
msg_info2 "$(translate "Private network:") $PRIVATE_BRIDGE ($PRIVATE_SUBNET)"
msg_ok "$(translate "Dry run completed; no containers were created.")"
exit 0
fi
NODE=$(hostname)
if ! pvesh get "/nodes/${NODE}/network/${PRIVATE_BRIDGE}" >/dev/null 2>&1; then
oci_log "Creating the persistent configuration for $PRIVATE_BRIDGE"
oci_quiet pvesh create "/nodes/${NODE}/network" --iface "$PRIVATE_BRIDGE" --type bridge \
--autostart 1 --cidr "$PRIVATE_HOST_ADDRESS"
PRIVATE_BRIDGE_CREATED=1
fi
if ! ip link show "$PRIVATE_BRIDGE" >/dev/null 2>&1; then
oci_log "Activating the private bridge $PRIVATE_BRIDGE"
oci_quiet ip link add name "$PRIVATE_BRIDGE" type bridge
oci_quiet ip address add "$PRIVATE_HOST_ADDRESS" dev "$PRIVATE_BRIDGE"
oci_quiet ip link set "$PRIVATE_BRIDGE" up
fi
ip -4 address show dev "$PRIVATE_BRIDGE" | grep -Fq "${PRIVATE_HOST_ADDRESS%/*}/" \
|| die "$(translate "The private bridge does not have the expected address:") $PRIVATE_BRIDGE ($PRIVATE_HOST_ADDRESS)"
for address in "$SERVER_ADDRESS" "$ML_ADDRESS" "$DB_ADDRESS" "$VALKEY_ADDRESS"; do
if grep -RqsF "ip=${address}" /etc/pve/lxc/*.conf 2>/dev/null; then
die "$(translate "The private address is already assigned to another container:") ${address%/*}"
fi
done
flock -u 9
msg_ok "$(translate "Private network:") $PRIVATE_BRIDGE ($PRIVATE_SUBNET)"
ARCH=$(dpkg --print-architecture)
case "$ARCH" in amd64|arm64) ;; *) die "$(translate "Unsupported architecture:") $ARCH" ;; esac
skopeo_transport_reference() {
local reference=$1 name digest
if [[ $reference == *@sha256:* ]]; then
name=${reference%@sha256:*}
digest="sha256:${reference##*@sha256:}"
[[ ${name##*/} == *:* ]] && name=${name%:*}
printf '%s@%s' "$name" "$digest"
else
printf '%s' "$reference"
fi
}
resolve_image_manifest() {
local label=$1 image=$2 manifest_file error_file pid elapsed=0 status=0
manifest_file=$(mktemp /tmp/proxmenux-oci-inspect.XXXXXX)
error_file="${manifest_file}.err"
oci_log "Querying the OCI registry for ${label}: ${image}"
skopeo inspect --no-tags --override-os linux --override-arch "$ARCH" \
"docker://${image}" >"$manifest_file" 2>"$error_file" &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
sleep 2
elapsed=$((elapsed + 2))
done
wait "$pid" || status=$?
if (( status != 0 )); then
cat "$error_file" >>"$OCI_LOG"
rm -f "$manifest_file" "$error_file"
return "$status"
fi
oci_log "Manifest for ${label} resolved in ${elapsed}s"
cat "$manifest_file"
rm -f "$manifest_file" "$error_file"
}
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
msg_info "$(translate "Checking the image in the registry...")"
oci_log "Resolving ${key}: ${image}"
transport_image=$(skopeo_transport_reference "$image")
if [[ $transport_image != "$image" ]]; then
oci_log "Digest-pinned reference in skopeo format: ${transport_image}"
fi
inspect=$(resolve_image_manifest "$key" "$transport_image") \
|| die "$(translate "Could not resolve the OCI manifest:") $image"
digest=$(jq -er '.Digest' <<<"$inspect")
oci_log "Selected digest for ${key}: ${digest}"
short=${digest#sha256:}
short=${short:0:16}
archive_name="image-immich-${key}_${ARCH}_${short}.tar"
archive_volume="${TEMPLATE_STORAGE}:vztmpl/${archive_name}"
archive_path=$(pvesm path "$archive_volume")
mkdir -p "$(dirname "$archive_path")"
if [[ -s $archive_path ]]; then
msg_info "$(translate "Verifying the image integrity...")"
fi
if [[ -s $archive_path ]] && oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$archive_path"; then
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))
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
RESOLVED_DIGEST=$digest
}
SERVER_IMAGE=$(jq -er '.container_contract.image.reference' "$TEMPLATE_FILE")
ML_IMAGE=$(jq -er --arg profile "$ML_ACCELERATION" '.proxmox.application_options.machine_learning.profile_images[$profile]' "$TEMPLATE_FILE")
DB_IMAGE=$(jq -er '.compose_stack.services[] | select(.name == "database") | .image' "$TEMPLATE_FILE")
VALKEY_IMAGE=$(jq -er '.compose_stack.services[] | select(.name == "redis") | .image' "$TEMPLATE_FILE")
ensure_image server "$SERVER_IMAGE"; SERVER_ARCHIVE=$RESOLVED_ARCHIVE; SERVER_DIGEST=$RESOLVED_DIGEST
ensure_image machine-learning "$ML_IMAGE"; ML_ARCHIVE=$RESOLVED_ARCHIVE
ensure_image postgres "$DB_IMAGE"; DB_ARCHIVE=$RESOLVED_ARCHIVE
ensure_image valkey "$VALKEY_IMAGE"; VALKEY_ARCHIVE=$RESOLVED_ARCHIVE
source "$SCRIPT_DIR/oci_native_stack.sh"
oci_native_begin "$SERVER_ID" \
--member server "$SERVER_ID" "$SERVER_IMAGE" "$SERVER_ARCHIVE" \
--member machine-learning "$ML_ID" "$ML_IMAGE" "$ML_ARCHIVE" \
--member database "$DB_ID" "$DB_IMAGE" "$DB_ARCHIVE" \
--member valkey "$VALKEY_ID" "$VALKEY_IMAGE" "$VALKEY_ARCHIVE"
DB_PASSWORD=$(openssl rand -hex 24)
if [[ $MEDIA_MODE == host-bind ]]; then
install -d -m 0750 -o 100000 -g 100000 "$MEDIA_ROOT"
SERVER_MEDIA_MOUNT="${MEDIA_ROOT},mp=/data,backup=0"
else
SERVER_MEDIA_MOUNT="${MEDIA_STORAGE}:${MEDIA_SIZE},mp=/data,backup=1"
fi
TAGS="media;oci;proxmenux"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$DB_ID" "$DB_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql/data,backup=1" \
--hostname "${STACK_NAME}-db" --cores 2 --memory 2048 --swap 512 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DB_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=10,up=10,down=30 --tags "$TAGS" \
--description 'Immich PostgreSQL VectorChord native OCI'
created_ids+=("$DB_ID")
oci_quiet pct set "$DB_ID" --entrypoint "/usr/local/bin/immich-docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf -c listen_addresses=127.0.0.1,${DB_IP}"
set_lxc_directive "$DB_ID" lxc.init.cwd /
set_lxc_directive "$DB_ID" lxc.signal.halt SIGINT
set_runtime_env "$DB_ID" POSTGRES_USER postgres
set_runtime_env "$DB_ID" POSTGRES_DB immich
set_runtime_env "$DB_ID" POSTGRES_INITDB_ARGS --data-checksums
set_runtime_env "$DB_ID" PGDATA /var/lib/postgresql/data/pgdata
set_runtime_env "$DB_ID" DB_STORAGE_TYPE SSD
set_runtime_env "$DB_ID" POSTGRES_PASSWORD "$DB_PASSWORD"
oci_quiet pct mount "$DB_ID"
DB_ROOT="/var/lib/lxc/${DB_ID}/rootfs"
POSTGRES_UID=$(awk -F: '$1 == "postgres" {print $3}' "$DB_ROOT/etc/passwd")
POSTGRES_GID=$(awk -F: '$1 == "postgres" {print $4}' "$DB_ROOT/etc/passwd")
[[ -n $POSTGRES_UID && -n $POSTGRES_GID ]] || die "$(translate "The postgres user was not found in the image")"
rm -rf "$DB_ROOT/var/lib/postgresql/data/lost+found"
install -d -m 0700 -o "$((100000 + POSTGRES_UID))" -g "$((100000 + POSTGRES_GID))" \
"$DB_ROOT/var/lib/postgresql/data/pgdata"
oci_quiet pct unmount "$DB_ID"
msg_ok "$(translate "Container created:") CT $DB_ID (PostgreSQL)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$VALKEY_ID" "$VALKEY_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
--mp0 "${ROOTFS_STORAGE}:4,mp=/data,backup=1" \
--hostname "${STACK_NAME}-valkey" --cores 1 --memory 512 --swap 256 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${VALKEY_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=20,up=5,down=15 --tags "$TAGS" --description 'Immich Valkey native OCI'
created_ids+=("$VALKEY_ID")
oci_quiet pct mount "$VALKEY_ID"
VALKEY_FACTORY_DIR="/var/lib/lxc/${VALKEY_ID}/rootfs/data/lost+found"
# Only remove the empty directory created by formatting this new managed disk.
if [[ -d $VALKEY_FACTORY_DIR && ! -L $VALKEY_FACTORY_DIR ]]; then
[[ $(stat -c '%i:%u:%g:%a' "$VALKEY_FACTORY_DIR") == 11:0:0:700 ]] \
|| die "$(translate "Unexpected formatting directory in the new Valkey volume")"
rmdir "$VALKEY_FACTORY_DIR" 2>>"$OCI_LOG" || die "$(translate "The new Valkey volume contains unexpected data")"
fi
oci_quiet pct unmount "$VALKEY_ID"
oci_quiet pct set "$VALKEY_ID" --entrypoint 'docker-entrypoint.sh valkey-server'
set_lxc_directive "$VALKEY_ID" lxc.init.cwd /data
set_lxc_directive "$VALKEY_ID" lxc.signal.halt SIGTERM
msg_ok "$(translate "Container created:") CT $VALKEY_ID (Valkey)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$ML_ID" "$ML_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:12" \
--mp0 "${ROOTFS_STORAGE}:${MODEL_CACHE_SIZE},mp=/cache,backup=1" \
--hostname "${STACK_NAME}-ml" "${ML_CPU_ARGS[@]}" --memory "$ML_MEMORY" --swap "$ML_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${ML_FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${ML_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=30,up=10,down=30 --tags "$TAGS" --description "Immich machine learning ${ML_ACCELERATION} native OCI"
created_ids+=("$ML_ID")
unset_runtime_env "$ML_ID" LD_PRELOAD
oci_quiet pct set "$ML_ID" --entrypoint 'env LD_PRELOAD=/usr/lib/libmimalloc.so.2 tini -- python -m immich_ml'
set_lxc_directive "$ML_ID" lxc.init.cwd /usr/src
set_lxc_directive "$ML_ID" lxc.signal.halt SIGTERM
set_runtime_env "$ML_ID" IMMICH_HOST "$ML_IP"
set_runtime_env "$ML_ID" IMMICH_PORT 3003
set_runtime_env "$ML_ID" MACHINE_LEARNING_CACHE_FOLDER /cache
set_runtime_env "$ML_ID" TRANSFORMERS_CACHE /cache
set_runtime_env "$ML_ID" MACHINE_LEARNING_MODEL_INTRA_OP_THREADS 2
set_runtime_env "$ML_ID" MACHINE_LEARNING_MODEL_INTER_OP_THREADS 1
configure_immich_ml_gpu
oci_quiet pct mount "$ML_ID"
ML_ROOT="/var/lib/lxc/${ML_ID}/rootfs"
rm -rf "$ML_ROOT/cache/lost+found"
chown 100000:100000 "$ML_ROOT/cache"
chmod 0755 "$ML_ROOT/cache"
oci_quiet pct unmount "$ML_ID"
msg_ok "$(translate "Container created:") CT $ML_ID ($(translate "Machine learning"))"
SERVER_DEVICE_ARGS=()
if [[ $VIDEO_ACCELERATION == vaapi ]]; then
[[ -c $RENDER_DEVICE ]] || die "$(translate "The VA-API device does not exist:") $RENDER_DEVICE"
RENDER_GID=$(stat -c %g "$RENDER_DEVICE")
SERVER_DEVICE_ARGS+=(--dev0 "path=${RENDER_DEVICE},gid=${RENDER_GID},mode=0660")
fi
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$SERVER_ID" "$SERVER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:16" \
--mp0 "$SERVER_MEDIA_MOUNT" --hostname "${STACK_NAME}-server" \
--cores 4 --memory 3072 --swap 1024 \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${SERVER_ADDRESS},type=veth" \
"${SERVER_DEVICE_ARGS[@]}" --unprivileged 1 --features nesting=1 --cmode console \
--onboot "$ONBOOT" --startup order=40,up=10,down=30 --tags "$TAGS" \
--description 'Immich server native OCI'
created_ids+=("$SERVER_ID")
oci_quiet pct mount "$SERVER_ID"
SERVER_ROOT="/var/lib/lxc/${SERVER_ID}/rootfs"
cat >"$SERVER_ROOT/usr/local/bin/immich-lxc-start" <<'EOF'
#!/bin/bash
set -e
for attempt in $(seq 1 60); do
if grep -qE '^eth0[[:space:]]+00000000[[:space:]]' /proc/net/route; then
sleep 3
exec /bin/bash -c 'start.sh'
fi
sleep 1
done
echo 'Immich frontend route was not ready after 60 seconds' >&2
exit 1
EOF
chmod 0755 "$SERVER_ROOT/usr/local/bin/immich-lxc-start"
chown 100000:100000 "$SERVER_ROOT/usr/local/bin/immich-lxc-start"
oci_quiet pct unmount "$SERVER_ID"
oci_quiet pct set "$SERVER_ID" --entrypoint 'tini -- /usr/local/bin/immich-lxc-start'
set_lxc_directive "$SERVER_ID" lxc.init.cwd /usr/src/app
set_lxc_directive "$SERVER_ID" lxc.signal.halt SIGTERM
set_runtime_env "$SERVER_ID" TZ "$TIMEZONE"
set_runtime_env "$SERVER_ID" CPU_CORES 4
set_runtime_env "$SERVER_ID" IMMICH_HOST 0.0.0.0
set_runtime_env "$SERVER_ID" IMMICH_PORT 2283
set_runtime_env "$SERVER_ID" DB_HOSTNAME "$DB_IP"
set_runtime_env "$SERVER_ID" DB_PORT 5432
set_runtime_env "$SERVER_ID" DB_USERNAME postgres
set_runtime_env "$SERVER_ID" DB_DATABASE_NAME immich
set_runtime_env "$SERVER_ID" DB_VECTOR_EXTENSION vectorchord
set_runtime_env "$SERVER_ID" REDIS_HOSTNAME "$VALKEY_IP"
set_runtime_env "$SERVER_ID" REDIS_PORT 6379
set_runtime_env "$SERVER_ID" IMMICH_MACHINE_LEARNING_URL "http://${ML_IP}:3003"
if [[ $VIDEO_ACCELERATION == vaapi && $VAAPI_DRIVER != auto ]]; then
set_runtime_env "$SERVER_ID" LIBVA_DRIVER_NAME "$VAAPI_DRIVER"
fi
set_runtime_env "$SERVER_ID" DB_PASSWORD "$DB_PASSWORD"
msg_ok "$(translate "Container created:") CT $SERVER_ID (Immich)"
msg_info "$(translate "Installing the stack startup hook...")"
LIFECYCLE_SPEC=$(mktemp /tmp/proxmenux-stack-lifecycle.XXXXXX)
jq -nc --arg stack "$STACK_NAME" --argjson db "$DB_ID" --arg db_ip "$DB_IP" \
--argjson valkey "$VALKEY_ID" --arg valkey_ip "$VALKEY_IP" \
--argjson ml "$ML_ID" --arg ml_ip "$ML_IP" '
{
schema: 1,
stack: $stack,
dependencies: [
{vmid: $db, label: "PostgreSQL", healthcheck: {
type: "exec", timeout_seconds: 90,
argv: ["pg_isready", "-h", $db_ip, "-p", "5432", "-U", "postgres", "-d", "immich"]
}},
{vmid: $valkey, label: "Valkey", healthcheck: {
type: "exec", timeout_seconds: 60,
argv: ["valkey-cli", "-h", $valkey_ip, "ping"]
}},
{vmid: $ml, label: "Immich Machine Learning", healthcheck: {
type: "http", timeout_seconds: 180, url: ("http://" + $ml_ip + ":3003/ping")
}}
]
}' >"$LIFECYCLE_SPEC"
LIFECYCLE_CONFIG_PATH="/etc/pve/priv/proxmenux-stack-${SERVER_ID}.json"
if ! oci_quiet bash "$STACK_DEPENDENCY_HOOK" --install "$SERVER_ID" "$LIFECYCLE_SPEC"; then
rm -f "$LIFECYCLE_SPEC"
die "$(translate "Could not install the stack startup hook")"
fi
rm -f "$LIFECYCLE_SPEC"
msg_ok "$(translate "Stack startup hook installed")"
wait_command() {
local label=$1 retries=$2
shift 2
local attempt
for attempt in $(seq 1 "$retries"); do
"$@" >/dev/null 2>&1 && return 0
sleep 2
done
die "$(translate "Health check failed:") $label"
}
SERVER_LAN_IP=""
if (( START_AFTER == 1 )); then
msg_info "$(translate "Starting the service:") PostgreSQL"
oci_quiet pct start "$DB_ID"
wait_command PostgreSQL 45 pct exec "$DB_ID" -- pg_isready -h "$DB_IP" -p 5432 -U postgres -d immich
msg_ok "$(translate "Service ready:") PostgreSQL"
msg_info "$(translate "Starting the service:") Valkey"
oci_quiet pct start "$VALKEY_ID"
wait_command Valkey 30 pct exec "$VALKEY_ID" -- valkey-cli -h "$VALKEY_IP" ping
msg_ok "$(translate "Service ready:") Valkey"
ML_LABEL=$(translate "Machine learning")
msg_info "$(translate "Starting the service:") $ML_LABEL"
oci_quiet pct start "$ML_ID"
wait_command "$ML_LABEL" 60 curl -fsS "http://${ML_IP}:3003/ping"
msg_ok "$(translate "Service ready:") $ML_LABEL"
validate_immich_ml_runtime \
|| die "$(translate "The requested machine learning GPU profile is not working; it is not replaced by CPU")"
msg_info "$(translate "Starting the service:") Immich"
oci_quiet pct start "$SERVER_ID"
msg_info "$(translate "Waiting for the application to respond...")"
wait_command Immich 90 curl -fsS "http://${SERVER_IP}:2283/api/server/ping"
SERVER_LAN_IP=$(pct exec "$SERVER_ID" -- node -e '
const os = require("node:os");
for (const addresses of Object.values(os.networkInterfaces())) {
for (const address of addresses ?? []) {
if (address.family === "IPv4" && !address.internal && !address.address.startsWith("10.77.")) {
process.stdout.write(address.address); process.exit(0);
}
}
}
process.exit(1);
')
msg_ok "$(translate "Application responding:") http://${SERVER_LAN_IP}:2283/"
fi
RESULT=$(jq -cn \
--argjson vmid "$SERVER_ID" --argjson ml "$ML_ID" --argjson db "$DB_ID" \
--argjson valkey "$VALKEY_ID" --arg ip "$SERVER_LAN_IP" --arg arch "$ARCH" \
--arg digest "$SERVER_DIGEST" --arg log "$OCI_LOG" \
'{vmid:$vmid,stack_vmids:{server:$vmid,machine_learning:$ml,database:$db,valkey:$valkey},ip:$ip,architecture:$arch,digest:$digest,urls:(if ($ip|length)>0 then [{label:"Immich WebUI",url:("http://"+$ip+":2283/")}] else [] end),credentials:[],log:$log}')
INSTALL_COMPLETE=1
oci_native_finalize
printf 'PROXMENUX_RESULT=%s\n' "$(printf '%s' "$RESULT" | base64 -w0)"
INSTALL_COMPLETE=1
trap - EXIT
+553
View File
@@ -0,0 +1,553 @@
#!/usr/bin/env bash
set -Eeuo pipefail
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
DRY_RUN=${3:-0}
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SCRIPT_DIR/oci_ui.sh"
VERIFY_OCI_ARCHIVE="${SCRIPT_DIR}/verify_oci_archive.py"
ALLOCATE_PRIVATE_NETWORK="${SCRIPT_DIR}/allocate_private_network.py"
STACK_DEPENDENCY_HOOK="${SCRIPT_DIR}/stack_dependency_hook.sh"
die() {
stop_spinner
msg_error "$*"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$(translate "Missing required command:") $1"
}
jqr() {
jq -er "$1" "$DEPLOYMENT_FILE"
}
set_runtime_env() {
local id=$1 key=$2 value=$3 config="/etc/pve/lxc/${1}.conf"
sed -i -E "/^lxc\.environment\.runtime: ${key}=/d" "$config"
printf 'lxc.environment.runtime: %s=%s\n' "$key" "$value" >>"$config"
}
set_lxc_directive() {
local id=$1 key=$2 value=$3 config="/etc/pve/lxc/${1}.conf" escaped_key
escaped_key=${key//./\.}
sed -i -E "/^${escaped_key}:/d" "$config"
printf '%s: %s\n' "$key" "$value" >>"$config"
}
created_ids=()
INSTALL_COMPLETE=0
PRIVATE_BRIDGE_CREATED=0
LIFECYCLE_CONFIG_PATH=""
UNEXPECTED_FAILURE=0
rollback() {
local status=$? index id
stop_spinner
if (( status != 0 && UNEXPECTED_FAILURE == 1 )); then
msg_error "$(translate "The installation stopped because of an unexpected error")"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
fi
if (( status != 0 && INSTALL_COMPLETE == 0 )); then
if declare -F oci_native_failed >/dev/null; then oci_native_failed; fi
if (( ${#created_ids[@]} > 0 || PRIVATE_BRIDGE_CREATED == 1 )); then
msg_info "$(translate "Removing the incomplete stack...")"
fi
for ((index=${#created_ids[@]}-1; index>=0; index--)); do
id=${created_ids[index]}
pct stop "$id" --skiplock 1 >/dev/null 2>&1 || true
pct destroy "$id" --force 1 --purge 1 >/dev/null 2>&1 \
|| pct destroy "$id" --purge 1 >/dev/null 2>&1 \
|| true
done
if (( PRIVATE_BRIDGE_CREATED == 1 )); then
oci_log "Removing the private bridge created by this installation"
ip link delete "$PRIVATE_BRIDGE" type bridge >/dev/null 2>&1 || true
pvesh delete "/nodes/${NODE}/network/${PRIVATE_BRIDGE}" >/dev/null 2>&1 || true
fi
[[ -z $LIFECYCLE_CONFIG_PATH ]] || rm -f "$LIFECYCLE_CONFIG_PATH"
if (( ${#created_ids[@]} > 0 || PRIVATE_BRIDGE_CREATED == 1 )); then
msg_ok "$(translate "Incomplete stack removed")"
fi
fi
exit "$status"
}
trap rollback EXIT
trap 'UNEXPECTED_FAILURE=1; oci_log "Command failed at line $LINENO (${FUNCNAME[0]:-main})"' ERR
[[ $EUID -eq 0 ]] || die "$(translate "The installer must run as root on Proxmox VE")"
oci_log_init "$(jq -r '.stack_name // "nextcloud"' "$DEPLOYMENT_FILE" 2>/dev/null || printf nextcloud)"
for command in pct qm pvesh pvesm skopeo jq openssl python3 curl ip stat dpkg base64 mktemp lxc-info flock; do
require_command "$command"
done
[[ -r $VERIFY_OCI_ARCHIVE ]] || die "$(translate "The OCI archive verifier was not found")"
[[ -r $ALLOCATE_PRIVATE_NETWORK ]] || die "$(translate "The private network allocator was not found")"
[[ -r $STACK_DEPENDENCY_HOOK ]] || die "$(translate "The stack startup hook was not found")"
msg_info "$(translate "Reserving a private network...")"
exec 9>/run/lock/proxmenux-private-network.lock
flock 9
oci_quiet python3 "$ALLOCATE_PRIVATE_NETWORK" "$DEPLOYMENT_FILE" \
|| die "$(translate "Could not reserve a private network for the stack")"
STACK_NAME=$(jqr '.stack_name')
[[ $STACK_NAME =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || die "$(translate "Invalid stack name")"
BASE_VMID=$(jq -r '.base_vmid // empty' "$DEPLOYMENT_FILE")
TEMPLATE_STORAGE=$(jqr '.template_storage')
ROOTFS_STORAGE=$(jqr '.rootfs_storage')
DATABASE_STORAGE=$(jqr '.database_storage')
DATABASE_SIZE=$(jqr '.database_size_gb')
APPLICATION_MODE=$(jqr '.application.mode')
APPLICATION_STORAGE=$(jq -r '.application.storage // empty' "$DEPLOYMENT_FILE")
APPLICATION_SIZE=$(jq -r '.application.size_gb // empty' "$DEPLOYMENT_FILE")
APPLICATION_ROOT=$(jq -r '.application.host_path // empty' "$DEPLOYMENT_FILE")
ADMIN_USERNAME=$(jqr '.application.admin_username')
PHP_MEMORY_LIMIT=$(jqr '.application.php_memory_limit')
PHP_UPLOAD_LIMIT=$(jqr '.application.php_upload_limit')
APACHE_BODY_LIMIT=$(jqr '.application.apache_body_limit')
TIMEZONE=$(jqr '.timezone')
MAINTENANCE_WINDOW=$(jqr '.maintenance_window_start_utc')
PHONE_REGION=$(jqr '.default_phone_region')
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
FRONTEND_IPV4=$(jqr '.network.frontend_ipv4')
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
FRONTEND_NET=$OCI_ACCESS_NET
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
APPLICATION_ADDRESS=$(jqr '.network.application_address')
DATABASE_ADDRESS=$(jqr '.network.database_address')
CACHE_ADDRESS=$(jqr '.network.cache_address')
APPLICATION_IP=${APPLICATION_ADDRESS%/*}
DATABASE_IP=${DATABASE_ADDRESS%/*}
CACHE_IP=${CACHE_ADDRESS%/*}
[[ $DATABASE_SIZE =~ ^[0-9]+$ ]] && (( DATABASE_SIZE >= 8 )) \
|| die "$(translate "The PostgreSQL volume needs at least 8 GB")"
[[ $ADMIN_USERNAME =~ ^[A-Za-z0-9_.@-]+$ ]] || die "$(translate "Invalid administrator user name")"
case "$APPLICATION_MODE" in
managed-volume)
[[ -n $APPLICATION_STORAGE && $APPLICATION_SIZE =~ ^[0-9]+$ ]] \
&& (( APPLICATION_SIZE >= 8 )) || die "$(translate "Invalid Nextcloud volume")"
;;
host-bind)
[[ $APPLICATION_ROOT == /* && $APPLICATION_ROOT != *","* && $APPLICATION_ROOT != *$'\n'* ]] \
|| die "$(translate "Invalid shared path")"
;;
*) die "$(translate "Unsupported storage mode:") $APPLICATION_MODE" ;;
esac
[[ $PRIVATE_BRIDGE =~ ^vmbr[0-9]+$ ]] || die "$(translate "Invalid private bridge")"
[[ $PRIVATE_SUBNET =~ ^10\.77\.[0-9]{1,3}\.0/24$ ]] || die "$(translate "Invalid private network")"
vmid_block_free() {
local candidate=$1 offset
for offset in 0 1 2; do
pct config "$((candidate + offset))" >/dev/null 2>&1 && return 1
qm config "$((candidate + offset))" >/dev/null 2>&1 && return 1
done
return 0
}
if [[ -z $BASE_VMID ]]; then
BASE_VMID=$(pvesh get /cluster/nextid)
while ! vmid_block_free "$BASE_VMID"; do
BASE_VMID=$((BASE_VMID + 1))
done
fi
[[ $BASE_VMID =~ ^[0-9]+$ ]] || die "$(translate "Invalid base VMID")"
vmid_block_free "$BASE_VMID" || die "$(translate "These VMIDs are not free:") ${BASE_VMID}-$((BASE_VMID + 2))"
APPLICATION_ID=$BASE_VMID
CACHE_ID=$((BASE_VMID + 1))
DATABASE_ID=$((BASE_VMID + 2))
oci_log "Stack: $STACK_NAME; VMIDs: Nextcloud=$APPLICATION_ID, Redis=$CACHE_ID, PostgreSQL=$DATABASE_ID"
oci_log "Private network: $PRIVATE_SUBNET on $PRIVATE_BRIDGE"
if [[ $DRY_RUN == 1 ]]; then
msg_info2 "$(translate "Stack:") $STACK_NAME · CT ${APPLICATION_ID}-${DATABASE_ID}"
msg_info2 "$(translate "Private network:") $PRIVATE_BRIDGE ($PRIVATE_SUBNET)"
msg_ok "$(translate "Dry run completed; no containers were created.")"
exit 0
fi
NODE=$(hostname)
if ! pvesh get "/nodes/${NODE}/network/${PRIVATE_BRIDGE}" >/dev/null 2>&1; then
oci_log "Creating the persistent configuration for $PRIVATE_BRIDGE"
oci_quiet pvesh create "/nodes/${NODE}/network" --iface "$PRIVATE_BRIDGE" --type bridge \
--autostart 1 --cidr "$PRIVATE_HOST_ADDRESS"
PRIVATE_BRIDGE_CREATED=1
fi
if ! ip link show "$PRIVATE_BRIDGE" >/dev/null 2>&1; then
oci_log "Activating the private bridge $PRIVATE_BRIDGE"
oci_quiet ip link add name "$PRIVATE_BRIDGE" type bridge
oci_quiet ip address add "$PRIVATE_HOST_ADDRESS" dev "$PRIVATE_BRIDGE"
oci_quiet ip link set "$PRIVATE_BRIDGE" up
fi
ip -4 address show dev "$PRIVATE_BRIDGE" | grep -Fq "${PRIVATE_HOST_ADDRESS%/*}/" \
|| die "$(translate "The private bridge does not have the expected address:") $PRIVATE_BRIDGE ($PRIVATE_HOST_ADDRESS)"
for address in "$APPLICATION_ADDRESS" "$DATABASE_ADDRESS" "$CACHE_ADDRESS"; do
if grep -RqsF "ip=${address}" /etc/pve/lxc/*.conf 2>/dev/null; then
die "$(translate "The private address is already assigned to another container:") ${address%/*}"
fi
done
flock -u 9
msg_ok "$(translate "Private network:") $PRIVATE_BRIDGE ($PRIVATE_SUBNET)"
ARCH=$(dpkg --print-architecture)
case "$ARCH" in amd64|arm64) ;; *) die "$(translate "Unsupported architecture:") $ARCH" ;; esac
skopeo_transport_reference() {
local reference=$1 name digest
if [[ $reference == *@sha256:* ]]; then
name=${reference%@sha256:*}
digest="sha256:${reference##*@sha256:}"
[[ ${name##*/} == *:* ]] && name=${name%:*}
printf '%s@%s' "$name" "$digest"
else
printf '%s' "$reference"
fi
}
resolve_image_manifest() {
local label=$1 image=$2 manifest_file error_file pid elapsed=0 status=0
manifest_file=$(mktemp /tmp/proxmenux-oci-inspect.XXXXXX)
error_file="${manifest_file}.err"
oci_log "Querying the OCI registry for ${label}: ${image}"
skopeo inspect --no-tags --override-os linux --override-arch "$ARCH" \
"docker://${image}" >"$manifest_file" 2>"$error_file" &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
sleep 2
elapsed=$((elapsed + 2))
done
wait "$pid" || status=$?
if (( status != 0 )); then
cat "$error_file" >>"$OCI_LOG"
rm -f "$manifest_file" "$error_file"
return "$status"
fi
oci_log "Manifest for ${label} resolved in ${elapsed}s"
cat "$manifest_file"
rm -f "$manifest_file" "$error_file"
}
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
msg_info "$(translate "Checking the image in the registry...")"
oci_log "Resolving ${key}: ${image}"
transport_image=$(skopeo_transport_reference "$image")
inspect=$(resolve_image_manifest "$key" "$transport_image") \
|| die "$(translate "Could not resolve the OCI manifest:") $image"
digest=$(jq -er '.Digest' <<<"$inspect")
oci_log "Selected digest for ${key}: ${digest}"
short=${digest#sha256:}
short=${short:0:16}
archive_name="image-nextcloud-${key}_${ARCH}_${short}.tar"
archive_volume="${TEMPLATE_STORAGE}:vztmpl/${archive_name}"
archive_path=$(pvesm path "$archive_volume")
mkdir -p "$(dirname "$archive_path")"
if [[ -s $archive_path ]]; then
msg_info "$(translate "Verifying the image integrity...")"
fi
if [[ -s $archive_path ]] && oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$archive_path"; then
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))
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
RESOLVED_DIGEST=$digest
}
APPLICATION_IMAGE=$(jq -er '.container_contract.image.reference' "$TEMPLATE_FILE")
DATABASE_IMAGE=$(jq -er '.compose_stack.services[] | select(.name == "database") | .image' "$TEMPLATE_FILE")
CACHE_IMAGE=$(jq -er '.compose_stack.services[] | select(.name == "cache") | .image' "$TEMPLATE_FILE")
ensure_image application "$APPLICATION_IMAGE"
APPLICATION_ARCHIVE=$RESOLVED_ARCHIVE
APPLICATION_DIGEST=$RESOLVED_DIGEST
ensure_image database "$DATABASE_IMAGE"
DATABASE_ARCHIVE=$RESOLVED_ARCHIVE
DATABASE_DIGEST=$RESOLVED_DIGEST
ensure_image cache "$CACHE_IMAGE"
CACHE_ARCHIVE=$RESOLVED_ARCHIVE
CACHE_DIGEST=$RESOLVED_DIGEST
source "$SCRIPT_DIR/oci_native_stack.sh"
oci_native_begin "$APPLICATION_ID" \
--member application "$APPLICATION_ID" "$APPLICATION_IMAGE" "$APPLICATION_ARCHIVE" \
--member cache "$CACHE_ID" "$CACHE_IMAGE" "$CACHE_ARCHIVE" \
--member database "$DATABASE_ID" "$DATABASE_IMAGE" "$DATABASE_ARCHIVE"
DB_PASSWORD=$(openssl rand -hex 24)
ADMIN_PASSWORD=$(openssl rand -hex 16)
if [[ $APPLICATION_MODE == host-bind ]]; then
install -d -m 0750 -o 100000 -g 100000 "$APPLICATION_ROOT"
APPLICATION_MOUNT="${APPLICATION_ROOT},mp=/var/www/html,backup=0"
else
APPLICATION_MOUNT="${APPLICATION_STORAGE}:${APPLICATION_SIZE},mp=/var/www/html,backup=1"
fi
TAGS="productivity;oci;proxmenux"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql,backup=1" \
--hostname "${STACK_NAME}-db" --cores 2 --memory 1024 --swap 512 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DATABASE_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=10,up=10,down=30 --tags "$TAGS" \
--description 'Nextcloud PostgreSQL native OCI'
created_ids+=("$DATABASE_ID")
oci_quiet pct mount "$DATABASE_ID"
DATABASE_ROOT="/var/lib/lxc/${DATABASE_ID}/rootfs"
POSTGRES_UID=$(awk -F: '$1 == "postgres" {print $3}' "$DATABASE_ROOT/etc/passwd")
POSTGRES_GID=$(awk -F: '$1 == "postgres" {print $4}' "$DATABASE_ROOT/etc/passwd")
[[ -n $POSTGRES_UID && -n $POSTGRES_GID ]] || die "$(translate "The postgres user was not found in the image")"
rm -rf "$DATABASE_ROOT/var/lib/postgresql/lost+found"
install -d -m 0700 -o "$((100000 + POSTGRES_UID))" -g "$((100000 + POSTGRES_GID))" \
"$DATABASE_ROOT/var/lib/postgresql/data/pgdata"
cat >"$DATABASE_ROOT/usr/local/bin/nextcloud-postgres-lxc-start" <<EOF
#!/bin/sh
set -eu
exec docker-entrypoint.sh postgres -c 'listen_addresses=127.0.0.1,${DATABASE_IP}'
EOF
chmod 0755 "$DATABASE_ROOT/usr/local/bin/nextcloud-postgres-lxc-start"
chown 100000:100000 "$DATABASE_ROOT/usr/local/bin/nextcloud-postgres-lxc-start"
oci_quiet pct unmount "$DATABASE_ID"
oci_quiet pct set "$DATABASE_ID" --entrypoint /usr/local/bin/nextcloud-postgres-lxc-start
set_lxc_directive "$DATABASE_ID" lxc.init.cwd /
set_lxc_directive "$DATABASE_ID" lxc.signal.halt SIGINT
set_runtime_env "$DATABASE_ID" POSTGRES_DB nextcloud
set_runtime_env "$DATABASE_ID" POSTGRES_USER nextcloud
set_runtime_env "$DATABASE_ID" POSTGRES_PASSWORD "$DB_PASSWORD"
set_runtime_env "$DATABASE_ID" POSTGRES_INITDB_ARGS --data-checksums
set_runtime_env "$DATABASE_ID" PGDATA /var/lib/postgresql/data/pgdata
set_runtime_env "$DATABASE_ID" TZ "$TIMEZONE"
msg_ok "$(translate "Container created:") CT $DATABASE_ID (PostgreSQL)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$CACHE_ID" "$CACHE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
--hostname "${STACK_NAME}-redis" --cores 1 --memory 512 --swap 256 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${CACHE_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=20,up=5,down=15 --tags "$TAGS" \
--description 'Nextcloud Redis native OCI'
created_ids+=("$CACHE_ID")
oci_quiet pct set "$CACHE_ID" --entrypoint 'docker-entrypoint.sh redis-server'
set_lxc_directive "$CACHE_ID" lxc.init.cwd /data
set_lxc_directive "$CACHE_ID" lxc.signal.halt SIGTERM
msg_ok "$(translate "Container created:") CT $CACHE_ID (Redis)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "$APPLICATION_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 1024 \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=30,up=15,down=30 --tags "$TAGS" \
--description 'Nextcloud Apache native OCI'
created_ids+=("$APPLICATION_ID")
oci_quiet pct mount "$APPLICATION_ID"
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
rm -rf "$APPLICATION_ROOTFS/var/www/html/lost+found"
cat >"$APPLICATION_ROOTFS/usr/local/bin/nextcloud-lxc-start" <<EOF
#!/bin/sh
set -eu
i=0
until php -r '\$s=@fsockopen("${DATABASE_IP}",5432,\$e,\$m,1); if(!\$s){exit(1);} fclose(\$s);'; do
i=\$((i + 1)); test "\$i" -lt 90 || exit 1; sleep 2
done
i=0
until php -r '\$s=@fsockopen("${CACHE_IP}",6379,\$e,\$m,1); if(!\$s){exit(1);} fclose(\$s);'; do
i=\$((i + 1)); test "\$i" -lt 60 || exit 1; sleep 2
done
/cron.sh &
exec /entrypoint.sh apache2-foreground
EOF
chmod 0755 "$APPLICATION_ROOTFS/usr/local/bin/nextcloud-lxc-start"
chown 100000:100000 "$APPLICATION_ROOTFS/usr/local/bin/nextcloud-lxc-start"
oci_quiet pct unmount "$APPLICATION_ID"
oci_quiet pct set "$APPLICATION_ID" --entrypoint /usr/local/bin/nextcloud-lxc-start
set_lxc_directive "$APPLICATION_ID" lxc.init.cwd /var/www/html
set_lxc_directive "$APPLICATION_ID" lxc.signal.halt SIGWINCH
set_runtime_env "$APPLICATION_ID" POSTGRES_HOST "$DATABASE_IP"
set_runtime_env "$APPLICATION_ID" POSTGRES_DB nextcloud
set_runtime_env "$APPLICATION_ID" POSTGRES_USER nextcloud
set_runtime_env "$APPLICATION_ID" POSTGRES_PASSWORD "$DB_PASSWORD"
set_runtime_env "$APPLICATION_ID" REDIS_HOST "$CACHE_IP"
set_runtime_env "$APPLICATION_ID" NEXTCLOUD_ADMIN_USER "$ADMIN_USERNAME"
set_runtime_env "$APPLICATION_ID" NEXTCLOUD_ADMIN_PASSWORD "$ADMIN_PASSWORD"
set_runtime_env "$APPLICATION_ID" NEXTCLOUD_INIT_HTACCESS true
set_runtime_env "$APPLICATION_ID" PHP_MEMORY_LIMIT "$PHP_MEMORY_LIMIT"
set_runtime_env "$APPLICATION_ID" PHP_UPLOAD_LIMIT "$PHP_UPLOAD_LIMIT"
set_runtime_env "$APPLICATION_ID" APACHE_BODY_LIMIT "$APACHE_BODY_LIMIT"
set_runtime_env "$APPLICATION_ID" TZ "$TIMEZONE"
msg_ok "$(translate "Container created:") CT $APPLICATION_ID (Nextcloud)"
msg_info "$(translate "Installing the stack startup hook...")"
LIFECYCLE_SPEC=$(mktemp /tmp/proxmenux-stack-lifecycle.XXXXXX)
jq -nc --arg stack "$STACK_NAME" --argjson db "$DATABASE_ID" \
--arg db_ip "$DATABASE_IP" --argjson cache "$CACHE_ID" --arg cache_ip "$CACHE_IP" '
{
schema: 1,
stack: $stack,
dependencies: [
{vmid: $db, label: "PostgreSQL", healthcheck: {
type: "exec", timeout_seconds: 120,
argv: ["pg_isready", "-h", $db_ip, "-U", "nextcloud", "-d", "nextcloud"]
}},
{vmid: $cache, label: "Redis", healthcheck: {
type: "exec", timeout_seconds: 90,
argv: ["redis-cli", "-h", $cache_ip, "ping"]
}}
]
}' >"$LIFECYCLE_SPEC"
LIFECYCLE_CONFIG_PATH="/etc/pve/priv/proxmenux-stack-${APPLICATION_ID}.json"
if ! oci_quiet bash "$STACK_DEPENDENCY_HOOK" --install "$APPLICATION_ID" "$LIFECYCLE_SPEC"; then
rm -f "$LIFECYCLE_SPEC"
die "$(translate "Could not install the stack startup hook")"
fi
rm -f "$LIFECYCLE_SPEC"
msg_ok "$(translate "Stack startup hook installed")"
wait_command() {
local label=$1 retries=$2
shift 2
local attempt
for attempt in $(seq 1 "$retries"); do
"$@" >/dev/null 2>&1 && return 0
sleep 2
done
die "$(translate "Health check failed:") $label"
}
APPLICATION_LAN_IP=""
if (( START_AFTER == 1 )); then
msg_info "$(translate "Starting the service:") PostgreSQL"
oci_quiet pct start "$DATABASE_ID"
wait_command PostgreSQL 60 pct exec "$DATABASE_ID" -- \
pg_isready -h "$DATABASE_IP" -U nextcloud -d nextcloud
msg_ok "$(translate "Service ready:") PostgreSQL"
msg_info "$(translate "Starting the service:") Redis"
oci_quiet pct start "$CACHE_ID"
wait_command Redis 60 pct exec "$CACHE_ID" -- redis-cli -h "$CACHE_IP" ping
msg_ok "$(translate "Service ready:") Redis"
msg_info "$(translate "Starting the service:") Nextcloud"
oci_quiet pct start "$APPLICATION_ID"
msg_info "$(translate "Waiting for the application to respond...")"
for _ in $(seq 1 120); do
APPLICATION_LAN_IP=$(lxc-info -n "$APPLICATION_ID" -iH 2>/dev/null \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -vFx "$APPLICATION_IP" | head -n 1 || true)
if [[ -n $APPLICATION_LAN_IP ]] \
&& curl -fsS -H 'Host: localhost' \
"http://${APPLICATION_LAN_IP}/status.php" >/dev/null 2>&1; then
break
fi
sleep 2
done
[[ -n $APPLICATION_LAN_IP ]] \
|| die "$(translate "The application did not get an address on the access network:") Nextcloud"
# Nextcloud 34 validates status.php against trusted_domains before the LAN IP
# can be registered. localhost is the official image's initial trusted host.
STATUS_JSON=$(curl -fsS -H 'Host: localhost' \
"http://${APPLICATION_LAN_IP}/status.php")
jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false' \
<<<"$STATUS_JSON" >/dev/null \
|| die "$(translate "The application did not complete its initial setup:") Nextcloud"
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}/"
msg_info "$(translate "Applying the initial Nextcloud settings...")"
OCC=(pct exec "$APPLICATION_ID" -- su -s /bin/sh www-data -c)
oci_quiet "${OCC[@]}" "php /var/www/html/occ config:system:set trusted_domains 1 --value='${APPLICATION_LAN_IP}'"
oci_quiet "${OCC[@]}" "php /var/www/html/occ config:system:set trusted_domains 2 --value='${STACK_NAME}'"
oci_quiet "${OCC[@]}" "php /var/www/html/occ config:system:set maintenance_window_start --type=integer --value='${MAINTENANCE_WINDOW}'"
oci_quiet "${OCC[@]}" "php /var/www/html/occ config:system:set default_phone_region --value='${PHONE_REGION}'"
oci_quiet "${OCC[@]}" "php /var/www/html/occ background:cron"
oci_quiet "${OCC[@]}" "php /var/www/html/occ maintenance:repair --include-expensive"
msg_ok "$(translate "Initial Nextcloud settings applied")"
fi
RESULT=$(jq -nc \
--argjson vmid "$APPLICATION_ID" \
--arg ip "$APPLICATION_LAN_IP" \
--argjson application_id "$APPLICATION_ID" \
--argjson database_id "$DATABASE_ID" \
--argjson cache_id "$CACHE_ID" \
--arg admin_user "$ADMIN_USERNAME" \
--arg admin_password "$ADMIN_PASSWORD" \
--arg application_digest "$APPLICATION_DIGEST" \
--arg database_digest "$DATABASE_DIGEST" \
--arg cache_digest "$CACHE_DIGEST" \
--arg admin_label "$(translate "Initial Nextcloud administrator")" \
--arg log "$OCI_LOG" \
'{
vmid: $vmid,
ip: (if $ip == "" then null else $ip end),
stack_vmids: {
application: $application_id,
database: $database_id,
cache: $cache_id
},
urls: (if $ip == "" then [] else [{label: "Nextcloud WebUI", url: ("http://" + $ip + "/")}] end),
credentials: [{
label: $admin_label,
username: $admin_user,
password: $admin_password,
change_required: true
}],
image_digests: {
application: $application_digest,
database: $database_digest,
cache: $cache_digest
},
log: $log
}')
INSTALL_COMPLETE=1
oci_native_finalize
printf 'PROXMENUX_RESULT=%s\n' "$(printf '%s' "$RESULT" | base64 -w0)"
+1918
View File
File diff suppressed because it is too large Load Diff
+542
View File
@@ -0,0 +1,542 @@
#!/usr/bin/env bash
set -Eeuo pipefail
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
DRY_RUN=${3:-0}
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SCRIPT_DIR/oci_ui.sh"
VERIFY_OCI_ARCHIVE="${SCRIPT_DIR}/verify_oci_archive.py"
ALLOCATE_PRIVATE_NETWORK="${SCRIPT_DIR}/allocate_private_network.py"
STACK_DEPENDENCY_HOOK="${SCRIPT_DIR}/stack_dependency_hook.sh"
die() {
stop_spinner
msg_error "$*"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$(translate "Missing required command:") $1"
}
jqr() {
jq -er "$1" "$DEPLOYMENT_FILE"
}
set_runtime_env() {
local id=$1 key=$2 value=$3 config="/etc/pve/lxc/${1}.conf"
sed -i -E "/^lxc\.environment\.runtime: ${key}=/d" "$config"
printf 'lxc.environment.runtime: %s=%s\n' "$key" "$value" >>"$config"
}
set_lxc_directive() {
local id=$1 key=$2 value=$3 config="/etc/pve/lxc/${1}.conf" escaped_key
escaped_key=${key//./\.}
sed -i -E "/^${escaped_key}:/d" "$config"
printf '%s: %s\n' "$key" "$value" >>"$config"
}
created_ids=()
INSTALL_COMPLETE=0
PRIVATE_BRIDGE_CREATED=0
LIFECYCLE_CONFIG_PATH=""
UNEXPECTED_FAILURE=0
rollback() {
local status=$? index id
stop_spinner
if (( status != 0 && UNEXPECTED_FAILURE == 1 )); then
msg_error "$(translate "The installation stopped because of an unexpected error")"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
fi
if (( status != 0 && INSTALL_COMPLETE == 0 )); then
if declare -F oci_native_failed >/dev/null; then oci_native_failed; fi
if (( ${#created_ids[@]} > 0 || PRIVATE_BRIDGE_CREATED == 1 )); then
msg_info "$(translate "Removing the incomplete stack...")"
fi
for ((index=${#created_ids[@]}-1; index>=0; index--)); do
id=${created_ids[index]}
pct stop "$id" --skiplock 1 >/dev/null 2>&1 || true
pct destroy "$id" --force 1 --purge 1 >/dev/null 2>&1 \
|| pct destroy "$id" --purge 1 >/dev/null 2>&1 \
|| true
done
if (( PRIVATE_BRIDGE_CREATED == 1 )); then
oci_log "Removing the private bridge created by this installation"
ip link delete "$PRIVATE_BRIDGE" type bridge >/dev/null 2>&1 || true
pvesh delete "/nodes/${NODE}/network/${PRIVATE_BRIDGE}" >/dev/null 2>&1 || true
fi
[[ -z $LIFECYCLE_CONFIG_PATH ]] || rm -f "$LIFECYCLE_CONFIG_PATH"
if (( ${#created_ids[@]} > 0 || PRIVATE_BRIDGE_CREATED == 1 )); then
msg_ok "$(translate "Incomplete stack removed")"
fi
fi
exit "$status"
}
trap rollback EXIT
trap 'UNEXPECTED_FAILURE=1; oci_log "Command failed at line $LINENO (${FUNCNAME[0]:-main})"' ERR
[[ $EUID -eq 0 ]] || die "$(translate "The installer must run as root on Proxmox VE")"
oci_log_init "$(jq -r '.stack_name // "paperless"' "$DEPLOYMENT_FILE" 2>/dev/null || printf paperless)"
for command in pct qm pvesh pvesm skopeo jq openssl python3 curl ip stat dpkg base64 mktemp lxc-info flock; do
require_command "$command"
done
[[ -r $VERIFY_OCI_ARCHIVE ]] || die "$(translate "The OCI archive verifier was not found")"
[[ -r $ALLOCATE_PRIVATE_NETWORK ]] || die "$(translate "The private network allocator was not found")"
[[ -r $STACK_DEPENDENCY_HOOK ]] || die "$(translate "The stack startup hook was not found")"
msg_info "$(translate "Reserving a private network...")"
exec 9>/run/lock/proxmenux-private-network.lock
flock 9
oci_quiet python3 "$ALLOCATE_PRIVATE_NETWORK" "$DEPLOYMENT_FILE" \
|| die "$(translate "Could not reserve a private network for the stack")"
STACK_NAME=$(jqr '.stack_name')
[[ $STACK_NAME =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || die "$(translate "Invalid stack name")"
BASE_VMID=$(jq -r '.base_vmid // empty' "$DEPLOYMENT_FILE")
TEMPLATE_STORAGE=$(jqr '.template_storage')
ROOTFS_STORAGE=$(jqr '.rootfs_storage')
DATABASE_STORAGE=$(jqr '.database_storage')
DATABASE_SIZE=$(jqr '.database_size_gb')
BROKER_SIZE=$(jqr '.broker_size_gb')
APPLICATION_STORAGE=$(jqr '.application_storage')
DATA_SIZE=$(jqr '.data_size_gb')
MEDIA_SIZE=$(jqr '.media_size_gb')
TRANSFER_MODE=$(jqr '.transfer.mode')
TRANSFER_STORAGE=$(jq -r '.transfer.storage // empty' "$DEPLOYMENT_FILE")
TRANSFER_SIZE=$(jq -r '.transfer.size_gb // empty' "$DEPLOYMENT_FILE")
TRANSFER_ROOT=$(jq -r '.transfer.host_path // empty' "$DEPLOYMENT_FILE")
ADMIN_USERNAME=$(jqr '.application.admin_username')
OCR_LANGUAGE=$(jqr '.application.ocr_language')
TIMEZONE=$(jqr '.timezone')
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
FRONTEND_IPV4=$(jqr '.network.frontend_ipv4')
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
FRONTEND_NET=$OCI_ACCESS_NET
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
APPLICATION_ADDRESS=$(jqr '.network.application_address')
DATABASE_ADDRESS=$(jqr '.network.database_address')
BROKER_ADDRESS=$(jqr '.network.broker_address')
APPLICATION_IP=${APPLICATION_ADDRESS%/*}
DATABASE_IP=${DATABASE_ADDRESS%/*}
BROKER_IP=${BROKER_ADDRESS%/*}
[[ $DATABASE_SIZE =~ ^[0-9]+$ ]] && (( DATABASE_SIZE >= 8 )) \
|| die "$(translate "The PostgreSQL volume needs at least 8 GB")"
[[ $DATA_SIZE =~ ^[0-9]+$ && $MEDIA_SIZE =~ ^[0-9]+$ ]] \
&& (( DATA_SIZE >= 8 && MEDIA_SIZE >= 8 )) \
|| die "$(translate "The data and document volumes need at least 8 GB")"
[[ $BROKER_SIZE =~ ^[0-9]+$ ]] && (( BROKER_SIZE >= 1 )) \
|| die "$(translate "The Valkey volume needs at least 1 GB")"
[[ $ADMIN_USERNAME =~ ^[A-Za-z0-9_.@-]+$ ]] || die "$(translate "Invalid administrator user name")"
[[ $OCR_LANGUAGE =~ ^[a-z]{3}(\+[a-z]{3})*$ ]] \
|| die "$(translate "Invalid OCR language:") $OCR_LANGUAGE"
case "$TRANSFER_MODE" in
managed-volume)
[[ -n $TRANSFER_STORAGE && $TRANSFER_SIZE =~ ^[0-9]+$ ]] \
&& (( TRANSFER_SIZE >= 1 )) || die "$(translate "Invalid consume/export volumes")"
;;
host-bind)
[[ $TRANSFER_ROOT == /* && $TRANSFER_ROOT != *","* && $TRANSFER_ROOT != *$'\n'* ]] \
|| die "$(translate "Invalid shared path")"
;;
*) die "$(translate "Unsupported storage mode:") $TRANSFER_MODE" ;;
esac
[[ $PRIVATE_BRIDGE =~ ^vmbr[0-9]+$ ]] || die "$(translate "Invalid private bridge")"
[[ $PRIVATE_SUBNET =~ ^10\.77\.[0-9]{1,3}\.0/24$ ]] || die "$(translate "Invalid private network")"
vmid_block_free() {
local candidate=$1 offset
for offset in 0 1 2; do
pct config "$((candidate + offset))" >/dev/null 2>&1 && return 1
qm config "$((candidate + offset))" >/dev/null 2>&1 && return 1
done
return 0
}
if [[ -z $BASE_VMID ]]; then
BASE_VMID=$(pvesh get /cluster/nextid)
while ! vmid_block_free "$BASE_VMID"; do
BASE_VMID=$((BASE_VMID + 1))
done
fi
[[ $BASE_VMID =~ ^[0-9]+$ ]] || die "$(translate "Invalid base VMID")"
vmid_block_free "$BASE_VMID" || die "$(translate "These VMIDs are not free:") ${BASE_VMID}-$((BASE_VMID + 2))"
APPLICATION_ID=$BASE_VMID
BROKER_ID=$((BASE_VMID + 1))
DATABASE_ID=$((BASE_VMID + 2))
oci_log "Stack: $STACK_NAME; VMIDs: Paperless=$APPLICATION_ID, Valkey=$BROKER_ID, PostgreSQL=$DATABASE_ID"
oci_log "Private network: $PRIVATE_SUBNET on $PRIVATE_BRIDGE"
if [[ $DRY_RUN == 1 ]]; then
msg_info2 "$(translate "Stack:") $STACK_NAME · CT ${APPLICATION_ID}-${DATABASE_ID}"
msg_info2 "$(translate "Private network:") $PRIVATE_BRIDGE ($PRIVATE_SUBNET)"
msg_ok "$(translate "Dry run completed; no containers were created.")"
exit 0
fi
NODE=$(hostname)
if ! pvesh get "/nodes/${NODE}/network/${PRIVATE_BRIDGE}" >/dev/null 2>&1; then
oci_log "Creating the persistent configuration for $PRIVATE_BRIDGE"
oci_quiet pvesh create "/nodes/${NODE}/network" --iface "$PRIVATE_BRIDGE" --type bridge \
--autostart 1 --cidr "$PRIVATE_HOST_ADDRESS"
PRIVATE_BRIDGE_CREATED=1
fi
if ! ip link show "$PRIVATE_BRIDGE" >/dev/null 2>&1; then
oci_log "Activating the private bridge $PRIVATE_BRIDGE"
oci_quiet ip link add name "$PRIVATE_BRIDGE" type bridge
oci_quiet ip address add "$PRIVATE_HOST_ADDRESS" dev "$PRIVATE_BRIDGE"
oci_quiet ip link set "$PRIVATE_BRIDGE" up
fi
ip -4 address show dev "$PRIVATE_BRIDGE" | grep -Fq "${PRIVATE_HOST_ADDRESS%/*}/" \
|| die "$(translate "The private bridge does not have the expected address:") $PRIVATE_BRIDGE ($PRIVATE_HOST_ADDRESS)"
for address in "$APPLICATION_ADDRESS" "$DATABASE_ADDRESS" "$BROKER_ADDRESS"; do
if grep -RqsF "ip=${address}" /etc/pve/lxc/*.conf 2>/dev/null; then
die "$(translate "The private address is already assigned to another container:") ${address%/*}"
fi
done
flock -u 9
msg_ok "$(translate "Private network:") $PRIVATE_BRIDGE ($PRIVATE_SUBNET)"
ARCH=$(dpkg --print-architecture)
case "$ARCH" in amd64|arm64) ;; *) die "$(translate "Unsupported architecture:") $ARCH" ;; esac
skopeo_transport_reference() {
local reference=$1 name digest
if [[ $reference == *@sha256:* ]]; then
name=${reference%@sha256:*}
digest="sha256:${reference##*@sha256:}"
[[ ${name##*/} == *:* ]] && name=${name%:*}
printf '%s@%s' "$name" "$digest"
else
printf '%s' "$reference"
fi
}
resolve_image_manifest() {
local label=$1 image=$2 manifest_file error_file pid elapsed=0 status=0
manifest_file=$(mktemp /tmp/proxmenux-oci-inspect.XXXXXX)
error_file="${manifest_file}.err"
oci_log "Querying the OCI registry for ${label}: ${image}"
skopeo inspect --no-tags --override-os linux --override-arch "$ARCH" \
"docker://${image}" >"$manifest_file" 2>"$error_file" &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
sleep 2
elapsed=$((elapsed + 2))
done
wait "$pid" || status=$?
if (( status != 0 )); then
cat "$error_file" >>"$OCI_LOG"
rm -f "$manifest_file" "$error_file"
return "$status"
fi
oci_log "Manifest for ${label} resolved in ${elapsed}s"
cat "$manifest_file"
rm -f "$manifest_file" "$error_file"
}
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
msg_info "$(translate "Checking the image in the registry...")"
oci_log "Resolving ${key}: ${image}"
transport_image=$(skopeo_transport_reference "$image")
inspect=$(resolve_image_manifest "$key" "$transport_image") \
|| die "$(translate "Could not resolve the OCI manifest:") $image"
digest=$(jq -er '.Digest' <<<"$inspect")
oci_log "Selected digest for ${key}: ${digest}"
short=${digest#sha256:}
short=${short:0:16}
archive_name="image-paperless-${key}_${ARCH}_${short}.tar"
archive_volume="${TEMPLATE_STORAGE}:vztmpl/${archive_name}"
archive_path=$(pvesm path "$archive_volume")
mkdir -p "$(dirname "$archive_path")"
if [[ -s $archive_path ]]; then
msg_info "$(translate "Verifying the image integrity...")"
fi
if [[ -s $archive_path ]] && oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$archive_path"; then
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))
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
RESOLVED_DIGEST=$digest
}
APPLICATION_IMAGE=$(jq -er '.container_contract.image.reference' "$TEMPLATE_FILE")
DATABASE_IMAGE=$(jq -er '.compose_stack.services[] | select(.name == "db") | .image' "$TEMPLATE_FILE")
BROKER_IMAGE=$(jq -er '.compose_stack.services[] | select(.name == "broker") | .image' "$TEMPLATE_FILE")
ensure_image application "$APPLICATION_IMAGE"
APPLICATION_ARCHIVE=$RESOLVED_ARCHIVE
APPLICATION_DIGEST=$RESOLVED_DIGEST
ensure_image database "$DATABASE_IMAGE"
DATABASE_ARCHIVE=$RESOLVED_ARCHIVE
DATABASE_DIGEST=$RESOLVED_DIGEST
ensure_image broker "$BROKER_IMAGE"
BROKER_ARCHIVE=$RESOLVED_ARCHIVE
BROKER_DIGEST=$RESOLVED_DIGEST
source "$SCRIPT_DIR/oci_native_stack.sh"
oci_native_begin "$APPLICATION_ID" \
--member application "$APPLICATION_ID" "$APPLICATION_IMAGE" "$APPLICATION_ARCHIVE" \
--member broker "$BROKER_ID" "$BROKER_IMAGE" "$BROKER_ARCHIVE" \
--member database "$DATABASE_ID" "$DATABASE_IMAGE" "$DATABASE_ARCHIVE"
DB_PASSWORD=$(openssl rand -hex 24)
ADMIN_PASSWORD=$(openssl rand -hex 16)
SECRET_KEY=$(openssl rand -hex 64)
if [[ $TRANSFER_MODE == host-bind ]]; then
for path in "$TRANSFER_ROOT/consume" "$TRANSFER_ROOT/export"; do
if [[ -e $path ]]; then
[[ -d $path ]] || die "$(translate "The shared path exists but is not a directory:") $path"
else
install -d -m 0770 -o 101000 -g 101000 "$path"
fi
done
CONSUME_MOUNT="${TRANSFER_ROOT}/consume,mp=/usr/src/paperless/consume,backup=0"
EXPORT_MOUNT="${TRANSFER_ROOT}/export,mp=/usr/src/paperless/export,backup=0"
else
CONSUME_MOUNT="${TRANSFER_STORAGE}:${TRANSFER_SIZE},mp=/usr/src/paperless/consume,backup=1"
EXPORT_MOUNT="${TRANSFER_STORAGE}:${TRANSFER_SIZE},mp=/usr/src/paperless/export,backup=1"
fi
TAGS="productivity;oci;proxmenux"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql,backup=1" \
--hostname "${STACK_NAME}-db" --cores 2 --memory 1024 --swap 512 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DATABASE_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=10,up=10,down=30 --tags "$TAGS" \
--description 'Paperless PostgreSQL native OCI'
created_ids+=("$DATABASE_ID")
oci_quiet pct mount "$DATABASE_ID"
DATABASE_ROOT="/var/lib/lxc/${DATABASE_ID}/rootfs"
POSTGRES_UID=$(awk -F: '$1 == "postgres" {print $3}' "$DATABASE_ROOT/etc/passwd")
POSTGRES_GID=$(awk -F: '$1 == "postgres" {print $4}' "$DATABASE_ROOT/etc/passwd")
[[ -n $POSTGRES_UID && -n $POSTGRES_GID ]] || die "$(translate "The postgres user was not found in the image")"
rm -rf "$DATABASE_ROOT/var/lib/postgresql/lost+found"
cat >"$DATABASE_ROOT/usr/local/bin/paperless-postgres-lxc-start" <<EOF
#!/bin/sh
set -eu
exec docker-entrypoint.sh postgres -c 'listen_addresses=127.0.0.1,${DATABASE_IP}'
EOF
chmod 0755 "$DATABASE_ROOT/usr/local/bin/paperless-postgres-lxc-start"
chown 100000:100000 "$DATABASE_ROOT/usr/local/bin/paperless-postgres-lxc-start"
oci_quiet pct unmount "$DATABASE_ID"
oci_quiet pct set "$DATABASE_ID" --entrypoint /usr/local/bin/paperless-postgres-lxc-start
set_lxc_directive "$DATABASE_ID" lxc.init.cwd /
set_lxc_directive "$DATABASE_ID" lxc.signal.halt SIGINT
set_runtime_env "$DATABASE_ID" POSTGRES_DB paperless
set_runtime_env "$DATABASE_ID" POSTGRES_USER paperless
set_runtime_env "$DATABASE_ID" POSTGRES_PASSWORD "$DB_PASSWORD"
set_runtime_env "$DATABASE_ID" POSTGRES_INITDB_ARGS --data-checksums
set_runtime_env "$DATABASE_ID" TZ "$TIMEZONE"
msg_ok "$(translate "Container created:") CT $DATABASE_ID (PostgreSQL)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$BROKER_ID" "$BROKER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
--mp0 "${APPLICATION_STORAGE}:${BROKER_SIZE},mp=/data,backup=1" \
--hostname "${STACK_NAME}-valkey" --cores 1 --memory 512 --swap 256 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${BROKER_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=20,up=5,down=15 --tags "$TAGS" \
--description 'Paperless Valkey native OCI'
created_ids+=("$BROKER_ID")
oci_quiet pct mount "$BROKER_ID"
rm -rf "/var/lib/lxc/${BROKER_ID}/rootfs/data/lost+found"
oci_quiet pct unmount "$BROKER_ID"
oci_quiet pct set "$BROKER_ID" --entrypoint 'tini -- docker-entrypoint.sh valkey-server'
set_lxc_directive "$BROKER_ID" lxc.init.cwd /data
set_lxc_directive "$BROKER_ID" lxc.signal.halt SIGTERM
msg_ok "$(translate "Container created:") CT $BROKER_ID (Valkey)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${APPLICATION_STORAGE}:${DATA_SIZE},mp=/usr/src/paperless/data,backup=1" \
--mp1 "${APPLICATION_STORAGE}:${MEDIA_SIZE},mp=/usr/src/paperless/media,backup=1" \
--mp2 "$EXPORT_MOUNT" --mp3 "$CONSUME_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 1024 \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=30,up=15,down=30 --tags "$TAGS" \
--description 'Paperless-ngx native OCI'
created_ids+=("$APPLICATION_ID")
oci_quiet pct mount "$APPLICATION_ID"
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
for path in data media export consume; do
rm -rf "$APPLICATION_ROOTFS/usr/src/paperless/${path}/lost+found"
done
oci_quiet pct unmount "$APPLICATION_ID"
set_runtime_env "$APPLICATION_ID" PAPERLESS_REDIS "redis://${BROKER_IP}:6379"
set_runtime_env "$APPLICATION_ID" PAPERLESS_DBHOST "$DATABASE_IP"
set_runtime_env "$APPLICATION_ID" PAPERLESS_DBPORT 5432
set_runtime_env "$APPLICATION_ID" PAPERLESS_DBENGINE postgresql
set_runtime_env "$APPLICATION_ID" PAPERLESS_DBNAME paperless
set_runtime_env "$APPLICATION_ID" PAPERLESS_DBUSER paperless
set_runtime_env "$APPLICATION_ID" PAPERLESS_DBPASS "$DB_PASSWORD"
set_runtime_env "$APPLICATION_ID" PAPERLESS_SECRET_KEY "$SECRET_KEY"
set_runtime_env "$APPLICATION_ID" PAPERLESS_ADMIN_USER "$ADMIN_USERNAME"
set_runtime_env "$APPLICATION_ID" PAPERLESS_ADMIN_PASSWORD "$ADMIN_PASSWORD"
set_runtime_env "$APPLICATION_ID" PAPERLESS_TIME_ZONE "$TIMEZONE"
set_runtime_env "$APPLICATION_ID" PAPERLESS_OCR_LANGUAGE "$OCR_LANGUAGE"
set_runtime_env "$APPLICATION_ID" USERMAP_UID 1000
set_runtime_env "$APPLICATION_ID" USERMAP_GID 1000
msg_ok "$(translate "Container created:") CT $APPLICATION_ID (Paperless-ngx)"
msg_info "$(translate "Installing the stack startup hook...")"
LIFECYCLE_SPEC=$(mktemp /tmp/proxmenux-stack-lifecycle.XXXXXX)
jq -nc --arg stack "$STACK_NAME" --argjson db "$DATABASE_ID" \
--arg db_ip "$DATABASE_IP" --argjson broker "$BROKER_ID" --arg broker_ip "$BROKER_IP" '
{
schema: 1,
stack: $stack,
dependencies: [
{vmid: $db, label: "PostgreSQL", healthcheck: {
type: "exec", timeout_seconds: 120,
argv: ["pg_isready", "-h", $db_ip, "-U", "paperless", "-d", "paperless"]
}},
{vmid: $broker, label: "Valkey", healthcheck: {
type: "exec", timeout_seconds: 90,
argv: ["valkey-cli", "-h", $broker_ip, "ping"]
}}
]
}' >"$LIFECYCLE_SPEC"
LIFECYCLE_CONFIG_PATH="/etc/pve/priv/proxmenux-stack-${APPLICATION_ID}.json"
if ! oci_quiet bash "$STACK_DEPENDENCY_HOOK" --install "$APPLICATION_ID" "$LIFECYCLE_SPEC"; then
rm -f "$LIFECYCLE_SPEC"
die "$(translate "Could not install the stack startup hook")"
fi
rm -f "$LIFECYCLE_SPEC"
msg_ok "$(translate "Stack startup hook installed")"
wait_command() {
local label=$1 retries=$2
shift 2
local attempt
for attempt in $(seq 1 "$retries"); do
"$@" >/dev/null 2>&1 && return 0
sleep 2
done
die "$(translate "Health check failed:") $label"
}
APPLICATION_LAN_IP=""
if (( START_AFTER == 1 )); then
msg_info "$(translate "Starting the service:") PostgreSQL"
oci_quiet pct start "$DATABASE_ID"
wait_command PostgreSQL 60 pct exec "$DATABASE_ID" -- \
pg_isready -h "$DATABASE_IP" -U paperless -d paperless
msg_ok "$(translate "Service ready:") PostgreSQL"
msg_info "$(translate "Starting the service:") Valkey"
oci_quiet pct start "$BROKER_ID"
wait_command Valkey 60 pct exec "$BROKER_ID" -- valkey-cli -h "$BROKER_IP" ping
msg_ok "$(translate "Service ready:") Valkey"
msg_info "$(translate "Starting the service:") Paperless-ngx"
oci_quiet pct start "$APPLICATION_ID"
msg_info "$(translate "Waiting for the application to respond...")"
for _ in $(seq 1 180); do
APPLICATION_LAN_IP=$(lxc-info -n "$APPLICATION_ID" -iH 2>/dev/null \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -vFx "$APPLICATION_IP" | head -n 1 || true)
if [[ -n $APPLICATION_LAN_IP ]] \
&& curl -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>&1; then
break
fi
if [[ $(pct status "$APPLICATION_ID" 2>/dev/null) != *running* ]]; then
die "$(translate "The application stopped during its first start:") Paperless-ngx"
fi
sleep 2
done
[[ -n $APPLICATION_LAN_IP ]] \
|| die "$(translate "The application did not get an address on the access network:") Paperless-ngx"
curl -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>>"$OCI_LOG" \
|| die "$(translate "The application did not complete its initial setup:") Paperless-ngx"
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}:8000/"
fi
RESULT=$(jq -nc \
--argjson vmid "$APPLICATION_ID" \
--arg ip "$APPLICATION_LAN_IP" \
--argjson application_id "$APPLICATION_ID" \
--argjson database_id "$DATABASE_ID" \
--argjson broker_id "$BROKER_ID" \
--arg admin_user "$ADMIN_USERNAME" \
--arg admin_password "$ADMIN_PASSWORD" \
--arg application_digest "$APPLICATION_DIGEST" \
--arg database_digest "$DATABASE_DIGEST" \
--arg broker_digest "$BROKER_DIGEST" \
--arg admin_label "$(translate "Initial Paperless-ngx administrator")" \
--arg log "$OCI_LOG" \
'{
vmid: $vmid,
ip: (if $ip == "" then null else $ip end),
stack_vmids: {
paperless: $application_id,
database: $database_id,
broker: $broker_id
},
urls: (if $ip == "" then [] else [{label: "Paperless-ngx WebUI", url: ("http://" + $ip + ":8000/")}] end),
credentials: [{
label: $admin_label,
username: $admin_user,
password: $admin_password,
change_required: true
}],
image_digests: {
application: $application_digest,
database: $database_digest,
broker: $broker_digest
},
log: $log
}')
INSTALL_COMPLETE=1
oci_native_finalize
printf 'PROXMENUX_RESULT=%s\n' "$(printf '%s' "$RESULT" | base64 -w0)"
+505
View File
@@ -0,0 +1,505 @@
#!/usr/bin/env bash
set -Eeuo pipefail
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
DRY_RUN=${3:-0}
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SCRIPT_DIR/oci_ui.sh"
VERIFY_OCI_ARCHIVE="${SCRIPT_DIR}/verify_oci_archive.py"
ALLOCATE_PRIVATE_NETWORK="${SCRIPT_DIR}/allocate_private_network.py"
STACK_DEPENDENCY_HOOK="${SCRIPT_DIR}/stack_dependency_hook.sh"
INSTALL_STARTED_AT=$(date --iso-8601=seconds)
die() {
stop_spinner
msg_error "$*"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$(translate "Missing required command:") $1"
}
jqr() {
jq -er "$1" "$DEPLOYMENT_FILE"
}
set_runtime_env() {
local id=$1 key=$2 value=$3 config="/etc/pve/lxc/${1}.conf"
sed -i -E "/^lxc\.environment\.runtime: ${key}=/d" "$config"
printf 'lxc.environment.runtime: %s=%s\n' "$key" "$value" >>"$config"
}
set_lxc_directive() {
local id=$1 key=$2 value=$3 config="/etc/pve/lxc/${1}.conf" escaped_key
escaped_key=${key//./\.}
sed -i -E "/^${escaped_key}:/d" "$config"
printf '%s: %s\n' "$key" "$value" >>"$config"
}
print_first_boot_diagnostics() {
local id=$1 label=$2
{
printf '=== %s CT %s: Proxmox service ===\n' "$label" "$id"
journalctl -u "pve-container@${id}.service" --since "$INSTALL_STARTED_AT" \
--no-pager -n 120 2>&1 || true
printf '\n=== Host: serious errors since the installation started ===\n'
journalctl -k --since "$INSTALL_STARTED_AT" --no-pager 2>&1 \
| grep -iE 'segfault|general protection fault|mce:|hardware error|memory failure|out of memory|oom-kill' \
| tail -n 120 || true
} >>"$OCI_LOG"
}
created_ids=()
INSTALL_COMPLETE=0
PRIVATE_BRIDGE_CREATED=0
LIFECYCLE_CONFIG_PATH=""
UNEXPECTED_FAILURE=0
rollback() {
local status=$? index id
stop_spinner
if (( status != 0 && UNEXPECTED_FAILURE == 1 )); then
msg_error "$(translate "The installation stopped because of an unexpected error")"
if [[ -n ${OCI_LOG:-} && -s ${OCI_LOG:-} ]]; then
oci_log_tail 12 >&2
printf ' %s %s\n' "$(translate "Full log:")" "$OCI_LOG" >&2
fi
fi
if (( status != 0 && INSTALL_COMPLETE == 0 )); then
if declare -F oci_native_failed >/dev/null; then oci_native_failed; fi
if (( ${#created_ids[@]} > 0 || PRIVATE_BRIDGE_CREATED == 1 )); then
msg_info "$(translate "Removing the incomplete stack...")"
fi
for ((index=${#created_ids[@]}-1; index>=0; index--)); do
id=${created_ids[index]}
pct stop "$id" --skiplock 1 >/dev/null 2>&1 || true
pct destroy "$id" --force 1 --purge 1 >/dev/null 2>&1 \
|| pct destroy "$id" --purge 1 >/dev/null 2>&1 \
|| true
done
if (( PRIVATE_BRIDGE_CREATED == 1 )); then
oci_log "Removing the private bridge created by this installation"
ip link delete "$PRIVATE_BRIDGE" type bridge >/dev/null 2>&1 || true
pvesh delete "/nodes/${NODE}/network/${PRIVATE_BRIDGE}" >/dev/null 2>&1 || true
fi
[[ -z $LIFECYCLE_CONFIG_PATH ]] || rm -f "$LIFECYCLE_CONFIG_PATH"
if (( ${#created_ids[@]} > 0 || PRIVATE_BRIDGE_CREATED == 1 )); then
msg_ok "$(translate "Incomplete stack removed")"
fi
fi
exit "$status"
}
trap rollback EXIT
trap 'UNEXPECTED_FAILURE=1; oci_log "Command failed at line $LINENO (${FUNCNAME[0]:-main})"' ERR
[[ $EUID -eq 0 ]] || die "$(translate "The installer must run as root on Proxmox VE")"
oci_log_init "$(jq -r '.stack_name // "tandoor"' "$DEPLOYMENT_FILE" 2>/dev/null || printf tandoor)"
for command in pct qm pvesh pvesm skopeo jq openssl python3 curl ip stat dpkg base64 mktemp lxc-info flock journalctl tee; do
require_command "$command"
done
[[ -r $VERIFY_OCI_ARCHIVE ]] || die "$(translate "The OCI archive verifier was not found")"
[[ -r $ALLOCATE_PRIVATE_NETWORK ]] || die "$(translate "The private network allocator was not found")"
[[ -r $STACK_DEPENDENCY_HOOK ]] || die "$(translate "The stack startup hook was not found")"
msg_info "$(translate "Reserving a private network...")"
exec 9>/run/lock/proxmenux-private-network.lock
flock 9
oci_quiet python3 "$ALLOCATE_PRIVATE_NETWORK" "$DEPLOYMENT_FILE" \
|| die "$(translate "Could not reserve a private network for the stack")"
STACK_NAME=$(jqr '.stack_name')
[[ $STACK_NAME =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || die "$(translate "Invalid stack name")"
BASE_VMID=$(jq -r '.base_vmid // empty' "$DEPLOYMENT_FILE")
TEMPLATE_STORAGE=$(jqr '.template_storage')
ROOTFS_STORAGE=$(jqr '.rootfs_storage')
APPLICATION_STORAGE=$(jqr '.application_storage')
STATIC_SIZE=$(jqr '.static_size_gb')
DATABASE_STORAGE=$(jqr '.database_storage')
DATABASE_SIZE=$(jqr '.database_size_gb')
MEDIA_MODE=$(jqr '.media.mode')
MEDIA_STORAGE=$(jq -r '.media.storage // empty' "$DEPLOYMENT_FILE")
MEDIA_SIZE=$(jq -r '.media.size_gb // empty' "$DEPLOYMENT_FILE")
MEDIA_ROOT=$(jq -r '.media.host_path // empty' "$DEPLOYMENT_FILE")
ALLOWED_HOSTS=$(jqr '.application.allowed_hosts')
ADMIN_USERNAME=$(jqr '.application.admin_username')
ADMIN_EMAIL=$(jqr '.application.admin_email')
TIMEZONE=$(jqr '.timezone')
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
FRONTEND_IPV4=$(jqr '.network.frontend_ipv4')
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
FRONTEND_NET=$OCI_ACCESS_NET
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
APPLICATION_ADDRESS=$(jqr '.network.application_address')
DATABASE_ADDRESS=$(jqr '.network.database_address')
APPLICATION_IP=${APPLICATION_ADDRESS%/*}
DATABASE_IP=${DATABASE_ADDRESS%/*}
[[ $STATIC_SIZE =~ ^[0-9]+$ ]] && (( STATIC_SIZE >= 1 )) \
|| die "$(translate "The staticfiles volume needs at least 1 GB")"
[[ $DATABASE_SIZE =~ ^[0-9]+$ ]] && (( DATABASE_SIZE >= 4 )) \
|| die "$(translate "The PostgreSQL volume needs at least 4 GB")"
[[ $ALLOWED_HOSTS != *$'\n'* && $ALLOWED_HOSTS != *$'\r'* ]] \
|| die "$(translate "Invalid ALLOWED_HOSTS value")"
[[ $ADMIN_USERNAME =~ ^[A-Za-z0-9_.@-]+$ ]] || die "$(translate "Invalid administrator user name")"
[[ $ADMIN_EMAIL =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] \
|| die "$(translate "Invalid administrator email")"
case "$MEDIA_MODE" in
managed-volume)
[[ -n $MEDIA_STORAGE && $MEDIA_SIZE =~ ^[0-9]+$ ]] \
&& (( MEDIA_SIZE >= 2 )) || die "$(translate "Invalid mediafiles volume")"
;;
host-bind)
[[ $MEDIA_ROOT == /* && $MEDIA_ROOT != *","* && $MEDIA_ROOT != *$'\n'* ]] \
|| die "$(translate "Invalid shared path")"
;;
*) die "$(translate "Unsupported storage mode:") $MEDIA_MODE" ;;
esac
[[ $PRIVATE_BRIDGE =~ ^vmbr[0-9]+$ ]] || die "$(translate "Invalid private bridge")"
[[ $PRIVATE_SUBNET =~ ^10\.77\.[0-9]{1,3}\.0/24$ ]] || die "$(translate "Invalid private network")"
vmid_block_free() {
local candidate=$1 offset
for offset in 0 1; do
pct config "$((candidate + offset))" >/dev/null 2>&1 && return 1
qm config "$((candidate + offset))" >/dev/null 2>&1 && return 1
done
return 0
}
if [[ -z $BASE_VMID ]]; then
BASE_VMID=$(pvesh get /cluster/nextid)
while ! vmid_block_free "$BASE_VMID"; do
BASE_VMID=$((BASE_VMID + 1))
done
fi
[[ $BASE_VMID =~ ^[0-9]+$ ]] || die "$(translate "Invalid base VMID")"
vmid_block_free "$BASE_VMID" || die "$(translate "These VMIDs are not free:") ${BASE_VMID}-$((BASE_VMID + 1))"
APPLICATION_ID=$BASE_VMID
DATABASE_ID=$((BASE_VMID + 1))
oci_log "Stack: $STACK_NAME; VMIDs: Tandoor=$APPLICATION_ID, PostgreSQL=$DATABASE_ID"
oci_log "Private network: $PRIVATE_SUBNET on $PRIVATE_BRIDGE"
if [[ $DRY_RUN == 1 ]]; then
msg_info2 "$(translate "Stack:") $STACK_NAME · CT ${APPLICATION_ID}-${DATABASE_ID}"
msg_info2 "$(translate "Private network:") $PRIVATE_BRIDGE ($PRIVATE_SUBNET)"
msg_ok "$(translate "Dry run completed; no containers were created.")"
exit 0
fi
NODE=$(hostname)
if ! pvesh get "/nodes/${NODE}/network/${PRIVATE_BRIDGE}" >/dev/null 2>&1; then
oci_log "Creating the persistent configuration for $PRIVATE_BRIDGE"
oci_quiet pvesh create "/nodes/${NODE}/network" --iface "$PRIVATE_BRIDGE" --type bridge \
--autostart 1 --cidr "$PRIVATE_HOST_ADDRESS"
PRIVATE_BRIDGE_CREATED=1
fi
if ! ip link show "$PRIVATE_BRIDGE" >/dev/null 2>&1; then
oci_log "Activating the private bridge $PRIVATE_BRIDGE"
oci_quiet ip link add name "$PRIVATE_BRIDGE" type bridge
oci_quiet ip address add "$PRIVATE_HOST_ADDRESS" dev "$PRIVATE_BRIDGE"
oci_quiet ip link set "$PRIVATE_BRIDGE" up
fi
ip -4 address show dev "$PRIVATE_BRIDGE" | grep -Fq "${PRIVATE_HOST_ADDRESS%/*}/" \
|| die "$(translate "The private bridge does not have the expected address:") $PRIVATE_BRIDGE ($PRIVATE_HOST_ADDRESS)"
for address in "$APPLICATION_ADDRESS" "$DATABASE_ADDRESS"; do
if grep -RqsF "ip=${address}" /etc/pve/lxc/*.conf 2>/dev/null; then
die "$(translate "The private address is already assigned to another container:") ${address%/*}"
fi
done
flock -u 9
msg_ok "$(translate "Private network:") $PRIVATE_BRIDGE ($PRIVATE_SUBNET)"
ARCH=$(dpkg --print-architecture)
case "$ARCH" in amd64|arm64) ;; *) die "$(translate "Unsupported architecture:") $ARCH" ;; esac
skopeo_transport_reference() {
local reference=$1 name digest
if [[ $reference == *@sha256:* ]]; then
name=${reference%@sha256:*}
digest="sha256:${reference##*@sha256:}"
[[ ${name##*/} == *:* ]] && name=${name%:*}
printf '%s@%s' "$name" "$digest"
else
printf '%s' "$reference"
fi
}
resolve_image_manifest() {
local label=$1 image=$2 manifest_file error_file pid elapsed=0 status=0
manifest_file=$(mktemp /tmp/proxmenux-oci-inspect.XXXXXX)
error_file="${manifest_file}.err"
oci_log "Querying the OCI registry for ${label}: ${image}"
skopeo inspect --no-tags --override-os linux --override-arch "$ARCH" \
"docker://${image}" >"$manifest_file" 2>"$error_file" &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
sleep 2
elapsed=$((elapsed + 2))
done
wait "$pid" || status=$?
if (( status != 0 )); then
cat "$error_file" >>"$OCI_LOG"
rm -f "$manifest_file" "$error_file"
return "$status"
fi
oci_log "Manifest for ${label} resolved in ${elapsed}s"
cat "$manifest_file"
rm -f "$manifest_file" "$error_file"
}
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
msg_info "$(translate "Checking the image in the registry...")"
oci_log "Resolving ${key}: ${image}"
transport_image=$(skopeo_transport_reference "$image")
inspect=$(resolve_image_manifest "$key" "$transport_image") \
|| die "$(translate "Could not resolve the OCI manifest:") $image"
digest=$(jq -er '.Digest' <<<"$inspect")
oci_log "Selected digest for ${key}: ${digest}"
short=${digest#sha256:}
short=${short:0:16}
archive_name="image-tandoor-${key}_${ARCH}_${short}.tar"
archive_volume="${TEMPLATE_STORAGE}:vztmpl/${archive_name}"
archive_path=$(pvesm path "$archive_volume")
mkdir -p "$(dirname "$archive_path")"
if [[ -s $archive_path ]]; then
msg_info "$(translate "Verifying the image integrity...")"
fi
if [[ -s $archive_path ]] && oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$archive_path"; then
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))
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
RESOLVED_DIGEST=$digest
}
APPLICATION_IMAGE=$(jq -er '.container_contract.image.reference' "$TEMPLATE_FILE")
DATABASE_IMAGE=$(jq -er '.compose_stack.services[] | select(.name == "db_recipes") | .image' "$TEMPLATE_FILE")
ensure_image application "$APPLICATION_IMAGE"
APPLICATION_ARCHIVE=$RESOLVED_ARCHIVE
APPLICATION_DIGEST=$RESOLVED_DIGEST
ensure_image database "$DATABASE_IMAGE"
DATABASE_ARCHIVE=$RESOLVED_ARCHIVE
DATABASE_DIGEST=$RESOLVED_DIGEST
source "$SCRIPT_DIR/oci_native_stack.sh"
oci_native_begin "$APPLICATION_ID" \
--member application "$APPLICATION_ID" "$APPLICATION_IMAGE" "$APPLICATION_ARCHIVE" \
--member database "$DATABASE_ID" "$DATABASE_IMAGE" "$DATABASE_ARCHIVE"
DB_PASSWORD=$(openssl rand -hex 24)
SECRET_KEY=$(openssl rand -hex 48)
ADMIN_PASSWORD=$(openssl rand -hex 16)
if [[ $MEDIA_MODE == host-bind ]]; then
install -d -m 0775 -o 100000 -g 100000 "$MEDIA_ROOT"
MEDIA_MOUNT="${MEDIA_ROOT},mp=/opt/recipes/mediafiles,backup=0"
else
MEDIA_MOUNT="${MEDIA_STORAGE}:${MEDIA_SIZE},mp=/opt/recipes/mediafiles,backup=1"
fi
TAGS="productivity;oci;proxmenux"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql/data,backup=1" \
--hostname "${STACK_NAME}-db" --cores 2 --memory 1024 --swap 512 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DATABASE_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=10,up=10,down=30 --tags "$TAGS" \
--description 'Tandoor PostgreSQL native OCI'
created_ids+=("$DATABASE_ID")
oci_quiet pct mount "$DATABASE_ID"
DATABASE_ROOT="/var/lib/lxc/${DATABASE_ID}/rootfs"
POSTGRES_UID=$(awk -F: '$1 == "postgres" {print $3}' "$DATABASE_ROOT/etc/passwd")
POSTGRES_GID=$(awk -F: '$1 == "postgres" {print $4}' "$DATABASE_ROOT/etc/passwd")
[[ -n $POSTGRES_UID && -n $POSTGRES_GID ]] || die "$(translate "The postgres user was not found in the image")"
rm -rf "$DATABASE_ROOT/var/lib/postgresql/data/lost+found"
install -d -m 0700 -o "$((100000 + POSTGRES_UID))" -g "$((100000 + POSTGRES_GID))" \
"$DATABASE_ROOT/var/lib/postgresql/data/pgdata"
cat >"$DATABASE_ROOT/usr/local/bin/tandoor-postgres-lxc-start" <<EOF
#!/bin/sh
set -eu
exec docker-entrypoint.sh postgres -c 'listen_addresses=127.0.0.1,${DATABASE_IP}'
EOF
chmod 0755 "$DATABASE_ROOT/usr/local/bin/tandoor-postgres-lxc-start"
chown 100000:100000 "$DATABASE_ROOT/usr/local/bin/tandoor-postgres-lxc-start"
oci_quiet pct unmount "$DATABASE_ID"
oci_quiet pct set "$DATABASE_ID" --entrypoint /usr/local/bin/tandoor-postgres-lxc-start
set_lxc_directive "$DATABASE_ID" lxc.init.cwd /
set_lxc_directive "$DATABASE_ID" lxc.signal.halt SIGINT
set_runtime_env "$DATABASE_ID" POSTGRES_DB djangodb
set_runtime_env "$DATABASE_ID" POSTGRES_USER djangouser
set_runtime_env "$DATABASE_ID" POSTGRES_PASSWORD "$DB_PASSWORD"
set_runtime_env "$DATABASE_ID" POSTGRES_INITDB_ARGS --data-checksums
set_runtime_env "$DATABASE_ID" PGDATA /var/lib/postgresql/data/pgdata
set_runtime_env "$DATABASE_ID" TZ "$TIMEZONE"
msg_ok "$(translate "Container created:") CT $DATABASE_ID (PostgreSQL)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${APPLICATION_STORAGE}:${STATIC_SIZE},mp=/opt/recipes/staticfiles,backup=1" \
--mp1 "$MEDIA_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 512 \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=20,up=15,down=30 --tags "$TAGS" \
--description 'Tandoor Recipes native OCI'
created_ids+=("$APPLICATION_ID")
oci_quiet pct mount "$APPLICATION_ID"
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
rm -rf "$APPLICATION_ROOTFS/opt/recipes/staticfiles/lost+found"
rm -rf "$APPLICATION_ROOTFS/opt/recipes/mediafiles/lost+found"
oci_quiet pct unmount "$APPLICATION_ID"
set_runtime_env "$APPLICATION_ID" SECRET_KEY "$SECRET_KEY"
set_runtime_env "$APPLICATION_ID" TZ "$TIMEZONE"
set_runtime_env "$APPLICATION_ID" ALLOWED_HOSTS "$ALLOWED_HOSTS"
set_runtime_env "$APPLICATION_ID" DB_ENGINE django.db.backends.postgresql
set_runtime_env "$APPLICATION_ID" POSTGRES_HOST "$DATABASE_IP"
set_runtime_env "$APPLICATION_ID" POSTGRES_DB djangodb
set_runtime_env "$APPLICATION_ID" POSTGRES_PORT 5432
set_runtime_env "$APPLICATION_ID" POSTGRES_USER djangouser
set_runtime_env "$APPLICATION_ID" POSTGRES_PASSWORD "$DB_PASSWORD"
msg_ok "$(translate "Container created:") CT $APPLICATION_ID (Tandoor)"
msg_info "$(translate "Installing the stack startup hook...")"
LIFECYCLE_SPEC=$(mktemp /tmp/proxmenux-stack-lifecycle.XXXXXX)
jq -nc --arg stack "$STACK_NAME" --argjson db "$DATABASE_ID" --arg db_ip "$DATABASE_IP" '
{
schema: 1,
stack: $stack,
dependencies: [
{vmid: $db, label: "PostgreSQL", healthcheck: {
type: "exec", timeout_seconds: 120,
argv: ["pg_isready", "-h", $db_ip, "-U", "djangouser", "-d", "djangodb"]
}}
]
}' >"$LIFECYCLE_SPEC"
LIFECYCLE_CONFIG_PATH="/etc/pve/priv/proxmenux-stack-${APPLICATION_ID}.json"
if ! oci_quiet bash "$STACK_DEPENDENCY_HOOK" --install "$APPLICATION_ID" "$LIFECYCLE_SPEC"; then
rm -f "$LIFECYCLE_SPEC"
die "$(translate "Could not install the stack startup hook")"
fi
rm -f "$LIFECYCLE_SPEC"
msg_ok "$(translate "Stack startup hook installed")"
wait_command() {
local label=$1 retries=$2
shift 2
local attempt
for attempt in $(seq 1 "$retries"); do
"$@" >/dev/null 2>&1 && return 0
sleep 2
done
die "$(translate "Health check failed:") $label"
}
APPLICATION_LAN_IP=""
if (( START_AFTER == 1 )); then
msg_info "$(translate "Starting the service:") PostgreSQL"
oci_quiet pct start "$DATABASE_ID"
wait_command PostgreSQL 60 pct exec "$DATABASE_ID" -- \
pg_isready -h "$DATABASE_IP" -U djangouser -d djangodb
msg_ok "$(translate "Service ready:") PostgreSQL"
msg_info "$(translate "Starting the service:") Tandoor"
oci_quiet pct start "$APPLICATION_ID"
msg_info "$(translate "Waiting for the application to respond...")"
for _ in $(seq 1 180); do
APPLICATION_LAN_IP=$(lxc-info -n "$APPLICATION_ID" -iH 2>/dev/null \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -vFx "$APPLICATION_IP" | head -n 1 || true)
if [[ -n $APPLICATION_LAN_IP ]] \
&& curl -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>&1; then
break
fi
if [[ $(pct status "$APPLICATION_ID" 2>/dev/null) != *running* ]]; then
print_first_boot_diagnostics "$APPLICATION_ID" tandoor
die "$(translate "The application stopped during its first start:") Tandoor"
fi
sleep 2
done
if [[ -z $APPLICATION_LAN_IP ]]; then
print_first_boot_diagnostics "$APPLICATION_ID" tandoor
die "$(translate "The application did not get an address on the access network:") Tandoor"
fi
curl -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>>"$OCI_LOG" \
|| { print_first_boot_diagnostics "$APPLICATION_ID" tandoor; \
die "$(translate "The application did not complete its initial setup:") Tandoor"; }
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}/"
msg_info "$(translate "Creating the initial administrator...")"
oci_quiet pct exec "$APPLICATION_ID" -- env DJANGO_SUPERUSER_PASSWORD="$ADMIN_PASSWORD" \
DJANGO_SUPERUSER_USERNAME="$ADMIN_USERNAME" DJANGO_SUPERUSER_EMAIL="$ADMIN_EMAIL" \
/bin/sh -c 'cd /opt/recipes && . venv/bin/activate && python manage.py createsuperuser --noinput' \
|| { print_first_boot_diagnostics "$APPLICATION_ID" tandoor; \
die "$(translate "Could not create the initial administrator")"; }
msg_ok "$(translate "Initial administrator created:") $ADMIN_USERNAME"
fi
RESULT=$(jq -nc \
--argjson vmid "$APPLICATION_ID" \
--arg ip "$APPLICATION_LAN_IP" \
--argjson application_id "$APPLICATION_ID" \
--argjson database_id "$DATABASE_ID" \
--arg application_digest "$APPLICATION_DIGEST" \
--arg database_digest "$DATABASE_DIGEST" \
--arg admin_user "$ADMIN_USERNAME" \
--arg admin_password "$ADMIN_PASSWORD" \
--arg admin_label "$(translate "Initial Tandoor administrator")" \
--arg log "$OCI_LOG" \
'{
vmid: $vmid,
ip: (if $ip == "" then null else $ip end),
stack_vmids: {tandoor: $application_id, database: $database_id},
urls: (if $ip == "" then [] else [{label: "Tandoor WebUI", url: ("http://" + $ip + "/")}] end),
credentials: [{
label: $admin_label,
username: $admin_user,
password: $admin_password,
change_required: true
}],
image_digests: {application: $application_digest, database: $database_digest},
log: $log
}')
INSTALL_COMPLETE=1
oci_native_finalize
printf 'PROXMENUX_RESULT=%s\n' "$(printf '%s' "$RESULT" | base64 -w0)"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Experimental native LXC mount hook: PVE devN owns device creation/cgroups.
set -euo pipefail
[[ ${LXC_HOOK_TYPE:-${3:-}} == mount ]] || exit 1
[[ ${LXC_HOOK_SECTION:-${2:-}} == lxc ]] || exit 1
[[ ${NVIDIA_VISIBLE_DEVICES:-void} != void && -n ${NVIDIA_VISIBLE_DEVICES:-} ]] || exit 0
[[ -n ${LXC_ROOTFS_MOUNT:-} && -d $LXC_ROOTFS_MOUNT ]] || exit 1
# Do not use this hook outside an unprivileged user namespace.
awk '$1 == 0 && $2 == 0 && $3 == 4294967295 {exit 1}' /proc/self/uid_map || exit 1
# Same process-only transition used by the upstream LXC NVIDIA mount hook.
# Fail closed if it is denied; do not disable host or container AppArmor.
if [[ -d /sys/kernel/security/apparmor ]]; then
printf 'changeprofile unconfined\n' > /proc/self/attr/current
fi
args=(--no-cgroups --no-devbind --ldconfig=@/usr/sbin/ldconfig)
args+=("--device=${NVIDIA_VISIBLE_DEVICES}")
capabilities=${NVIDIA_DRIVER_CAPABILITIES:-utility}
[[ $capabilities != all ]] || capabilities=compute,utility,video,graphics,display,compat32
while [[ -n $capabilities ]]; do
capability=${capabilities%%,*}
if [[ $capabilities == *,* ]]; then capabilities=${capabilities#*,}; else capabilities=; fi
case "$capability" in
compute|utility|video|graphics|display|compat32) args+=("--${capability}") ;;
*) printf 'Unsupported NVIDIA capability: %s\n' "$capability" >&2; exit 1 ;;
esac
done
for requirement in $(compgen -e NVIDIA_REQUIRE_ || true); do
args+=("--require=${!requirement}")
done
exec nvidia-container-cli --user configure "${args[@]}" "$LXC_ROOTFS_MOUNT"
+124
View File
@@ -0,0 +1,124 @@
"""Preservation profiles for native DRM devices and NVIDIA Toolkit runtimes."""
from __future__ import annotations
import re
import oci_runtime_settings as runtime_settings
import oci_nvidia_dynamic as dynamic
import oci_gpu_devices as drm
import oci_nvidia_runtime as nvidia
from oci_ui import translate
def dynamic_mode(deployment):
return any(d.get('kind') == 'nvidia-runtime' and d.get('runtime_mode') == 'dynamic'
for d in deployment.get('devices', []))
def check_dynamic(config, value, deployment):
hooks = [line.split(': ', 1)[1] for line in config.decode().splitlines()
if line.startswith('lxc.hook.mount: ')]
if len(hooks) != 1:
raise ValueError(translate('The dynamic NVIDIA hook is missing or duplicated'))
match = re.fullmatch(r'/usr/local/lib/proxmenux/oci/nvidia-mount-([a-f0-9]{64})\.sh', hooks[0])
if not match:
raise ValueError(translate('The NVIDIA hook path does not belong to the installer'))
capabilities = next((e['value'] for e in reversed(deployment.get('environment', []))
if e['name'] == 'NVIDIA_DRIVER_CAPABILITIES'), 'compute,utility,video')
return dynamic.validate(config, value, value, hooks[0], match[1], capabilities)
def verify_baseline(expected, deployment):
if not dynamic_mode(deployment):
verify(expected)
return
drm.verify({p: v for p, v in expected.items() if p != nvidia.KEY})
if dynamic.gpu_identity(expected[nvidia.KEY]) != dynamic.gpu_identity(nvidia.snapshot()):
raise ValueError(translate('The selected GPU changed'))
def drm_plan(deployment):
return dict(deployment, devices=[d for d in deployment.get('devices', []) if d.get('kind') != 'nvidia-runtime'])
def planned(deployment):
result = drm.planned(drm_plan(deployment))
if nvidia.enabled(deployment):
value = nvidia.snapshot()
if set(result) & set(value['devices']):
raise ValueError(translate('Duplicated NVIDIA devices'))
result[nvidia.KEY] = value
return result
def verify(expected):
drm.verify({p: v for p, v in expected.items() if p != nvidia.KEY})
if nvidia.KEY in expected:
nvidia.verify(expected[nvidia.KEY])
def check(config, deployment):
config = runtime_settings.filter_config(config, deployment)
expected = planned(deployment)
value = expected.get(nvidia.KEY)
if value:
nvidia.check_devices(config, value)
if dynamic_mode(deployment):
check_dynamic(config, value, deployment)
else:
nvidia.check_mounts(config, value)
filtered = []
for line in config.splitlines(keepends=True):
if re.match(rb'dev[0-9]+: ', line):
fields = dict(part.split('=', 1) for part in line.decode().strip().split(': ', 1)[1].split(','))
if fields.get('path') in value['devices']:
continue
filtered.append(line)
filtered = b''.join(filtered)
drm.check(filtered, drm_plan(deployment))
else:
if nvidia.mount_lines(config):
raise ValueError(translate('LXC entries outside the selected acceleration profile'))
drm.check(config, deployment)
return expected
def capture(config):
result = drm.capture(config)
if any(isinstance(p, str) and p.startswith('/dev/nvidia') for p in drm.actual_devices(config)):
result[nvidia.KEY] = nvidia.snapshot()
return result
def verify_observation(expected, observed):
if observed.get('gpu_devices', {}) != expected:
raise ValueError(translate('The acceleration evidence differs from the verified inventory'))
verify(expected)
def validate_runtime(vmid, deployment):
if nvidia.enabled(deployment):
value = nvidia.snapshot()
if dynamic_mode(deployment):
rows = nvidia.command('pct', 'exec', str(vmid), '--', 'nvidia-smi', nvidia.QUERY, '--format=csv,noheader')
if sorted(line.strip() for line in rows.splitlines() if line.strip()) != value['gpus']:
raise ValueError(translate('NVML does not match the current host driver'))
else:
nvidia.validate_runtime(vmid, value)
def check_recovery_entries(config, state):
runtime_settings.check_recovery(config, state)
for key in ('record', 'candidate_contract'):
config = runtime_settings.filter_config(config, state.get(key, {}).get('deployment', {}))
entries = nvidia.mount_lines(config)
if not entries:
return
values = [state.get(key, {}).get(nvidia.KEY) for key in ('original_gpu_devices', 'desired_gpu_devices')]
for value in values:
if value:
try:
nvidia.check_mounts(config, value, complete=False)
return
except ValueError:
continue
raise ValueError(translate('LXC entries outside the NVIDIA inventory of the journal'))
+149
View File
@@ -0,0 +1,149 @@
"""Native Intel/AMD device preservation. NVIDIA library mounts need a separate profile."""
from __future__ import annotations
import os
from pathlib import Path
import re
import stat
from oci_installation_state import parse_config
from oci_ui import translate
def gpu_path(path):
return isinstance(path, str) and (re.fullmatch(r'/dev/dri/(renderD|card)[0-9]+', path) is not None or path == '/dev/kfd')
def peripheral_path(path):
return isinstance(path, str) and re.fullmatch(
r'/dev/(apex_[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path) is not None
def system_path(path):
"""Fixed nodes the kernel always presents the same way. They carry no
identity beyond their device numbers, so there is no sysfs to interrogate:
the numbers and the permissions are the whole record."""
return isinstance(path, str) and re.fullmatch(
r'/dev/(kvm|fuse|net/tun|video[0-9]+|sg[0-9]+)', path) is not None
def block_path(path):
return isinstance(path, str) and re.fullmatch(r'/dev/sr[0-9]+', path) is not None
def known_path(path):
return gpu_path(path) or peripheral_path(path) or system_path(path) or block_path(path)
def snapshot(path):
if not known_path(path):
raise ValueError(translate('Device node outside the supported profiles'))
info = Path(path).stat()
if block_path(path):
if not stat.S_ISBLK(info.st_mode):
raise ValueError(translate('The selected device is not a block device'))
elif not stat.S_ISCHR(info.st_mode):
raise ValueError(translate('The selected device is not a character device'))
value = {'path': str(Path(path).resolve()), 'major': os.major(info.st_rdev),
'minor': os.minor(info.st_rdev), 'uid': info.st_uid, 'gid': info.st_gid,
'mode': stat.S_IMODE(info.st_mode)}
if peripheral_path(path):
sysfs = Path('/sys/dev/char') / f'{value["major"]}:{value["minor"]}'
value['sysfs_path'] = str(sysfs.resolve(strict=True))
if '/bus/usb/' in path:
for name in ('idVendor', 'idProduct', 'serial'):
field = sysfs / name
if field.is_file():
value[name] = field.read_text().strip()
elif gpu_path(path) and path != '/dev/kfd':
sysfs = Path('/sys/class/drm') / Path(path).name / 'device'
value.update(vendor=(sysfs / 'vendor').read_text().strip(), pci_path=str(sysfs.resolve()))
if value['vendor'] not in ('0x1002', '0x8086'):
raise ValueError(translate('The DRM node is not an Intel or AMD GPU; NVIDIA requires its library profile'))
return value
def planned(deployment):
result = {}
for device in deployment.get('devices', []):
path = device.get('host_path')
kind = device.get('kind', 'character-device')
if kind == 'block-device':
allowed = block_path(path)
elif kind == 'character-device':
allowed = gpu_path(path) or peripheral_path(path) or system_path(path)
else:
allowed = False
if not allowed:
raise ValueError(translate('Device outside the supported profiles; NVIDIA and device trees require another profile'))
if device.get('container_path') != path or path in result:
raise ValueError(translate('The device must keep its native path without duplicates'))
value = snapshot(path)
vendors = device.get('drm_vendor_ids')
if vendors and value.get('vendor') not in vendors:
raise ValueError(translate('The GPU vendor differs from the requested profile'))
mode = device.get('mode', '0660')
if mode != 'preserve-host' and (not isinstance(mode, str) or not re.fullmatch(r'0?[0-7]{3}', mode)):
raise ValueError(translate('Invalid device mode'))
if device.get('gid_strategy') not in ('none', 'host-device-gid'):
raise ValueError(translate('Unsupported device GID strategy'))
result[path] = value
return result
def verify(expected):
for path, value in expected.items():
if snapshot(path) != value:
raise ValueError(translate('The GPU identity or permissions changed; the container is not modified'))
def actual_devices(config):
result = {}
for key, value in parse_config(config).items():
if re.fullmatch(r'dev[0-9]+', key):
fields = dict(part.split('=', 1) for part in value.split(','))
path = fields.get('path')
if path in result:
raise ValueError(translate('Duplicated GPU device in the container'))
result[path] = fields
return result
def check(config, deployment):
expected = planned(deployment)
actual = actual_devices(config)
if actual.keys() != expected.keys():
raise ValueError(translate('The container devices do not match the saved record'))
for device in deployment.get('devices', []):
path = device['host_path']
fields = actual[path]
value = expected[path]
requested_mode = device.get('mode', '0660')
mode = value['mode'] if requested_mode == 'preserve-host' else int(requested_mode, 8)
gid = value['gid'] if device['gid_strategy'] == 'host-device-gid' else 0
if (set(fields) - {'path', 'mode', 'gid', 'uid', 'deny-write'}
or int(fields.get('mode', '0660'), 8) != mode
or int(fields.get('gid', 0)) != gid
or int(fields.get('uid', 0)) != int(device.get('uid', 0))
or fields.get('deny-write', '0') != ('1' if device.get('deny_write') else '0')):
raise ValueError(translate('The native GPU permissions were not kept'))
return expected
def verify_observation(expected, observed):
if observed.get('gpu_devices', {}) != expected:
raise ValueError(translate('The GPU evidence does not match the verified devices'))
verify(expected)
def capture(config):
result = {}
for path in actual_devices(config):
if not known_path(path):
continue
if gpu_path(path) and path != '/dev/kfd':
vendor = (Path('/sys/class/drm') / Path(path).name / 'device/vendor').read_text().strip()
if vendor not in ('0x1002', '0x8086'):
continue
result[path] = snapshot(path)
return result
+74
View File
@@ -0,0 +1,74 @@
"""Read-only identity checks for directory bind mounts; never manage their data."""
from __future__ import annotations
from pathlib import Path, PurePosixPath
import re
import stat
from oci_installation_state import parse_config
from oci_ui import translate
def valid_path(value):
if (not isinstance(value, str) or not value.startswith('/') or value == '/'
or any(c.isspace() or ord(c) < 32 or c == ',' for c in value)
or any(p in ('.', '..') for p in value.split('/'))
or str(PurePosixPath(value)) != value or value.startswith('//')):
raise ValueError(translate('Invalid absolute mount path'))
return value
def snapshot(source, allow_missing=False):
path = Path(source)
resolved = str(path.resolve())
try:
info = path.stat()
except FileNotFoundError:
if not allow_missing or path.is_symlink():
raise ValueError(f"{translate('The container is not modified because a host directory is not available:')} {source}")
return {'resolved_path': resolved, 'exists': False}
if not stat.S_ISDIR(info.st_mode):
raise ValueError(translate('This profile only supports directory bind mounts'))
return {'resolved_path': resolved, 'exists': True, 'device': info.st_dev,
'inode': info.st_ino, 'uid': info.st_uid, 'gid': info.st_gid,
'mode': stat.S_IMODE(info.st_mode)}
def validate_source(source, allow_missing=False):
valid_path(source)
value = snapshot(source, allow_missing)
protected = ('/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/dev', '/proc', '/sys', '/run')
protected += tuple(str(Path(p).resolve()) for p in protected)
resolved = value['resolved_path']
if resolved == '/' or any(resolved == p or resolved.startswith(p + '/') for p in protected):
raise ValueError(translate('The shared directory points to a protected host path'))
return value
def same_source(a, b):
# Native application init may legitimately change permissions, not identity.
return all(a.get(k) == b.get(k) for k in ('resolved_path', 'exists', 'device', 'inode'))
def verify_sources(expected):
for source, previous in expected.items():
current = validate_source(source, allow_missing=not previous['exists'])
if not same_source(previous, current):
raise ValueError(f"{translate('The operation was stopped because a shared directory changed its identity:')} {source}")
def verify_observation(expected, observed):
actual = observed.get('host_bind_sources', {})
if actual.keys() != expected.keys() or any(not same_source(value, actual[source])
for source, value in expected.items()):
raise ValueError(translate('The mount evidence does not match the verified directories'))
def capture_sources(config):
result = {}
for key, value in parse_config(config).items():
if re.fullmatch(r'mp[0-9]+', key):
source = value.split(',', 1)[0]
if source.startswith('/'):
result[source] = snapshot(source)
return result
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Image archives that ProxMenux downloaded to the template storage and that
no installation uses any more are removed after a successful operation."""
from __future__ import annotations
import json
from pathlib import Path
import re
import sys
import time
import oci_instances as instances
# Names given by the OCI installers and by updates; other archives are never touched.
NAME = re.compile(r'(?:proxmenux-update-[a-z0-9]+-[0-9a-f]{64}'
r'|(?:image|linuxserver)-[A-Za-z0-9._-]+_[a-z0-9]+_[0-9a-f]{16})\.tar')
IN_PROGRESS = {'installing', 'assembling', 'updating', 'recovering'}
# A recent archive may belong to an installation that has not saved its record yet.
MIN_AGE_SECONDS = 3600
def unused_archives(root=instances.ROOT):
"""Archives no installed guest uses; empty while any operation is pending
or a record cannot be read."""
keep, folders = set(), set()
for path in Path(root).glob('*/oci-compose.json'):
try:
record = json.loads(path.read_text())
vmid = int(record['vmid'])
except (OSError, ValueError, KeyError, TypeError):
return []
exists = instances.guest_exists(vmid)
if (record.get('pending_transaction') or record.get('pending_stack_transaction')
or (exists and record.get('status') in IN_PROGRESS)):
return []
archive = (record.get('observed') or {}).get('archive_path')
if not archive:
continue
folders.add(Path(archive).parent)
if exists:
keep.add(Path(archive))
now = time.time()
result = []
for folder in folders:
for candidate in folder.glob('*.tar'):
if candidate in keep or not NAME.fullmatch(candidate.name) or candidate.is_symlink():
continue
info = candidate.stat()
if candidate.is_file() and now - info.st_mtime >= MIN_AGE_SECONDS:
result.append((candidate, info.st_size))
return result
def prune(root=instances.ROOT, lock=True):
"""Removes the unused archives and returns them as (path, size). Callers
that already hold the registry lock pass lock=False."""
if lock:
with instances.locked(root):
return prune(root, lock=False)
removed = []
for candidate, size in unused_archives(root):
try:
candidate.unlink()
except OSError:
continue
removed.append((candidate, size))
return removed
def archives_of(vmids, root=instances.ROOT):
"""The downloaded archives the given installations were created from."""
result = {}
for vmid in vmids:
archive = (instances.read(root, vmid).get('observed') or {}).get('archive_path')
path = Path(archive) if archive else None
if path and NAME.fullmatch(path.name) and path.is_file() and not path.is_symlink():
result[path] = path.stat().st_size
return result
def main(arguments):
command = arguments[0] if arguments else 'prune'
if command == 'prune':
for path, size in prune():
print(f'removed unused image archive: {path} ({size} bytes)')
return 0
if command not in ('list', 'remove') or not all(a.isdigit() for a in arguments[1:]):
print('usage: oci_image_cache.py [prune | list VMID... | remove VMID...]', file=sys.stderr)
return 2
with instances.locked(instances.ROOT):
archives = archives_of([int(a) for a in arguments[1:]])
freed = 0
if command == 'remove':
for path, size in archives.items():
path.unlink()
freed += size
print(json.dumps({'archives': [{'path': str(p), 'size': s} for p, s in archives.items()],
'freed': freed}))
return 0
if __name__ == '__main__':
try:
sys.exit(main(sys.argv[1:]))
except (OSError, ValueError, BlockingIOError) as error:
print(f'image cache: {error}', file=sys.stderr)
sys.exit(1)
+112
View File
@@ -0,0 +1,112 @@
# Immich ML prerequisites and native GPU setup; no host driver installation.
validate_immich_ml_profile() {
ML_CPU_ARGS=(--cores 2)
ML_MEMORY=2048
case "$ML_ACCELERATION" in
cpu) ;;
openvino)
ML_RENDER_DEVICE=$(jq -er '.machine_learning.render_device' "$DEPLOYMENT_FILE")
[[ $ML_RENDER_DEVICE =~ ^/dev/dri/renderD[0-9]+$ && -c $ML_RENDER_DEVICE ]] \
|| die "$(translate "The selected Intel render device does not exist:") $ML_RENDER_DEVICE"
[[ $(cat "/sys/class/drm/${ML_RENDER_DEVICE##*/}/device/vendor") == 0x8086 ]] \
|| die "$(translate "OpenVINO requires the render device of an Intel GPU")"
ML_CPU_ARGS=(--cpulimit 4)
ML_MEMORY=8192
;;
cuda)
command -v nvidia-container-cli >/dev/null 2>&1 \
|| die "$(translate "CUDA requires the NVIDIA Container Toolkit on the host")"
command -v nvidia-smi >/dev/null 2>&1 \
|| die "$(translate "CUDA requires a working NVIDIA driver")"
local inventory version capability
inventory=$(nvidia-smi --query-gpu=driver_version,compute_cap --format=csv,noheader) \
|| die "$(translate "Could not check the NVIDIA GPU")"
[[ -n $inventory ]] || die "$(translate "No NVIDIA GPU is available")"
while IFS=, read -r version capability; do
[[ $version =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] \
|| die "$(translate "Could not read the NVIDIA driver version")"
(( ${version%%.*} >= 545 )) || die "$(translate "Immich CUDA requires NVIDIA driver 545 or later")"
capability=${capability//[[:space:]]/}
[[ $capability =~ ^[0-9]+\.[0-9]+$ ]] \
|| die "$(translate "Could not read the CUDA compute capability")"
awk -v value="$capability" 'BEGIN {exit !(value >= 5.2)}' \
|| die "$(translate "Immich requires CUDA compute capability 5.2 or later")"
done <<<"$inventory"
[[ -r $SCRIPT_DIR/nvidia_lxc_mount_lab.sh ]] || die "$(translate "The dynamic NVIDIA hook is missing")"
ML_CPU_ARGS=(--cores 4)
ML_MEMORY=8192
;;
*) die "$(translate "Machine learning profile not implemented; it is not replaced by CPU:") $ML_ACCELERATION" ;;
esac
local cores allocation default_allocation
default_allocation=cpuset
[[ $ML_ACCELERATION != openvino ]] || default_allocation=quota
cores=$(jq -er --argjson fallback "${ML_CPU_ARGS[1]}" '.machine_learning.resources.cores // $fallback' "$DEPLOYMENT_FILE")
ML_MEMORY=$(jq -er --argjson fallback "$ML_MEMORY" '.machine_learning.resources.memory_mb // $fallback' "$DEPLOYMENT_FILE")
ML_SWAP=$(jq -er '.machine_learning.resources.swap_mb // 1024' "$DEPLOYMENT_FILE")
allocation=$(jq -er --arg fallback "$default_allocation" '.machine_learning.resources.cpu_allocation // $fallback' "$DEPLOYMENT_FILE")
[[ $cores =~ ^[1-9][0-9]*$ && $ML_MEMORY =~ ^[1-9][0-9]*$ && $ML_SWAP =~ ^(0|[1-9][0-9]*)$ ]] \
|| die "$(translate "Invalid machine learning resources")"
case "$allocation" in
quota) ML_CPU_ARGS=(--cpulimit "$cores") ;;
cpuset)
[[ $ML_ACCELERATION != openvino ]] || die "$(translate "OpenVINO requires a CPU quota to keep the CPU topology")"
ML_CPU_ARGS=(--cores "$cores")
;;
*) die "$(translate "Invalid machine learning CPU allocation:") $allocation" ;;
esac
}
configure_immich_ml_gpu() {
case "$ML_ACCELERATION" in
openvino)
oci_quiet pct set "$ML_ID" --dev0 "path=${ML_RENDER_DEVICE},gid=$(stat -c %g "$ML_RENDER_DEVICE"),mode=0660"
;;
cuda)
# Isolate the shared standalone installer's runtime context from the stack.
(
VMID=$ML_ID
CONF="/etc/pve/lxc/${ML_ID}.conf"
UNPRIVILEGED_FLAG=1
DEVICE='{"kind":"nvidia-runtime","runtime_mode":"dynamic"}'
NVIDIA_GID_ENV=""
DEVICE_INDEX=0
fragment=$(mktemp)
trap 'rm -f "$fragment"' EXIT
printf '%s\n' '{"environment":[{"name":"NVIDIA_DRIVER_CAPABILITIES","value":"compute,utility"}]}' >"$fragment"
DEPLOYMENT_FILE=$fragment
add_character_device() {
local path=$1 mode gid
[[ -c $path && $path == /dev/nvidia* ]] || die "$(translate "Invalid NVIDIA device:") $path"
mode="0$(stat -c %a "$path")"
gid=$(stat -c %g "$path")
oci_quiet pct set "$VMID" "--dev${DEVICE_INDEX}" "path=${path},mode=${mode},gid=${gid},deny-write=0"
DEVICE_INDEX=$((DEVICE_INDEX + 1))
}
configure_nvidia_runtime
)
;;
esac
}
validate_immich_ml_runtime() {
[[ $ML_ACCELERATION != cpu ]] || return 0
msg_info "$(translate "Checking the GPU of the machine learning container...")"
oci_quiet pct exec "$ML_ID" -- python -c '
import ctypes
import sys
import onnxruntime as ort
profile = sys.argv[1]
if profile == "openvino":
assert "OpenVINOExecutionProvider" in ort.get_available_providers()
devices = ort.capi._pybind_state.get_available_openvino_device_ids()
assert any(device.startswith("GPU") for device in devices), devices
else:
assert profile == "cuda"
assert "CUDAExecutionProvider" in ort.get_available_providers()
driver = ctypes.CDLL("libcuda.so.1")
assert driver.cuInit(0) == 0, "CUDA driver initialization failed"
print("Immich ML GPU runtime:", profile, "available; model inference is tested separately")
' "$ML_ACCELERATION" || return
msg_ok "$(translate "GPU available for machine learning:") $ML_ACCELERATION"
}
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Private installation evidence and read-only update diagnostics. No updater."""
from __future__ import annotations
import argparse
import contextlib
import datetime
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tarfile
import tempfile
import uuid
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')
def command(*args):
result = subprocess.run(args, capture_output=True, timeout=120)
if result.returncode:
# Tool errors may contain credentials or environment values.
raise RuntimeError(f"{args[0]} {translate('failed with exit code')} {result.returncode}")
return result.stdout
def sha(data):
return hashlib.sha256(data).hexdigest()
def image_from_archive(path):
with tarfile.open(path) as archive:
members = {m.name.removeprefix('./'): m for m in archive.getmembers() if m.isfile()}
def read(name, digest=None):
member = members[name]
if member.size > 16 * 1024 * 1024:
raise ValueError(translate('OCI metadata too large'))
data = archive.extractfile(member).read()
if digest and 'sha256:' + sha(data) != digest:
raise ValueError(translate('OCI metadata integrity mismatch'))
return json.loads(data)
def blob(digest):
if not re.fullmatch(r'sha256:[0-9a-f]{64}', digest):
raise ValueError(translate('Invalid OCI digest'))
return read('blobs/sha256/' + digest[7:], digest)
descriptors = read('index.json')['manifests']
if len(descriptors) != 1:
raise ValueError(translate('A single-platform OCI archive is required'))
digest = descriptors[0]['digest']
manifest = blob(digest)
config = blob(manifest['config']['digest'])
return {'manifest_digest': digest, 'config_digest': manifest['config']['digest'],
'architecture': config['architecture'], 'os': config.get('os'),
'defaults': {k: config.get('config', {}).get(k) for k in FIELDS}}
def parse_config(data):
values = {}
for line in data.decode().splitlines():
if line and not line.startswith('#') and ': ' in line:
key, value = line.split(': ', 1)
values[key] = value
return values
def private_directory(path):
path.mkdir(parents=True, exist_ok=True, mode=0o700)
if path.is_symlink() or path.stat().st_uid != os.geteuid():
raise ValueError(translate('Unsafe registry directory'))
path.chmod(0o700)
def save_record(root, record):
private_directory(root)
with (root / '.lock').open('a') as lock:
os.chmod(root / '.lock', 0o600)
fcntl.flock(lock, fcntl.LOCK_EX)
path = root / f"{record['vmid']}.json"
if path.exists() or path.is_symlink():
if path.is_symlink():
raise ValueError(translate('Unsafe record'))
history = root / 'history'
private_directory(history)
# Keep the current record present until its replacement is durable.
os.link(path, history / f"{record['vmid']}-{uuid.uuid4().hex}.json")
fd, temporary = tempfile.mkstemp(dir=root, prefix='.record-')
try:
with os.fdopen(fd, 'w') as out:
json.dump(record, out, indent=2, ensure_ascii=True)
out.write('\n')
out.flush()
os.fsync(out.fileno())
os.replace(temporary, path)
directory_fd = os.open(root, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def record_install(args):
template = json.loads(Path(args.template).read_text())
deployment = json.loads(Path(args.deployment).read_text())
config = command('pct', 'config', str(args.vmid))
image = image_from_archive(args.archive)
record = {'schema_version': 1, 'installation_id': str(uuid.uuid4()), 'vmid': args.vmid,
'recorded_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'provenance': 'installer-completed', 'image': image,
'reference': template['container_contract']['image']['reference'],
'resolved_registry_digest': args.digest, 'archive_path': args.archive,
'template': template, 'deployment': deployment,
'config_sha256': sha(config), 'config': config.decode(),
'start_after_create_requested': bool(deployment.get('start_after_create')),
'automatic_update_enabled': False}
save_record(args.state_dir, record)
def resolve_candidate(reference, architecture):
repo = reference.split('@', 1)[0]
if ':' in repo.rsplit('/', 1)[-1]:
repo = repo.rsplit(':', 1)[0]
transport = reference
if '@' in reference:
transport = repo + '@' + reference.split('@', 1)[1]
raw = command('skopeo', 'inspect', '--raw', 'docker://' + transport)
manifest = json.loads(raw)
if 'manifests' in manifest:
matches = [m for m in manifest['manifests'] if m.get('platform', {}).get('architecture') == architecture
and m.get('platform', {}).get('os') == 'linux']
if len(matches) != 1:
raise ValueError(translate('A single matching image platform cannot be resolved'))
digest = matches[0]['digest']
raw = command('skopeo', 'inspect', '--raw', 'docker://' + repo + '@' + digest)
if 'sha256:' + sha(raw) != digest:
raise ValueError(translate('The manifest does not match its digest'))
digest = 'sha256:' + sha(raw)
config = json.loads(command('skopeo', 'inspect', '--config', 'docker://' + repo + '@' + digest))
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')}
def compare(record, current, candidate=None):
blockers = []
cfg = parse_config(current)
if sha(current) != record['config_sha256']:
blockers.append('configuration-drift-or-vmid-reused')
mounts = []
for key, value in cfg.items():
if not re.fullmatch(r'mp\d+', key):
continue
parts = value.split(',')
source = parts[0].removeprefix('volume=')
options = dict(p.split('=', 1) for p in parts[1:] if '=' in p)
managed = not source.startswith('/') and ':' in source
mounts.append(options.get('mp'))
if not managed or options.get('backup') != '1':
blockers.append('persistent-mount-needs-backup:' + key)
declared = set(record['image']['defaults'].get('Volumes') or {})
declared.update(v['container_path'] for v in record['template'].get('container_contract', {}).get('volumes', []) if v.get('container_path'))
for path in sorted(declared):
if path not in mounts:
blockers.append('image-volume-not-externalized:' + path)
deployment = record['deployment']
if deployment.get('stack_managed'):
blockers.append('stack-member-requires-coordination')
if deployment.get('deployment_kind') not in (None, 'single-lxc'):
blockers.append('stack-or-special-deployment-requires-coordination')
if cfg.get('hookscript'):
blockers.append('hookscript-requires-coordination')
if record['template'].get('installer_profile', {}).get('post_start_configurations') or deployment.get('post_start_configurations'):
blockers.append('rootfs-adaptations-require-replay')
report = {'vmid': record['vmid'], 'registered': True, 'update_available': 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)]
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']
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--state-dir', type=Path, default=ROOT)
sub = parser.add_subparsers(dest='action', required=True)
sub.add_parser('inventory')
diagnose = sub.add_parser('diagnose')
diagnose.add_argument('vmid', type=int)
diagnose.add_argument('--check-registry', action='store_true')
record = sub.add_parser('record', help='Internal installer operation; not legacy adoption')
record.add_argument('vmid', type=int)
for flag in ('template', 'deployment', 'archive', 'digest'):
record.add_argument('--' + flag, required=True)
args = parser.parse_args(argv)
if os.geteuid() != 0:
parser.error(translate('Run as root on the Proxmox node; the registry contains private data'))
try:
if args.action == 'record':
record_install(args)
msg_ok(translate('Private installation record saved'))
elif args.action == 'inventory':
rows = command('pct', 'list').decode().splitlines()[1:]
print(json.dumps([{'vmid': int(row.split()[0]), 'registered': (args.state_dir / (row.split()[0] + '.json')).is_file()}
for row in rows if row.strip()], indent=2))
else:
path = args.state_dir / f'{args.vmid}.json'
if not path.exists():
print(json.dumps({'vmid': args.vmid, 'registered': False, 'automatic_update_enabled': False,
'blockers': ['unregistered-installation-no-automatic-adoption']}))
return 0
record = json.loads(path.read_text())
if record.get('schema_version') != 1 or record.get('vmid') != args.vmid:
raise ValueError(translate('Incompatible record'))
current = command('pct', 'config', str(args.vmid))
candidate = resolve_candidate(record['reference'], record['image']['architecture']) if args.check_registry else None
print(json.dumps(compare(record, current, candidate), indent=2))
return 0
except (OSError, ValueError, KeyError, RuntimeError, subprocess.TimeoutExpired, tarfile.TarError):
with contextlib.redirect_stdout(sys.stderr):
msg_error(translate('The record or diagnosis could not be completed; no update was run.'))
return 1
if __name__ == '__main__':
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""Private OCI instance contracts. Does not recreate or update containers."""
from __future__ import annotations
import argparse
import contextlib
import copy
from contextlib import contextmanager
import datetime
import fcntl
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import uuid
from oci_installation_state import command, image_from_archive, private_directory, sha
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')
MARKER = 'proxmenux-instance='
ACTIVE = {'installing', 'assembling', 'updating', 'recovering'}
def now():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def location(root, vmid):
if not isinstance(vmid, int) or vmid < 100:
raise ValueError(translate('Invalid VMID'))
path = root / str(vmid)
if path.is_symlink():
raise ValueError(translate('Unsafe instance directory'))
return path / 'oci-compose.json'
@contextmanager
def locked(root):
private_directory(root)
path = root / '.lock'
if path.is_symlink():
raise ValueError(translate('Unsafe registry lock'))
inherited = os.environ.get('PROXMENUX_INSTANCE_LOCK_FD')
if inherited:
fd = int(inherited)
if os.fstat(fd).st_ino != path.stat().st_ino or os.fstat(fd).st_dev != path.stat().st_dev:
raise ValueError(translate('Wrong inherited registry lock'))
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
else:
with path.open('a') as lock:
os.chmod(path, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
def write(path, value):
private_directory(path.parent)
if path.is_symlink():
raise ValueError(translate('Unsafe instance record'))
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix='.compose-')
try:
with os.fdopen(fd, 'w') as out:
json.dump(value, out, indent=2)
out.write('\n')
out.flush()
os.fsync(out.fileno())
os.replace(tmp, path)
fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def read(root, vmid):
path = location(root, vmid)
if path.is_symlink():
raise ValueError(translate('Unsafe instance record'))
value = json.loads(path.read_text())
if value.get('schema_version') != 1 or value.get('vmid') != vmid:
raise ValueError(translate('Incompatible instance record'))
uuid.UUID(value['installation_id'])
return value
def has_contract(root, vmid):
path = location(root, vmid)
if path.is_symlink():
raise ValueError(translate('Unsafe instance record'))
if path.parent.exists() and not path.parent.is_dir():
raise ValueError(translate('Incompatible instance directory'))
return path.exists()
def guest_exists(vmid):
nodes = Path('/etc/pve/nodes')
return any(nodes.glob(f'*/lxc/{vmid}.conf')) or any(nodes.glob(f'*/qemu-server/{vmid}.conf'))
def release_orphan(root, vmid):
"""A record whose guest no longer exists on any node does not own the VMID:
a failed install, or a container deleted from the Proxmox UI. An update or
recovery left halfway keeps it, because its backup may still be needed.
The record stays in the same directory as history."""
path = location(root, vmid)
if not path.exists():
return True
previous = read(root, vmid)
if (previous.get('status') in ('updating', 'recovering')
or previous.get('pending_transaction') or previous.get('pending_stack_transaction')
or guest_exists(vmid)):
return False
path.replace(path.with_name(f"retired-{previous['installation_id']}.json"))
return True
def prepare(root, vmid, template, deployment):
path = location(root, vmid)
if not release_orphan(root, vmid):
raise ValueError(f"VMID {vmid} {translate('belongs to another OCI installation')}")
deployment = dict(deployment, vmid=vmid)
value = {'schema_version': 1, 'vmid': vmid, 'installation_id': str(uuid.uuid4()),
'created_at': now(), 'status': 'installing', 'template': template,
'deployment': deployment, 'observed': None,
'automatic_update_enabled': False}
write(path, value)
return value
def observe(vmid, installation_id, archive, digest, image=None):
"""Collect evidence before changing the current desired-state contract.
An installation observed again passes its saved image metadata, since the
downloaded archive may have been removed."""
config = command('pct', 'config', str(vmid))
if identity(config) != installation_id:
raise ValueError(translate('The container identity does not match'))
resources = json.loads(command('pvesh', 'get', '/cluster/resources', '--type', 'vm', '--output-format', 'json'))
node = next(r['node'] for r in resources if r.get('type') == 'lxc' and int(r['vmid']) == vmid)
api_config = command('pvesh', 'get', f'/nodes/{node}/lxc/{vmid}/config', '--output-format', 'json')
observed = {
'config': config.decode(), 'config_sha256': sha(config), 'api_config_sha256': api_hash(api_config),
'image': image if image is not None else image_from_archive(archive), 'archive_path': archive,
'resolved_registry_digest': digest}
sources = capture_sources(config)
if sources:
observed['host_bind_sources'] = sources
gpu = capture_gpu_devices(config)
if gpu:
observed['gpu_devices'] = gpu
return observed
def finish(root, vmid, archive, digest):
value = read(root, vmid)
observed = observe(vmid, value['installation_id'], archive, digest)
value.update(status='assembling' if value['deployment'].get('stack_managed') else 'installed',
completed_at=now(), observed=observed)
write(location(root, vmid), value)
def publish_stack(root, primary_id, template, deployment, members):
"""Preserve each recipe AND a complete, nonrecursive copy in the principal."""
records = {}
for member in members:
vmid = int(member['vmid'])
record = read(root, vmid)
if identity(command('pct', 'config', str(vmid))) != record['installation_id']:
raise ValueError(translate('A stack member has a different identity'))
records[vmid] = record
if primary_id is not None and primary_id not in records:
raise ValueError(translate('The main member of the stack is missing'))
stack_id = records[primary_id]['installation_id'] if primary_id is not None else None
for member in members:
vmid = int(member['vmid'])
record = records[vmid]
if 'deployment' in member:
record['deployment'] = copy.deepcopy(member['deployment'])
record['deployment']['vmid'] = vmid
record['deployment']['stack_managed'] = primary_id is not None
write(location(root, vmid), record)
finish(root, vmid, record['observed']['archive_path'], record['observed']['resolved_registry_digest'])
record = read(root, vmid)
record['status'] = 'installed'
if stack_id:
record['stack_member'] = {'stack_id': stack_id, 'primary_vmid': primary_id, 'name': member['name']}
records[vmid] = record
if primary_id is not None:
recipes = [copy.deepcopy(records[int(m['vmid'])]) for m in members]
for recipe in recipes:
recipe.pop('stack', None)
records[primary_id]['stack'] = {
'id': stack_id, 'template': copy.deepcopy(template),
'deployment': copy.deepcopy(deployment), 'members': recipes,
'reconstruction_requires_volume_verification': True,
}
records[primary_id]['stack']['deployment']['services'] = copy.deepcopy(members)
# Persist recovery recipes first, before publishing member completion.
write(location(root, primary_id), records[primary_id])
for vmid, record in records.items():
if vmid != primary_id:
write(location(root, vmid), record)
def identity(config):
# Description is URL-escaped by pct; UUID characters remain unchanged.
text = config.decode()
try:
description = json.loads(text).get('description', '')
except ValueError:
description = next((line[len('description: '):] for line in text.splitlines()
if line.startswith('description: ')), '')
match = re.search(r'proxmenux-instance=([0-9a-f-]{36})(?![0-9a-f-])', description)
return match.group(1) if match else None
def save_assembly(root, primary_id, template, deployment, members):
path = location(root, primary_id).parent / 'stack-assembly.json'
if path.exists() or path.is_symlink():
raise ValueError(translate('A pending stack assembly already exists; it is not overwritten'))
ids = [int(m['vmid']) for m in members]
if primary_id not in ids or len(set(ids)) != len(ids):
raise ValueError(translate('Invalid stack members'))
expected = {str(vmid): read(root, vmid)['installation_id'] for vmid in ids}
write(path, {'schema_version': 1, 'primary_vmid': primary_id, 'created_at': now(),
'expected_installation_ids': expected, 'template': template,
'deployment': deployment, 'services': members})
def resume_assembly(root, primary_id):
path = location(root, primary_id).parent / 'stack-assembly.json'
if path.is_symlink():
raise ValueError(translate('Unsafe stack assembly'))
saved = json.loads(path.read_text())
ids = [int(m['vmid']) for m in saved['services']]
if (saved.get('schema_version') != 1 or saved['primary_vmid'] != primary_id
or primary_id not in ids or len(set(ids)) != len(ids)
or set(saved['expected_installation_ids']) != {str(vmid) for vmid in ids}):
raise ValueError(translate('Incompatible stack assembly'))
for vmid in ids:
expected = saved['expected_installation_ids'][str(vmid)]
if read(root, vmid)['installation_id'] != expected:
raise ValueError(translate('The record of a member was replaced; the assembly is not resumed'))
if identity(command('pct', 'config', str(vmid))) != expected:
raise ValueError(translate('The container of a member was replaced; the assembly is not resumed'))
publish_stack(root, primary_id, saved['template'], saved['deployment'], saved['services'])
path.unlink()
def api_hash(config):
return sha(json.dumps(json.loads(config), sort_keys=True, separators=(',', ':')).encode())
def reconcile(root, resources, configs):
"""Caller holds lock and fetched a complete cluster inventory and configs."""
if not isinstance(resources, list):
raise ValueError(translate('Invalid Proxmox inventory'))
for resource in resources:
if (not isinstance(resource, dict) or resource.get('type') not in ('lxc', 'qemu')
or not isinstance(resource.get('vmid'), int)):
raise ValueError(translate('Incomplete or incompatible Proxmox inventory'))
present = {int(r['vmid']): r for r in resources if r.get('type') in ('lxc', 'qemu')}
decisions = []
pending = set()
for directory in root.iterdir():
if directory.name.isdecimal():
journal = location(root, int(directory.name)).parent / 'stack-assembly.json'
if journal.is_symlink():
raise ValueError(translate('Unsafe stack assembly'))
if journal.exists():
saved = json.loads(journal.read_text())
if saved.get('schema_version') != 1:
raise ValueError(translate('Incompatible stack assembly'))
pending.update(int(vmid) for vmid in saved['expected_installation_ids'])
# Plan everything before removing anything: malformed records fail closed.
for directory in sorted(root.iterdir()):
if not directory.name.isdecimal():
continue
vmid = int(directory.name)
if not has_contract(root, vmid):
# Orphan cleanup intentionally retains transaction history/backups.
continue
value = read(root, vmid)
row = {'vmid': vmid, 'status': value['status'], 'action': 'keep'}
if value['status'] in ACTIVE or vmid in pending or value.get('pending_stack_transaction'):
row['reason'] = 'operation-in-progress'
elif vmid not in present or present[vmid]['type'] != 'lxc':
row.update(action='delete', reason='container-absent-or-replaced')
else:
config = configs[vmid]
marker = identity(config)
if marker and marker != value['installation_id']:
row.update(action='delete', reason='different-installation')
elif not marker:
row['reason'] = 'identity-unconfirmed'
else:
row['reason'] = 'matched'
baseline = (value.get('observed') or {}).get('api_config_sha256')
row['configuration_changed'] = api_hash(config) != baseline if baseline else None
decisions.append(row)
if value.get('stack'):
row['missing_members'] = [m['vmid'] for m in value['stack']['members']
if m['vmid'] not in present]
row['replaced_members'] = [m['vmid'] for m in value['stack']['members']
if m['vmid'] in present and (present[m['vmid']]['type'] != 'lxc' or
(m['vmid'] in configs and identity(configs[m['vmid']]) not in (None, m['installation_id'])))]
for row in decisions:
if row['action'] == 'delete':
path = location(root, row['vmid'])
path.unlink()
# Never recursively delete history, backups, or unexpected files.
try:
path.parent.rmdir()
except OSError:
pass
return decisions
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=ROOT)
sub = parser.add_subparsers(dest='action', required=True)
for action in ('prepare', 'complete', 'failed'):
p = sub.add_parser(action)
p.add_argument('vmid', type=int)
if action == 'prepare':
p.add_argument('--template', required=True)
p.add_argument('--deployment', required=True)
if action == 'complete':
p.add_argument('--archive', required=True)
p.add_argument('--digest', required=True)
sub.add_parser('reconcile')
resume = sub.add_parser('resume-stack-recording')
resume.add_argument('vmid', type=int)
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
try:
with locked(args.root):
if args.action == 'resume-stack-recording':
resume_assembly(args.root, args.vmid)
msg_ok(translate('Stack records saved; no container was reinstalled.'))
elif args.action == 'prepare':
value = prepare(args.root, args.vmid, json.loads(Path(args.template).read_text()),
json.loads(Path(args.deployment).read_text()))
print(value['installation_id'])
elif args.action == 'complete':
finish(args.root, args.vmid, args.archive, args.digest)
elif args.action == 'failed':
value = read(args.root, args.vmid)
value.update(status='failed', failed_at=now())
write(location(args.root, args.vmid), value)
else:
resources = json.loads(command('pvesh', 'get', '/cluster/resources', '--type', 'vm', '--output-format', 'json'))
configs = {}
wanted = set()
for directory in args.root.iterdir():
if directory.name.isdecimal():
if not has_contract(args.root, int(directory.name)):
continue
record = read(args.root, int(directory.name))
wanted.add(record['vmid'])
wanted.update(m['vmid'] for m in record.get('stack', {}).get('members', []))
for r in resources:
if r.get('type') == 'lxc' and int(r['vmid']) in wanted:
configs[int(r['vmid'])] = command('pvesh', 'get', f"/nodes/{r['node']}/lxc/{r['vmid']}/config", '--output-format', 'json')
# pvesh JSON contains the description and all repeated LXC entries.
print(json.dumps(reconcile(args.root, resources, configs), indent=2))
return 0
except (OSError, ValueError, KeyError, StopIteration, RuntimeError, subprocess.TimeoutExpired) as exc:
# stdout carries data read by the installers; the error goes to stderr.
with contextlib.redirect_stdout(sys.stderr):
msg_error(f"{translate('The instance record operation did not complete; no container was modified.')} ({exc})")
return 1
if __name__ == '__main__':
raise SystemExit(main())
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Instance recording adapter for the existing dedicated stack installers."""
import argparse
import copy
import json
import os
from pathlib import Path
import subprocess
import sys
import oci_instances as instances
from oci_installation_state import command, image_from_archive, sha
import oci_stack_replay
from oci_ui import translate
def begin(root, primary, template, deployment, members, adapter):
ids = [int(m[1]) for m in members]
if primary not in ids or len(set(ids)) != len(ids):
raise ValueError(translate('Invalid stack members'))
resources = json.loads(command('pvesh', 'get', '/cluster/resources', '--type', 'vm', '--output-format', 'json'))
if not isinstance(resources, list):
raise ValueError(translate('Invalid Proxmox inventory'))
occupied = {int(r['vmid']) for r in resources}
for vmid in ids:
if vmid in occupied or not instances.release_orphan(root, vmid):
raise ValueError(translate('The VMID or its contract is already in use; it is not adopted'))
adapter_source = Path(adapter).read_text()
prepared = []
for name, vmid, reference, archive in members:
archive_path = archive if archive.startswith('/') else command('pvesm', 'path', archive).decode().strip()
image = image_from_archive(archive_path)
child = {'id': template['id'] + '-' + name,
'container_contract': {'image': {'reference': reference}}}
plan = {'deployment_kind': 'dedicated-stack-member', 'stack_managed': True,
'role': name, 'archive_volume': archive, 'archive_path': archive_path,
'image': image, 'rootfs_adaptation_replay_required': True,
'mounts': []}
if Path(adapter).name == 'install_immich_stack.sh' and name == 'machine-learning':
plan['machine_learning'] = copy.deepcopy(deployment.get('machine_learning', {'acceleration': 'cpu'}))
if Path(adapter).name in oci_stack_replay.FILES:
plan['replay_profile'] = {'adapter': Path(adapter).name, 'role': name}
if not oci_stack_replay.FILES[Path(adapter).name][name]:
plan['rootfs_replay'] = {'schema_version': 1, 'adapter': Path(adapter).name,
'role': name, 'files': []}
prepared.append((int(vmid), child, plan))
for vmid, child, plan in prepared:
instances.prepare(root, vmid, child, plan)
parent = instances.read(root, primary)
parent['native_stack_intent'] = {
'template': template, 'deployment': copy.deepcopy(deployment),
'members': [{'name': m[0], 'vmid': int(m[1])} for m in members],
'adapter': {'name': Path(adapter).name, 'sha256': sha(adapter_source.encode()),
'source': adapter_source},
}
parent['native_stack_intent']['deployment']['base_vmid'] = primary
instances.write(instances.location(root, primary), parent)
def create(root, args):
vmid = int(args[0])
record = instances.read(root, vmid)
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']]
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
def capture_rootfs(root, vmid):
record = instances.read(root, vmid)
profile = record['deployment'].get('replay_profile')
if not profile:
raise ValueError(translate('The stack member has no declared adaptation profile'))
if record['status'] != 'installing':
raise ValueError(translate('The rootfs capture only belongs to the running installation'))
mounts = []
for line in command('pct', 'config', str(vmid)).decode().splitlines():
key, sep, value = line.partition(': ')
if sep and key.startswith('mp') and key[2:].isdigit():
options = dict(p.split('=', 1) for p in value.split(',')[1:] if '=' in p)
mounts.append({'container_path': options['mp']})
record['deployment']['rootfs_replay'] = oci_stack_replay.capture(
Path('/var/lib/lxc') / str(vmid) / 'rootfs', profile['adapter'], profile['role'], mounts)
instances.write(instances.location(root, vmid), record)
def finalize(root, primary):
parent = instances.read(root, primary)
intent = parent['native_stack_intent']
services = []
for member in intent['members']:
vmid = member['vmid']
record = instances.read(root, vmid)
plan = record['deployment']
instances.finish(root, vmid, plan['archive_path'], plan['image']['manifest_digest'])
record = instances.read(root, vmid)
plan = record['deployment']
# Store raw config: repeated LXC directives must not be collapsed.
plan['native_config'] = record['observed']['config']
if plan.get('rootfs_replay'):
try:
plan['member_replay_projection'] = oci_stack_replay.normalize(record)
plan.pop('member_replay_projection_error', None)
except (ValueError, KeyError):
# Recording an unsupported projection must not roll back a
# healthy installation; it remains blocked for management.
plan.pop('member_replay_projection', None)
plan['member_replay_projection_error'] = 'native-config-requires-reviewed-conversion'
mounts = []
for line in plan['native_config'].splitlines():
key, sep, value = line.partition(': ')
if sep and key.startswith('mp') and key[2:].isdigit():
parts = value.split(',')
options = dict(p.split('=', 1) for p in parts[1:] if '=' in p)
mounts.append({'container_path': options['mp'], 'source': parts[0],
'type': 'host-bind' if parts[0].startswith('/') else 'managed-volume',
'backup': options.get('backup') == '1',
'read_only': options.get('ro') == '1', 'existing_volume': True})
plan['mounts'] = mounts
services.append({'name': member['name'], 'vmid': vmid, 'deployment': plan})
path = instances.location(root, primary).parent / 'stack-assembly.json'
if not path.exists():
instances.save_assembly(root, primary, intent['template'], intent['deployment'], services)
instances.resume_assembly(root, primary)
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='action', required=True)
p = sub.add_parser('begin')
p.add_argument('primary', type=int)
for option in ('template', 'deployment', 'adapter'):
p.add_argument('--' + option, required=True)
p.add_argument('--member', action='append', nargs=4, required=True)
p = sub.add_parser('create')
p.add_argument('arguments', nargs=argparse.REMAINDER)
for action in ('finalize', 'failed'):
p = sub.add_parser(action)
p.add_argument('primary', type=int)
p = sub.add_parser('capture-rootfs')
p.add_argument('vmid', type=int)
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
try:
with instances.locked(instances.ROOT):
if args.action == 'begin':
begin(instances.ROOT, args.primary, json.loads(Path(args.template).read_text()),
json.loads(Path(args.deployment).read_text()), args.member, args.adapter)
elif args.action == 'create':
return create(instances.ROOT, args.arguments)
elif args.action == 'finalize':
finalize(instances.ROOT, args.primary)
elif args.action == 'capture-rootfs':
capture_rootfs(instances.ROOT, args.vmid)
else:
parent = instances.read(instances.ROOT, args.primary)
for member in parent['native_stack_intent']['members']:
record = instances.read(instances.ROOT, member['vmid'])
record['status'] = 'failed'
instances.write(instances.location(instances.ROOT, member['vmid']), record)
return 0
except (OSError, ValueError, KeyError, RuntimeError, subprocess.TimeoutExpired) as exc:
print(f"ERROR: {translate('The stack registry is incomplete; review the private contracts.')} ({exc})",
file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())
+42
View File
@@ -0,0 +1,42 @@
# Sourced by the dedicated installers after image resolution, before CT creation.
oci_native_begin() {
OCI_NATIVE_PRIMARY=$1
shift
local root=/usr/local/share/proxmenux/oci/apps
[[ ! -L $root && ! -L $root/.lock ]] || die "$(translate "The instance registry is not safe")"
oci_quiet install -d -m 0700 "$root"
exec 8>>"$root/.lock"
chmod 600 "$root/.lock"
flock -n 8 || die "$(translate "Another OCI operation is using the instance registry")"
export PROXMENUX_INSTANCE_LOCK_FD=8
oci_quiet python3 "$SCRIPT_DIR/oci_native_stack.py" begin "$OCI_NATIVE_PRIMARY" \
--template "$TEMPLATE_FILE" --deployment "$DEPLOYMENT_FILE" \
--adapter "$0" "$@"
OCI_NATIVE_ACTIVE=1
}
# A failure returns to the caller instead of exiting inside a redirected call,
# so the caller's error report reaches the terminal and not the log.
pct() {
if [[ ${1:-} == unmount && ${OCI_NATIVE_ACTIVE:-0} == 1 ]]; then
if ! python3 "$SCRIPT_DIR/oci_native_stack.py" capture-rootfs "$2"; then
command pct unmount "$2" 8>&- 9>&- || true
return 1
fi
fi
if [[ ${1:-} == create && ${OCI_NATIVE_ACTIVE:-0} == 1 ]]; then
shift
python3 "$SCRIPT_DIR/oci_native_stack.py" create "$@" || return
else
command pct "$@" 8>&- 9>&- || return
fi
}
oci_native_finalize() {
oci_quiet python3 "$SCRIPT_DIR/oci_native_stack.py" finalize "$OCI_NATIVE_PRIMARY"
}
oci_native_failed() {
[[ ${OCI_NATIVE_ACTIVE:-0} == 1 ]] || return 0
oci_quiet python3 "$SCRIPT_DIR/oci_native_stack.py" failed "$OCI_NATIVE_PRIMARY" || true
}
+57
View File
@@ -0,0 +1,57 @@
"""Validation for the experimental native-device/dynamic-library NVIDIA profile.
Driver files are runtime evidence, not persistent desired-state dependencies.
This module does not enable transactions before the common installer supports
the same profile.
"""
from pathlib import Path
import hashlib
import oci_nvidia_runtime as nv
from oci_ui import translate
def gpu_identity(inventory):
identities = []
for row in inventory['gpus']:
fields = [part.strip() for part in row.split(',')]
if len(fields) != 3 or not all(fields):
raise ValueError(translate('Incomplete NVIDIA identity'))
identities.append(tuple(fields[:2]))
if not identities or len(set(identities)) != len(identities):
raise ValueError(translate('Empty or duplicated NVIDIA identity'))
return sorted(identities)
def validate(config, previous, current, hook, expected_hook_sha256,
capabilities='compute,utility,video'):
if gpu_identity(previous) != gpu_identity(current):
raise ValueError(translate('The selected GPU changed'))
if nv.mount_lines(config):
raise ValueError(translate('The dynamic profile does not support static driver mounts'))
hook = Path(hook)
info = hook.stat()
if (hook.is_symlink() or not hook.is_file() or info.st_uid != 0
or info.st_mode & 0o022 or not info.st_mode & 0o111
or hashlib.sha256(hook.read_bytes()).hexdigest() != expected_hook_sha256):
raise ValueError(translate('Untrusted or modified NVIDIA hook'))
allowed = {'lxc.hook.mount': str(hook),
'lxc.environment': {'NVIDIA_VISIBLE_DEVICES=all',
f'NVIDIA_DRIVER_CAPABILITIES={capabilities}'}}
found_hook, environments = [], []
for line in config.decode().splitlines():
if not line.startswith('lxc.') or ': ' not in line:
continue
key, value = line.split(': ', 1)
if key == 'lxc.hook.mount':
found_hook.append(value)
elif key == 'lxc.environment':
environments.append(value)
elif key.startswith(('lxc.hook.', 'lxc.cgroup', 'lxc.apparmor')):
raise ValueError(translate('Security directive outside the dynamic profile'))
if found_hook != [allowed['lxc.hook.mount']] or (
len(environments) != 2 or set(environments) != allowed['lxc.environment']):
raise ValueError(translate('The NVIDIA hook or environment differs from the declared one'))
nv.check_devices(config, current)
return {'gpu_identity': gpu_identity(current), 'hook_sha256': expected_hook_sha256,
'driver_capabilities': capabilities, 'inventory': current}
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Explicit stopped-CT NVIDIA refresh. Never changes the desired deployment."""
from __future__ import annotations
import argparse
import copy
import os
from pathlib import Path
import subprocess
import oci_instances as instances
import oci_nvidia_runtime as nv
from oci_ui import translate, msg_info, msg_ok, msg_error
def destination(root, name):
if not name.startswith('/') or '..' in Path(name).parts:
raise ValueError(translate('Invalid NVIDIA destination'))
parent = (root / name.lstrip('/')).parent.resolve()
if parent != root and root not in parent.parents:
raise ValueError(translate('The NVIDIA destination escapes the rootfs'))
return parent / Path(name).name
def prepare(root, plan):
root = root.resolve()
# Validate every destination before making any rootfs change.
files = {name: destination(root, name) for name in plan['inventory']['files']}
links = {name: destination(root, name) for name in plan['links']}
for path in list(files.values()) + list(links.values()):
if path.exists() and not path.is_file() and not path.is_symlink():
raise ValueError(translate('The NVIDIA destination cannot be replaced'))
for path in files.values():
create_parent(path.parent, root)
if path.is_symlink():
path.unlink()
if not path.exists():
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
os.close(fd)
owner = path.parent.stat()
os.chown(path, owner.st_uid, owner.st_gid)
for name, path in links.items():
create_parent(path.parent, root)
if path.exists() or path.is_symlink():
path.unlink()
path.symlink_to(plan['links'][name])
# Native LXC cannot mount onto an alias in a usr-merged image.
lines = []
for line in plan['config'].decode().splitlines():
if line.startswith('lxc.mount.entry: '):
fields = line.split(': ', 1)[1].split()
fields[1] = str(files['/' + fields[1]].relative_to(root))
line = 'lxc.mount.entry: ' + ' '.join(fields)
lines.append(line)
return ('\n'.join(lines) + '\n').encode()
def create_parent(path, root):
if path.exists():
if not path.is_dir():
raise ValueError(translate('The parent of an NVIDIA destination is not a directory'))
return
if path == root:
raise ValueError(translate('The rootfs is not mounted'))
create_parent(path.parent, root)
path.mkdir(mode=0o755)
owner = path.parent.stat()
os.chown(path, owner.st_uid, owner.st_gid)
def refresh(root, vmid, apply=False):
with instances.locked(root):
record = instances.read(root, vmid)
if record['status'] != 'installed' or record.get('pending_transaction'):
raise ValueError(translate('The instance has a pending operation'))
if not nv.enabled(record['deployment']):
raise ValueError(translate('The instance does not use NVIDIA'))
config = instances.command('pct', 'config', str(vmid))
if (instances.identity(config) != record['installation_id']
or instances.sha(config) != record['observed']['config_sha256']):
raise ValueError(translate('The container identity or configuration changed'))
previous = record['observed']['gpu_devices'][nv.KEY]
plan = nv.refresh_plan(config, previous)
journal = instances.location(root, vmid).parent / 'nvidia-refresh.json'
if journal.exists() or journal.is_symlink():
raise ValueError(translate('A previous NVIDIA refresh is pending review'))
if not apply:
msg_ok(translate('Current NVIDIA inventory resolved: a refresh is required') if plan['changed']
else translate('Current NVIDIA inventory resolved: no refresh is required'))
return
if not plan['changed']:
msg_ok(translate('The NVIDIA runtime is up to date; the container is not modified or started'))
return
if instances.command('pct', 'status', str(vmid)).strip() != b'status: stopped':
raise ValueError(translate('Stop the container before the NVIDIA refresh'))
instances.write(journal, {'phase': 'preparing', 'record': record,
'inventory': plan['inventory']})
msg_info(translate('Refreshing the NVIDIA runtime...'))
instances.command('pct', 'mount', str(vmid))
try:
candidate = prepare(Path(f'/var/lib/lxc/{vmid}/rootfs'), plan)
finally:
instances.command('pct', 'unmount', str(vmid))
nv.verify(plan['inventory'])
if instances.command('pct', 'config', str(vmid)) != config:
raise ValueError(translate('The configuration changed during the NVIDIA refresh'))
conf = Path(f'/etc/pve/lxc/{vmid}.conf')
# Same native configuration file used by the common installer.
conf.write_bytes(candidate)
instances.write(journal, {'phase': 'validating', 'record': record,
'inventory': plan['inventory']})
instances.command('pct', 'start', str(vmid))
try:
nv.validate_runtime(vmid, plan['inventory'])
finally:
instances.command('pct', 'shutdown', str(vmid), '--timeout', '30')
updated = copy.deepcopy(record)
updated['observed'] = instances.observe(vmid, record['installation_id'],
record['observed']['archive_path'], record['observed']['resolved_registry_digest'],
record['observed']['image'])
nv.check_mounts(updated['observed']['config'].encode(), plan['inventory'])
nv.check_devices(updated['observed']['config'].encode(), plan['inventory'])
if updated['observed']['gpu_devices'][nv.KEY] != plan['inventory']:
raise ValueError(translate('The observed inventory differs from the validated runtime'))
nv.verify(plan['inventory'])
instances.write(instances.location(root, vmid), updated)
journal.unlink()
msg_ok(translate('NVIDIA refresh validated; the container is stopped and its settings are kept'))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int)
parser.add_argument('--root', type=Path, default=instances.ROOT)
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
try:
refresh(args.root, args.vmid, args.apply)
except BlockingIOError:
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))
raise SystemExit(1)
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
msg_error(str(error) if not isinstance(error, KeyError) else
translate('The saved OCI record is incomplete or has an unexpected format.'))
raise SystemExit(1)
+165
View File
@@ -0,0 +1,165 @@
"""Read-only NVIDIA Toolkit inventory and strict native runtime validation."""
from __future__ import annotations
import hashlib
import os
from pathlib import Path, PurePosixPath
import stat
import subprocess
from oci_gpu_devices import actual_devices
from oci_ui import translate
KEY = '_nvidia_runtime'
QUERY = '--query-gpu=uuid,pci.bus_id,driver_version'
def command(*args):
result = subprocess.run(args, capture_output=True, text=True, timeout=120)
if result.returncode:
raise RuntimeError(f"{args[0]} {translate('could not validate NVIDIA; exit code')} {result.returncode}")
return result.stdout
def enabled(deployment):
devices = [d for d in deployment.get('devices', []) if d.get('kind') == 'nvidia-runtime']
if len(devices) > 1 or any(d.get('device_selection', 'all-requested-by-compose') != 'all-requested-by-compose' for d in devices):
raise ValueError(translate('NVIDIA selection not supported by this profile'))
return bool(devices)
def digest(path):
result = hashlib.sha256()
with path.open('rb') as source:
for block in iter(lambda: source.read(1024 * 1024), b''):
result.update(block)
return result.hexdigest()
def snapshot():
gpus = sorted(line.strip() for line in command('nvidia-smi', QUERY, '--format=csv,noheader').splitlines() if line.strip())
if not gpus:
raise ValueError(translate('No working NVIDIA GPU was found'))
version = command('nvidia-container-cli', '--version')
paths = command('nvidia-container-cli', 'list', '--device', 'all', '--libraries', '--binaries', '--firmwares', '--ipcs')
devices, files, links = {}, {}, {}
for name in sorted(set(paths.splitlines())):
if not name.startswith('/') or str(PurePosixPath(name)) != name or any(c.isspace() or c == ',' for c in name):
raise ValueError(translate('Invalid path in the NVIDIA inventory'))
path = Path(name)
info = path.stat()
basic = {'source': str(path.resolve()), 'uid': info.st_uid,
'gid': info.st_gid, 'mode': stat.S_IMODE(info.st_mode)}
if stat.S_ISCHR(info.st_mode):
if not name.startswith('/dev/nvidia'):
raise ValueError(translate('NVIDIA device outside the expected native profile'))
devices[name] = dict(basic, major=os.major(info.st_rdev), minor=os.minor(info.st_rdev))
elif stat.S_ISREG(info.st_mode):
files[name] = dict(basic, size=info.st_size, sha256=digest(path))
if name.startswith('/usr/lib/'):
for link in path.parent.iterdir():
if link.is_symlink() and str(link.resolve()) == basic['source']:
links[str(link)] = os.readlink(link)
# The common installer intentionally does not publish IPC sockets.
if not devices or not files:
raise ValueError(translate('Incomplete NVIDIA inventory'))
versions = [l for l in version.splitlines() if l.startswith(('cli-version:', 'lib-version:'))]
if len(versions) != 2:
raise ValueError(translate('The NVIDIA Container Toolkit version cannot be identified'))
return {'gpus': gpus, 'toolkit_version': versions,
'devices': devices, 'files': files, 'links': links}
def verify(value):
if snapshot() != value:
raise ValueError(translate('The NVIDIA driver or inventory changed; the operation was stopped'))
def refresh_plan(config, previous, current=None):
"""Resolve current host components without treating a driver version as intent.
This only prepares a plan; applying it requires a stopped-CT transaction and
preparing file destinations/library links before the next native start.
"""
check_devices(config, previous)
check_mounts(config, previous)
current = snapshot() if current is None else current
def identities(value):
result = []
for row in value['gpus']:
fields = [field.strip() for field in row.split(',')]
if len(fields) != 3 or not all(fields):
raise ValueError(translate('Incomplete NVIDIA identity'))
result.append(tuple(fields[:2]))
return sorted(result)
if identities(previous) != identities(current):
raise ValueError(translate('The physical NVIDIA selection changed'))
# Remove only entries already validated against our recorded inventory.
kept = []
for line in config.decode().splitlines():
if line.startswith('lxc.mount.entry: '):
continue
if line.startswith('dev') and ': ' in line:
key, properties = line.split(': ', 1)
if key[3:].isdigit():
fields = dict(part.split('=', 1) for part in properties.split(','))
if fields.get('path') in previous['devices']:
continue
kept.append(line)
occupied = {int(line.split(':', 1)[0][3:]) for line in kept
if line.startswith('dev') and line.split(':', 1)[0][3:].isdigit()}
for path, info in sorted(current['devices'].items()):
slot = next(i for i in range(256) if i not in occupied)
occupied.add(slot)
kept.append(f'dev{slot}: path={path},mode={info["mode"]:04o},gid={info["gid"]},deny-write=0')
for path, info in sorted(current['files'].items()):
kept.append(f'lxc.mount.entry: {info["source"]} {path.lstrip("/")} none ro,bind,create=file 0 0')
candidate = ('\n'.join(kept) + '\n').encode()
check_devices(candidate, current)
check_mounts(candidate, current)
return {'config': candidate, 'inventory': current,
'links': dict(current['links']), 'changed': previous != current}
def mount_lines(config):
return [line.split(': ', 1)[1] for line in config.decode().splitlines() if line.startswith('lxc.mount.entry: ')]
def check_mounts(config, value, complete=True):
remaining = dict(value['files'])
seen = set()
for line in mount_lines(config):
parts = line.split()
if (len(parts) != 6 or parts[2] != 'none' or set(parts[3].split(',')) != {'ro', 'bind', 'create=file'}
or parts[4:] != ['0', '0'] or line in seen):
raise ValueError(translate('LXC entry outside the read-only NVIDIA profile'))
seen.add(line)
match = next((name for name, file in remaining.items()
if parts[0] == file['source'] and parts[1] in {name.lstrip('/'), file['source'].lstrip('/')}), None)
if match is None:
raise ValueError(translate('NVIDIA mount with an unauthorized source or target'))
del remaining[match]
if complete and remaining:
raise ValueError(translate('NVIDIA runtime libraries or components are missing'))
def check_devices(config, value):
actual = actual_devices(config)
for path, info in value['devices'].items():
fields = actual.get(path, {})
if (fields.get('path') != path or set(fields) - {'path', 'mode', 'gid', 'uid', 'deny-write'}
or int(fields.get('mode', '0'), 8) != info['mode']
or int(fields.get('gid', 0)) != info['gid'] or int(fields.get('uid', 0)) != 0
or fields.get('deny-write', '0') != '0'):
raise ValueError(translate('NVIDIA permissions or device nodes differ from the official inventory'))
def validate_runtime(vmid, value):
rows = command('pct', 'exec', str(vmid), '--', 'nvidia-smi', QUERY, '--format=csv,noheader')
if sorted(line.strip() for line in rows.splitlines() if line.strip()) != value['gpus']:
raise ValueError(translate('NVIDIA inside the container does not match the host driver or GPU'))
if value['links']:
arguments = [part for pair in sorted(value['links'].items()) for part in pair]
command('pct', 'exec', str(vmid), '--', 'sh', '-c',
'while [ "$#" -gt 0 ]; do [ "$(readlink -- "$1")" = "$2" ] || exit 1; shift 2; done',
'check-nvidia-links', *arguments)
+81
View File
@@ -0,0 +1,81 @@
# Shared NVIDIA runtime setup for native OCI installers.
# Sourced by the installers: uses their msg_*, translate, oci_log and die.
configure_nvidia_runtime() {
local inventory path source target runtime_mode hook_hash hook_path capabilities
local device_count=0 mount_count=0
declare -A configured_paths=()
msg_info "$(translate "Preparing the NVIDIA GPU...")"
command -v nvidia-smi >/dev/null 2>&1 \
|| die "$(translate "The image requests NVIDIA, but the host has no working NVIDIA driver")"
nvidia-smi -L >/dev/null 2>&1 \
|| die "$(translate "The host NVIDIA driver is not responding correctly")"
command -v nvidia-container-cli >/dev/null 2>&1 \
|| die "$(translate "NVIDIA Container Toolkit is missing on the host (nvidia-container-cli)")"
runtime_mode=$(jq -r '.runtime_mode // "static"' <<<"$DEVICE")
[[ $runtime_mode == static || $runtime_mode == dynamic ]] \
|| die "$(translate "Unsupported NVIDIA mode:") $runtime_mode"
if [[ $runtime_mode == dynamic ]]; then
[[ $UNPRIVILEGED_FLAG == 1 ]] || die "$(translate "The dynamic NVIDIA profile requires an unprivileged LXC")"
[[ -f ${SCRIPT_DIR}/nvidia_lxc_mount_lab.sh ]] || die "$(translate "The dynamic NVIDIA hook is missing")"
capabilities=$(jq -r '[.environment[]? | select(.name == "NVIDIA_DRIVER_CAPABILITIES") | .value] | last // "compute,utility,video"' "$DEPLOYMENT_FILE")
[[ $capabilities == all || $capabilities =~ ^(compute|utility|video|graphics|display|compat32)(,(compute|utility|video|graphics|display|compat32))*$ ]] \
|| die "$(translate "Unsupported dynamic NVIDIA capabilities:") $capabilities"
hook_hash=$(sha256sum "${SCRIPT_DIR}/nvidia_lxc_mount_lab.sh" | awk '{print $1}')
hook_path="/usr/local/lib/proxmenux/oci/nvidia-mount-${hook_hash}.sh"
install -d -m 0755 /usr/local/lib/proxmenux/oci
if [[ -e $hook_path || -L $hook_path ]]; then
[[ ! -L $hook_path && $(sha256sum "$hook_path" | awk '{print $1}') == "$hook_hash" ]] \
|| die "$(translate "The persistent NVIDIA hook does not match the installer:") $hook_path"
else
install -m 0755 "${SCRIPT_DIR}/nvidia_lxc_mount_lab.sh" "$hook_path"
fi
printf 'lxc.environment: NVIDIA_VISIBLE_DEVICES=all\nlxc.environment: NVIDIA_DRIVER_CAPABILITIES=%s\nlxc.hook.mount: %s\n' \
"$capabilities" "$hook_path" >>"$CONF"
fi
inventory=$(mktemp /tmp/proxmenux-nvidia-inventory.XXXXXX)
if ! nvidia-container-cli list --device all --libraries --binaries --firmwares --ipcs \
2>>"${OCI_LOG:-/dev/null}" | sort -u >"$inventory"; then
rm -f "$inventory"
die "$(translate "NVIDIA Container Toolkit could not generate the runtime inventory")"
fi
while IFS= read -r path; do
[[ $path == /* && $path != *[[:space:]]* && $path != *","* ]] || continue
[[ -z ${configured_paths[$path]+x} ]] || continue
if [[ -c $path ]]; then
add_character_device "$path" preserve-host host-device-gid 0
if [[ -n ${NVIDIA_GID_ENV:-} ]]; then
append_deployment_environment_csv "$NVIDIA_GID_ENV" "$(stat -c '%g' "$path")"
fi
device_count=$((device_count + 1))
elif [[ -f $path ]]; then
if [[ $runtime_mode == dynamic ]]; then
continue
fi
source=$(readlink -f "$path")
[[ -f $source ]] || continue
target=$path
prepare_file_mount_target "$target"
target=$PREPARED_FILE_TARGET
prepare_nvidia_driver_links "$source" "$target"
printf 'lxc.mount.entry: %s %s none ro,bind,create=file 0 0\n' \
"$source" "${target#/}" >>"$CONF"
mount_count=$((mount_count + 1))
else
continue
fi
configured_paths[$path]=1
done <"$inventory"
rm -f "$inventory"
(( device_count > 0 )) || die "$(translate "The official inventory contains no NVIDIA devices")"
[[ $runtime_mode == dynamic ]] || (( mount_count > 0 )) \
|| die "$(translate "The official inventory contains no NVIDIA driver components")"
NVIDIA_RUNTIME_CONFIGURED=1
if [[ $runtime_mode == dynamic ]]; then
oci_log "Dynamic NVIDIA runtime prepared: $device_count native devices; libraries resolved by the Toolkit at every start"
msg_ok "$(translate "NVIDIA GPU prepared:") $device_count $(translate "devices (dynamic runtime)")"
return
fi
oci_log "NVIDIA runtime prepared from NVIDIA Container Toolkit: $device_count devices and $mount_count read-only components"
msg_ok "$(translate "NVIDIA GPU prepared:") $device_count $(translate "devices") · $mount_count $(translate "driver components")"
}
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Removes an OCI installation: its containers with the volumes they own, the
private network of a multi-container application and its saved record. Host
directories are left exactly as they are."""
from __future__ import annotations
import argparse
import ipaddress
import json
import os
from pathlib import Path
import re
import shutil
import socket
import subprocess
import sys
import oci_image_cache as image_cache
import oci_instances as instances
from oci_installation_state import parse_config
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
# The private networks ProxMenux creates for multi-container applications.
PRIVATE_STACK_NETWORK = ipaddress.ip_network('10.77.0.0/16')
def run(*args):
subprocess.run(args, check=True, capture_output=True)
def guest_config(vmid):
try:
return instances.command('pct', 'config', str(vmid))
except (subprocess.CalledProcessError, RuntimeError, OSError):
return None
def members_of(root, vmid):
"""Every container of the installation: one, or the whole stack when the
selected container belongs to one. The main container is removed last."""
record = instances.read(root, vmid)
primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
try:
primary = instances.read(root, primary_id)
except (OSError, ValueError, KeyError):
primary, primary_id = record, vmid
stack = primary.get('stack') or {}
members = [int(member['vmid']) for member in stack.get('members', []) if member.get('vmid')]
if primary_id not in members:
members.append(primary_id)
if vmid not in members:
members.append(vmid)
ordered = [member for member in members if member != primary_id] + [primary_id]
return primary_id, primary, ordered
def host_directories(root, members):
"""The host directories the containers were using, which are kept."""
paths = []
for vmid in members:
try:
record = instances.read(root, vmid)
except (OSError, ValueError, KeyError):
continue
for mount in record.get('deployment', {}).get('mounts', []):
if mount.get('type') == 'host-bind' and mount.get('source') not in paths:
paths.append(mount['source'])
return paths
def private_bridge(primary):
network = (primary.get('stack') or {}).get('deployment', {}).get('network', {})
bridge = network.get('private_bridge')
subnet = network.get('private_subnet')
if not bridge or not re.fullmatch(r'vmbr[0-9]+', bridge):
return None
try:
if not ipaddress.ip_network(subnet).subnet_of(PRIVATE_STACK_NETWORK):
return None
except (TypeError, ValueError):
return None
return bridge
def bridge_in_use(bridge, removed):
"""Whether a guest that is not being removed still uses the bridge."""
for path in Path('/etc/pve/nodes').glob('*/lxc/*.conf'):
if int(path.stem) in removed:
continue
if re.search(rf'(?:^|[,\s])bridge={re.escape(bridge)}(?:[,\s]|$)',
path.read_text(encoding='utf-8', errors='ignore'), re.MULTILINE):
return True
for path in Path('/etc/pve/nodes').glob('*/qemu-server/*.conf'):
if re.search(rf'(?:^|[,\s])bridge={re.escape(bridge)}(?:[,\s]|$)',
path.read_text(encoding='utf-8', errors='ignore'), re.MULTILINE):
return True
return False
def release_bridge(bridge):
node = socket.gethostname().split('.', 1)[0]
subprocess.run(['ip', 'link', 'delete', bridge, 'type', 'bridge'], check=False, capture_output=True)
subprocess.run(['pvesh', 'delete', f'/nodes/{node}/network/{bridge}'], check=False, capture_output=True)
def remove(root, vmid):
primary_id, primary, members = members_of(root, vmid)
for member in members:
record = instances.read(root, member)
if record.get('pending_transaction') or record.get('pending_stack_transaction'):
raise ValueError(translate('An operation of this installation has not finished; '
'recover it from the management menu before removing it'))
kept = host_directories(root, members)
bridge = private_bridge(primary)
msg_info(translate('Removing the containers...'))
for member in members:
record = instances.read(root, member)
config = guest_config(member)
if config is None:
msg_warn(f"{translate('The container no longer exists:')} CT {member}")
elif instances.identity(config) != record['installation_id']:
msg_warn(f"{translate('The VMID belongs to another container now and is not touched:')} CT {member}")
else:
subprocess.run(['pct', 'stop', str(member), '--skiplock', '1'], check=False, capture_output=True)
run('pct', 'destroy', str(member), '--purge', '1', '--destroy-unreferenced-disks', '1')
msg_ok(f"{translate('Container removed:')} CT {member}")
if bridge and not bridge_in_use(bridge, set(members)):
release_bridge(bridge)
msg_ok(f"{translate('Private network of the application released:')} {bridge}")
elif bridge:
msg_warn(f"{translate('The private network is still used by another container and is kept:')} {bridge}")
lifecycle = Path(f'/etc/pve/priv/proxmenux-stack-{primary_id}.json')
if lifecycle.exists() and not lifecycle.is_symlink():
lifecycle.unlink()
for member in members:
directory = instances.location(root, member).parent
if directory.is_dir() and not directory.is_symlink():
shutil.rmtree(directory)
msg_ok(translate('Saved record removed'))
for path, size in image_cache.prune(root, lock=False):
msg_ok(f"{translate('Unused image removed from the cache:')} {path.name}")
for path in kept:
msg_warn(f"{translate('Host directory kept, with its content:')} {path}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int)
parser.add_argument('--root', type=Path, default=instances.ROOT)
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
try:
with instances.locked(args.root):
remove(args.root, args.vmid)
except BlockingIOError:
msg_error(translate('Another OCI operation is using the instance registry'))
return 1
except (OSError, ValueError, KeyError, RuntimeError, subprocess.CalledProcessError) as error:
msg_error(str(error) or type(error).__name__)
return 1
msg_ok(translate('The application was removed'))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Resolve the effective OCI process after Compose runtime overrides."""
from __future__ import annotations
import json
import shlex
import sys
import tarfile
from pathlib import Path
from typing import Any
from oci_ui import translate
class RuntimeResolutionError(RuntimeError):
pass
def _member(archive: tarfile.TarFile, name: str) -> tarfile.TarInfo:
for item in archive.getmembers():
if item.name.lstrip("./") == name:
return item
raise RuntimeResolutionError(f"{translate('Not found in the OCI archive:')} {name}")
def _json_member(archive: tarfile.TarFile, name: str) -> dict[str, Any]:
source = archive.extractfile(_member(archive, name))
if source is None:
raise RuntimeResolutionError(f"{translate('Cannot read')} {name}")
value = json.load(source)
if not isinstance(value, dict):
raise RuntimeResolutionError(f"{translate('Not a JSON object:')} {name}")
return value
def _blob_name(digest: str) -> str:
algorithm, separator, value = digest.partition(":")
if separator != ":" or algorithm != "sha256" or len(value) != 64:
raise RuntimeResolutionError(f"{translate('Unsupported OCI digest:')} {digest}")
return f"blobs/sha256/{value}"
def image_entrypoint(archive_path: Path) -> list[str]:
with tarfile.open(archive_path, mode="r:*") as archive:
index = _json_member(archive, "index.json")
manifests = index.get("manifests") or []
if len(manifests) != 1:
raise RuntimeResolutionError(translate("The OCI archive does not contain exactly one manifest"))
manifest = _json_member(archive, _blob_name(str(manifests[0]["digest"])))
config = _json_member(archive, _blob_name(str(manifest["config"]["digest"])))
entrypoint = (config.get("config") or {}).get("Entrypoint") or []
if isinstance(entrypoint, str):
return [entrypoint]
if not isinstance(entrypoint, list) or not all(isinstance(item, str) for item in entrypoint):
raise RuntimeResolutionError(translate("Invalid OCI Entrypoint"))
return entrypoint
def _arguments(value: Any, label: str) -> list[str]:
if isinstance(value, str):
return shlex.split(value)
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return value
raise RuntimeResolutionError(f"{translate('The Compose value must be text or a list:')} {label}")
def effective_entrypoint(
archive_path: Path,
command: Any,
compose_entrypoint: Any = None,
) -> str:
entrypoint = (
image_entrypoint(archive_path)
if compose_entrypoint is None
else _arguments(compose_entrypoint, "entrypoint")
)
if command is None:
arguments: list[str] = []
elif isinstance(command, str):
arguments = shlex.split(command)
elif isinstance(command, list) and all(isinstance(item, str) for item in command):
arguments = command
else:
raise RuntimeResolutionError(f"{translate('The Compose value must be text or a list:')} command")
process = entrypoint + arguments
if not process:
raise RuntimeResolutionError(translate("The Entrypoint/Cmd combination is empty"))
return " ".join(shlex.quote(item) for item in process)
def main() -> int:
if len(sys.argv) not in (3, 4):
print(
f"{translate('Usage:')} {sys.argv[0]} OCI_ARCHIVE COMMAND_JSON [ENTRYPOINT_JSON]",
file=sys.stderr,
)
return 2
try:
command = json.loads(sys.argv[2])
compose_entrypoint = json.loads(sys.argv[3]) if len(sys.argv) == 4 else None
print(effective_entrypoint(Path(sys.argv[1]), command, compose_entrypoint))
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError, RuntimeResolutionError) as exc:
print(f"{translate('Cannot apply the Compose command:')} {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+115
View File
@@ -0,0 +1,115 @@
"""Validate declared volatile mounts and native network sysctl includes."""
import os
from pathlib import Path
import re
import stat
import tempfile
from oci_ui import translate
def sysctl_content(deployment):
result = []
seen = set()
for item in deployment.get('security', {}).get('sysctls', []):
name, value = item['name'], str(item['value'])
if (not re.fullmatch(r'net\.(ipv4|ipv6)\.[A-Za-z0-9_.-]+', name)
or name in seen or not value or any(ord(c) < 32 or ord(c) == 127 for c in value)):
raise ValueError(translate('Invalid or duplicated network sysctl'))
seen.add(name)
result.append(f'lxc.sysctl.{name} = {value}\n')
return ''.join(result)
def tmpfs_lines(deployment):
result = []
targets = set()
persistent = [m['container_path'].rstrip('/') for m in deployment.get('mounts', [])]
for item in deployment.get('tmpfs_mounts', []):
target, size = item['container_path'], item['size_mb']
if (not re.fullmatch(r'/[A-Za-z0-9_./-]+', target) or '..' in target.split('/')
or '//' in target or target.endswith('/') or target in ('/etc','/usr','/bin','/lib','/lib64','/sbin','/proc','/sys','/dev','/run')
or not (target.startswith(('/run/', '/tmp/', '/var/cache/')) or target == '/dev/shm')
or isinstance(size, bool) or not isinstance(size, int) or size < 1):
raise ValueError(translate('The tmpfs path or size is outside the supported profile'))
if any(target == p or target.startswith(p+'/') or p.startswith(target+'/') for p in [*persistent,*targets]):
raise ValueError(translate('A tmpfs mount overlaps another mount'))
options = item.get('mount_options', [])
if not options or any(not re.fullmatch(r'rw|ro|nosuid|nodev|noexec|mode=0[0-7]{3}', opt) for opt in options):
raise ValueError(translate('Unsupported tmpfs options'))
if len(options) != len(set(options)) or ('rw' in options and 'ro' in options):
raise ValueError(translate('Contradictory tmpfs options'))
targets.add(target)
result.append(f'tmpfs {target.lstrip("/")} tmpfs {",".join(options)},size={size}M,create=dir 0 0')
return result
def include_path(vmid):
return Path(f'/etc/pve/lxc/{int(vmid)}.proxmenux-sysctls')
def check(config, deployment, vmid):
expected = tmpfs_lines(deployment)
lines = config.decode().splitlines()
actual = [line.split(': ',1)[1] for line in lines if line.startswith('lxc.mount.entry: tmpfs ')]
if sorted(actual) != sorted(expected):
raise ValueError(translate('The tmpfs mounts of the container differ from the saved record'))
includes = [line.split(': ',1)[1] for line in lines if line.startswith('lxc.include: ')]
content = sysctl_content(deployment)
if includes != ([str(include_path(vmid))] if content else []):
raise ValueError(translate('The sysctl include is unknown or differs from the saved record'))
if content:
path = include_path(vmid)
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise ValueError(translate('The sysctl include is not a safe host file'))
if path.read_text() != content:
raise ValueError(translate('The sysctl content was modified outside the saved record'))
def filter_config(config, deployment):
declared = set(tmpfs_lines(deployment))
return b''.join(line for line in config.splitlines(keepends=True)
if not line.startswith(b'lxc.include: ') and not (
line.startswith(b'lxc.mount.entry: ')
and line.decode().strip().split(': ',1)[1] in declared))
def check_recovery(config, state):
plans = [state.get(key, {}).get('deployment', {}) for key in ('record', 'candidate_contract')]
permitted = {line for plan in plans for line in tmpfs_lines(plan)}
for line in config.decode().splitlines():
if line.startswith('lxc.mount.entry: tmpfs ') and line.split(': ', 1)[1] not in permitted:
raise ValueError(translate('A tmpfs mount is not part of the journal; recovery blocked'))
if line.startswith('lxc.include: '):
path = include_path(state['vmid'])
if line.split(': ', 1)[1] != str(path):
raise ValueError(translate('An include is not part of the journal; recovery blocked'))
contents = {sysctl_content(plan) for plan in plans} - {''}
info = path.lstat()
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022
or path.read_text() not in contents):
raise ValueError(translate('An include was modified outside the journal; recovery blocked'))
def restore(deployment, vmid):
content = sysctl_content(deployment)
if not content:
return
path = include_path(vmid)
if path.is_symlink():
raise ValueError(translate('The sysctl include is not restored over a symbolic link'))
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix='.oci-sysctl-')
try:
with os.fdopen(fd, 'w') as output:
output.write(content)
output.flush()
os.fsync(output.fileno())
# pmxcfs uses fixed permissions; ordinary filesystem fixtures still
# receive an explicit restrictive mode.
if path.parent != Path('/etc/pve/lxc'):
os.chmod(temporary, 0o640)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
+720
View File
@@ -0,0 +1,720 @@
#!/usr/bin/env python3
"""Native, coordinated updates of portable generic OCI stacks on the local node."""
import argparse
import copy
import json
import os
from pathlib import Path
import socket
import stat
import subprocess
import time
import uuid
import oci_image_cache as image_cache
import oci_instances as instances
import oci_instance_transaction as member_tx
import oci_stack_plan
import oci_stack_transaction as stack_tx
import oci_stack_replay as replay
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
from oci_update_current import resolve_archive
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
def validate_database_transition(previous, candidate):
def major(image):
values = image.get('defaults', {}).get('Env') or []
return next((value.split('=', 1)[1] for value in values
if isinstance(value, str) and value.startswith('PG_MAJOR=')), None)
old, new = major(previous), major(candidate)
if old is not None and old != new:
raise ValueError(translate('The new image changes the PostgreSQL major version; the data must be migrated before updating'))
def nextcloud_plan(primary, records, inventory, lifecycle):
"""Translate only the known three-member stack and retain rollback contracts."""
intent = primary.get('native_stack_intent', {})
if intent.get('adapter', {}).get('name') != 'install_nextcloud_stack.sh':
raise ValueError(f"{translate('Unrecognized stack adapter:')} Nextcloud")
translated = {vmid: replay.nextcloud_record(record) for vmid, record in records.items()}
roles = {record['deployment']['replay_profile']['role']: vmid
for vmid, record in translated.items()}
if len(translated) != 3 or set(roles) != {'application', 'cache', 'database'} or roles['application'] != primary['vmid']:
raise ValueError(f"{translate('Unrecognized stack structure:')} Nextcloud")
dependencies = lifecycle.get('dependencies', [])
if (lifecycle.get('schema') != 1
or [d.get('vmid') for d in dependencies] != [roles['database'], roles['cache']]):
raise ValueError(f"{translate('Unrecognized dependency order of the stack:')} Nextcloud")
services = [{'vmid': d['vmid'], 'name': d['label'], 'healthcheck': copy.deepcopy(d['healthcheck'])}
for d in dependencies]
services.append({'vmid': primary['vmid'], 'name': 'Nextcloud', 'healthcheck': {
'type': 'exec', 'timeout_seconds': 600, 'argv': ['php', '-r',
'$s=json_decode(file_get_contents("http://127.0.0.1/status.php"),true);'
'exit(is_array($s)&&!empty($s["installed"])&&empty($s["maintenance"])'
'&&empty($s["needsDbUpgrade"])?0:1);']}})
parent = translated[primary['vmid']]
parent['stack']['deployment']['services'] = services
parent['stack']['members'] = []
for service in services:
snapshot = copy.deepcopy(translated[service['vmid']])
snapshot.pop('stack', None)
parent['stack']['members'].append(snapshot)
plan = oci_stack_plan.build(parent, translated, inventory, 'update')
plan['original_members'] = copy.deepcopy(list(records.values()))
plan['nextcloud_replay'] = True
return plan
def paperless_plan(primary, records, inventory, lifecycle):
"""Prepare the known Paperless stack without publishing translated recipes."""
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_paperless_stack.sh':
raise ValueError(f"{translate('Unrecognized stack adapter:')} Paperless")
translated = {vmid: replay.paperless_record(record) for vmid, record in records.items()}
roles = {r['deployment']['replay_profile']['role']: vmid for vmid, r in translated.items()}
if len(translated) != 3 or set(roles) != {'application', 'database', 'broker'} or roles['application'] != primary['vmid']:
raise ValueError(f"{translate('Unrecognized stack structure:')} Paperless")
dependencies = lifecycle.get('dependencies', [])
if lifecycle.get('schema') != 1 or [d.get('vmid') for d in dependencies] != [roles['database'], roles['broker']]:
raise ValueError(f"{translate('Unrecognized dependency order of the stack:')} Paperless")
services = [{'vmid': d['vmid'], 'name': d['label'], 'healthcheck': copy.deepcopy(d['healthcheck'])}
for d in dependencies]
services.append({'vmid': primary['vmid'], 'name': 'Paperless', 'healthcheck': {
'type': 'exec', 'timeout_seconds': 600, 'argv': ['python3', '-c',
'import urllib.request; urllib.request.urlopen("http://127.0.0.1:8000/", timeout=10).read(1)']}})
parent = translated[primary['vmid']]
parent['stack']['deployment']['services'] = services
parent['stack']['members'] = []
for service in services:
snapshot = copy.deepcopy(translated[service['vmid']])
snapshot.pop('stack', None)
parent['stack']['members'].append(snapshot)
plan = oci_stack_plan.build(parent, translated, inventory, 'update')
plan['original_members'] = copy.deepcopy(list(records.values()))
plan['paperless_replay'] = True
return plan
def tandoor_plan(primary, records, inventory, lifecycle):
"""Prepare exactly the application and PostgreSQL without publishing state."""
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_tandoor_stack.sh':
raise ValueError(f"{translate('Unrecognized stack adapter:')} Tandoor")
translated = {vmid: replay.tandoor_record(record) for vmid, record in records.items()}
roles = {r['deployment']['replay_profile']['role']: vmid for vmid, r in translated.items()}
if len(translated) != 2 or set(roles) != {'application', 'database'} or roles['application'] != primary['vmid']:
raise ValueError(f"{translate('Unrecognized stack structure:')} Tandoor")
dependencies = lifecycle.get('dependencies', [])
if lifecycle.get('schema') != 1 or [d.get('vmid') for d in dependencies] != [roles['database']]:
raise ValueError(f"{translate('Unrecognized dependency order of the stack:')} Tandoor")
services = [{'vmid': d['vmid'], 'name': d['label'], 'healthcheck': copy.deepcopy(d['healthcheck'])}
for d in dependencies]
services.append({'vmid': primary['vmid'], 'name': 'Tandoor', 'healthcheck': {
'type': 'exec', 'timeout_seconds': 600, 'argv': ['python3', '-c',
'import urllib.request; urllib.request.urlopen("http://127.0.0.1/", timeout=10).read(1)']}})
parent = translated[primary['vmid']]
parent['stack']['deployment']['services'] = services
parent['stack']['members'] = []
for service in services:
snapshot = copy.deepcopy(translated[service['vmid']])
snapshot.pop('stack', None)
parent['stack']['members'].append(snapshot)
plan = oci_stack_plan.build(parent, translated, inventory, 'update')
plan['original_members'] = copy.deepcopy(list(records.values()))
plan['tandoor_replay'] = True
return plan
def immich_plan(primary, records, inventory, lifecycle):
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_immich_stack.sh':
raise ValueError(f"{translate('Unrecognized stack adapter:')} Immich")
translated = {vmid: replay.immich_record(record) for vmid, record in records.items()}
roles = {r['deployment']['replay_profile']['role']: vmid for vmid, r in translated.items()}
if len(translated) != 4 or set(roles) != {'server', 'database', 'valkey', 'machine-learning'} or roles['server'] != primary['vmid']:
raise ValueError(f"{translate('Unrecognized stack structure:')} Immich")
dependencies = lifecycle.get('dependencies', [])
if lifecycle.get('schema') != 1 or [d.get('vmid') for d in dependencies] != [roles['database'], roles['valkey'], roles['machine-learning']]:
raise ValueError(f"{translate('Unrecognized dependency order of the stack:')} Immich")
services = [{'vmid': d['vmid'], 'name': d['label'], 'healthcheck': copy.deepcopy(d['healthcheck'])} for d in dependencies]
services.append({'vmid': primary['vmid'], 'name': 'Immich', 'healthcheck': {
'type': 'exec', 'timeout_seconds': 600, 'argv': ['node', '-e',
'fetch("http://127.0.0.1:2283/api/server/ping").then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))']}})
parent = translated[primary['vmid']]
parent['stack']['deployment']['services'] = services
parent['stack']['members'] = []
for service in services:
snapshot = copy.deepcopy(translated[service['vmid']])
snapshot.pop('stack', None)
parent['stack']['members'].append(snapshot)
plan = oci_stack_plan.build(parent, translated, inventory, 'update')
plan['original_members'] = copy.deepcopy(list(records.values()))
plan['immich_replay'] = True
return plan
class NativeAdapter:
def __init__(self, root, journal, plan, acknowledge_external_data=False):
self.root, self.journal, self.plan = root, Path(journal), plan
self.records = {m['vmid']: copy.deepcopy(m) for m in plan['members']}
self.original_records = {m['vmid']: copy.deepcopy(m)
for m in plan.get('original_members', plan['members'])}
primary = self.records[plan['primary_vmid']]
self.services = {s['vmid']: s for s in primary['stack']['deployment']['services']}
self.acknowledge = acknowledge_external_data
def state(self):
return json.loads(self.journal.read_text())
def describe(self, vmid):
name = self.services.get(vmid, {}).get('name')
return f'{name} (CT {vmid})' if name else f'CT {vmid}'
def validate(self, plan):
resources = json.loads(instances.command('pvesh', 'get', '/cluster/resources',
'--type', 'vm', '--output-format', 'json'))
if not isinstance(resources, list):
raise ValueError(translate('Incomplete Proxmox inventory'))
inventory = {}
for row in resources:
if (not isinstance(row, dict) or type(row.get('vmid')) is not int
or row.get('type') not in ('lxc', 'qemu')):
raise ValueError(translate('Invalid Proxmox inventory'))
if row['vmid'] in inventory:
raise ValueError(translate('Duplicated VMID in the Proxmox inventory'))
inventory[row['vmid']] = row
for vmid, record in self.records.items():
row = inventory.get(vmid)
if row:
if row['type'] != 'lxc' or row.get('node') != socket.gethostname().split('.')[0]:
raise ValueError(translate('A member VMID is in use by another guest or is on another node'))
config = instances.command('pct', 'config', str(vmid))
if instances.identity(config) != record['installation_id']:
raise ValueError(translate('The identity of a member was replaced'))
if (not self.journal.exists() or not self.state().get('replacement_intent')) and sha(config) != record['observed']['config_sha256']:
raise ValueError(translate('A member configuration changed during the preparation'))
else:
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
raise ValueError(translate('The Proxmox inventory and the local configurations differ'))
if not self.journal.exists() or not self.state().get('replacement_intent'):
raise ValueError(translate('A member is missing before the replacement'))
current = instances.read(self.root, vmid)
if current['installation_id'] != record['installation_id']:
raise ValueError(translate('The record belongs to another container'))
pending = current.get('pending_stack_transaction')
if pending and pending != str(self.journal):
raise ValueError(translate('Another stack operation is pending'))
def preflight(self):
self.validate(self.plan)
ha = json.loads(instances.command('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json'))
if not isinstance(ha, list) or any(r.get('sid') == 'ct:%s' % vmid for r in ha for vmid in self.records):
raise ValueError(translate('High availability resources are not supported for stacks'))
for vmid, record in self.records.items():
if record.get('pending_transaction') or record.get('pending_stack_transaction'):
raise ValueError(translate('A member has a pending operation'))
if record['deployment'].get('post_start_configurations'):
raise ValueError(translate('The recipe requires configuration at startup; its coordinated replay is not available'))
config = instances.command('pct', 'config', str(vmid))
member_tx.preflight(record, record, config, coordinated=True)
member_tx.freeze_host_sources(record, record, self.acknowledge)
check = self.services[vmid].get('healthcheck', {})
if check.get('type') not in ('exec', 'http', 'running'):
raise ValueError(translate('A member has no reproducible service check'))
if check['type'] == 'exec' and not check.get('argv'):
raise ValueError(translate('Empty exec service check'))
if check['type'] == 'exec' and (not isinstance(check['argv'], list)
or any(not isinstance(arg, str) or not arg or '\0' in arg for arg in check['argv'])):
raise ValueError(translate('Invalid service check arguments'))
if check['type'] == 'http' and not check.get('url'):
raise ValueError(translate('HTTP service check without a saved URL'))
if check['type'] == 'http' and not check['url'].startswith(('http://', 'https://')):
raise ValueError(translate('Invalid service check URL'))
if not 0 < int(check.get('timeout_seconds', 120)) <= 3600:
raise ValueError(translate('Invalid service check timeout'))
primary_id = self.plan['primary_vmid']
cfg = parse_config(instances.command('pct', 'config', str(primary_id)))
volume = cfg.get('hookscript', '')
if not volume.endswith(':snippets/proxmenux-stack-dependencies.sh'):
raise ValueError(translate('The stack does not have the expected native hook'))
hook = Path(instances.command('pvesm', 'path', volume).decode().strip())
info = hook.lstat()
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022
or hook.read_bytes() != Path(__file__).with_name('stack_dependency_hook.sh').read_bytes()):
raise ValueError(translate('The dependency hook was modified; review it before updating'))
lifecycle = Path('/etc/pve/priv/proxmenux-stack-%s.json' % primary_id)
info = lifecycle.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
raise ValueError(translate('Unsafe dependency hook contract'))
spec = json.loads(lifecycle.read_text())
expected = [{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': s['healthcheck']}
for s in self.services.values() if s['vmid'] != primary_id and not s.get('deferred_setup')]
if spec.get('schema') != 1 or spec.get('dependencies') != expected:
raise ValueError(translate('The dependency hook and the stack recipe differ'))
member_tx.require_backup_space(self.journal.parent, list(self.records))
def is_running(self, vmid):
return instances.command('pct', 'status', str(vmid)).strip() == b'status: running'
def prepare(self, record, operation):
for vmid in self.records:
current = instances.read(self.root, vmid)
current['pending_stack_transaction'] = str(self.journal)
instances.write(instances.location(self.root, vmid), current)
config = instances.command('pct', 'config', str(record['vmid']))
archive, digest = resolve_archive(record, config)
image = image_from_archive(str(archive))
old = record['observed']['image']
validate_database_transition(old, image)
if image['architecture'] != old['architecture'] or image['os'] != 'linux':
raise ValueError(translate('Incompatible image platform'))
paths = [m['container_path'] for m in record['deployment'].get('mounts', [])]
if any(not any(p == target or p.startswith(target.rstrip('/') + '/') for target in paths)
for p in (image['defaults'].get('Volumes') or {})):
raise ValueError(translate('The new image requires additional persistent paths'))
profile = record['deployment'].get('replay_profile', {})
if profile.get('adapter') in ('install_nextcloud_stack.sh', 'install_paperless_stack.sh', 'install_tandoor_stack.sh', 'install_immich_stack.sh'):
msg_info(f"{translate('Checking the new image without starting it:')} {self.describe(record['vmid'])}")
self.probe_nextcloud_image(record, archive, image)
msg_ok(f"{translate('New image compatible:')} {self.describe(record['vmid'])}")
return {'archive': str(archive), 'digest': digest}
def probe_nextcloud_image(self, record, archive, image):
"""Import but never start a disposable rootfs before stopping the stack."""
vmid = int(instances.command('pvesh', 'get', '/cluster/nextid').strip())
marker = 'proxmenux-image-probe=' + uuid.uuid4().hex
directory = self.journal.parent / 'image-probes'
private_directory(directory)
descriptor = directory / ('%s.json' % vmid)
instances.write(descriptor, {'vmid': vmid, 'marker': marker})
mounted = False
try:
member_tx.log('image probe: CT %s' % record['vmid'])
root = record['deployment']['rootfs']
member_tx.run('pct', 'create', str(vmid), str(archive), '--rootfs',
'%s:%s' % (root['storage'], root['size_gb']), '--hostname', 'oci-image-probe',
'--ostype', 'unmanaged', '--unprivileged', '1', '--memory', '128',
'--cores', '1', '--onboot', '0', '--description', marker)
member_tx.owned(vmid, marker)
member_tx.run('pct', 'mount', str(vmid))
mounted = True
profile = record['deployment']['replay_profile']
check = {'install_paperless_stack.sh': replay.paperless_prerequisites,
'install_nextcloud_stack.sh': replay.nextcloud_prerequisites,
'install_tandoor_stack.sh': replay.tandoor_prerequisites,
'install_immich_stack.sh': replay.immich_prerequisites}[profile['adapter']]
check(Path('/var/lib/lxc') / str(vmid) / 'rootfs', profile['role'], image)
finally:
if mounted:
member_tx.run('pct', 'unmount', str(vmid))
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
member_tx.owned(vmid, marker)
member_tx.run('pct', 'destroy', str(vmid))
descriptor.unlink()
def stop(self, vmid):
self.validate(self.plan)
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
member_tx.stop(vmid)
def backup(self, vmid, identity):
self.validate(self.plan)
directory = self.journal.parent / ('backup-%s' % vmid)
private_directory(directory)
member_tx.run('vzdump', str(vmid), '--mode', 'stop', '--compress', 'zstd',
'--dumpdir', str(directory), '--tmpdir', '/var/tmp')
backups = list(directory.glob('vzdump-lxc-*.tar.zst'))
if len(backups) != 1:
raise ValueError(translate('The backup of a member could not be identified'))
member_tx.run('zstd', '-t', str(backups[0]))
return {'archive': str(backups[0]), 'sha256': member_tx.filehash(backups[0])}
def verify_backups(self, backups):
for backup in backups.values():
if member_tx.filehash(backup['archive']) != backup['sha256']:
raise ValueError(translate('A backup was modified'))
member_tx.run('zstd', '-t', backup['archive'])
def replace(self, vmid, prepared, identity):
self.validate(self.plan)
state = self.state()
context = {'journal': str(self.journal), 'id': identity,
'backup': state['backups'][str(vmid)]}
if self.plan.get('nextcloud_replay'):
context.update(nextcloud_replay=True, effective_record=self.records[vmid])
if self.plan.get('paperless_replay'):
context.update(paperless_replay=True, effective_record=self.records[vmid])
if self.plan.get('tandoor_replay'):
context.update(tandoor_replay=True, effective_record=self.records[vmid])
if self.plan.get('immich_replay'):
context.update(immich_replay=True, effective_record=self.records[vmid])
member_tx.apply(self.root, vmid, Path(prepared['archive']), 'update',
registry_digest=prepared['digest'], acknowledge_external_data=self.acknowledge,
coordinated=context, progress=f"{translate('Updating')} {self.describe(vmid)}:")
def start(self, vmid):
self.validate(self.plan)
if not self.is_running(vmid):
member_tx.run('pct', 'start', str(vmid))
def healthcheck(self, vmid):
check = self.services[vmid]['healthcheck']
timeout = int(check.get('timeout_seconds', 120))
if not 0 < timeout <= 3600:
raise ValueError(translate('Invalid service check timeout'))
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not self.is_running(vmid):
raise ValueError(f"{translate('A member stopped:')} {self.describe(vmid)}")
try:
if check['type'] == 'exec':
member_tx.run('pct', 'exec', str(vmid), '--', *check['argv'])
elif check['type'] == 'http':
member_tx.run('curl', '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
member_tx.gpu_devices.validate_runtime(vmid, self.records[vmid]['deployment'])
deployment = self.records[vmid]['deployment']
if deployment.get('replay_profile') == {'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}:
acceleration = deployment.get('machine_learning', {}).get('acceleration', 'cpu')
if acceleration in ('openvino', 'cuda'):
member_tx.run('pct', 'exec', str(vmid), '--', 'python', '-c',
'import sys,ctypes,onnxruntime as ort; p=sys.argv[1]; '
'assert ("OpenVINOExecutionProvider" if p=="openvino" else "CUDAExecutionProvider") '
'in ort.get_available_providers(); '
'assert (any(d.startswith("GPU") for d in ort.capi._pybind_state.get_available_openvino_device_ids()) '
'if p=="openvino" else ctypes.CDLL("libcuda.so.1").cuInit(0)==0)', acceleration)
return
except RuntimeError:
time.sleep(2)
raise ValueError(f"{translate('A member did not pass its service check:')} {self.describe(vmid)}")
def validate_candidates(self, state):
self.validate(self.plan)
for vmid, original in self.records.items():
current = instances.read(self.root, vmid)
journal = Path(current['pending_transaction'])
child = json.loads(journal.read_text())
if child.get('coordinated', {}).get('id') != state['id']:
raise ValueError(translate('A member operation does not belong to the stack'))
config = instances.command('pct', 'config', str(vmid))
if sha(config) != child.get('staged_config_sha256'):
raise ValueError(translate('The configuration of a new member changed after it was created'))
member_tx.check_runtime_mounts(config, original['deployment'])
member_tx.gpu_devices.check(config, original['deployment'])
child['candidate_host_sources'] = member_tx.candidate_host_sources(journal, child)
child['validated_config_sha256'] = sha(config)
instances.write(journal, child)
def restore_running_state(self, running, order):
for vmid in order:
if running[str(vmid)]:
self.start(vmid)
for vmid in order:
if running[str(vmid)]:
self.healthcheck(vmid)
# Starting the primary can start dependencies through its native hook.
for vmid in reversed(order):
if not running[str(vmid)]:
self.stop(vmid)
if not self.state()['replacement_intent']:
self.restore_contracts(self.plan, self.state()['id'])
def publish(self, state):
for vmid, original in self.records.items():
current = instances.read(self.root, vmid)
journal = Path(current['pending_transaction'])
child = json.loads(journal.read_text())
before = member_tx.owned(vmid, original['installation_id'])
if sha(before) != child.get('validated_config_sha256'):
raise ValueError(translate('A member configuration changed after the stack was checked'))
# Keep child journals available even if publication is interrupted.
links = self.journal.parent / ('member-%s.json' % vmid)
instances.write(links, {'journal': str(journal)})
member_tx.run('pct', 'set', str(vmid), '--onboot', '1' if original['deployment']['onboot'] else '0')
after = member_tx.owned(vmid, original['installation_id'])
if ([line for line in before.splitlines() if not line.startswith(b'onboot: ')]
!= [line for line in after.splitlines() if not line.startswith(b'onboot: ')]):
raise ValueError(translate('Concurrent change while restoring the start at boot setting'))
child['validated_config_sha256'] = sha(after)
member_tx.checkpoint(journal, child, 'health-passed')
member_tx.commit(self.root, journal, child)
primary_id = self.plan['primary_vmid']
primary = instances.read(self.root, primary_id)
primary['stack']['members'] = []
for vmid in self.plan['start_order']:
record = instances.read(self.root, vmid)
record.pop('stack', None)
record.pop('pending_stack_transaction', None)
primary['stack']['members'].append(record)
instances.write(instances.location(self.root, primary_id), primary)
def restore(self, vmid, backup, identity):
self.validate(self.plan)
original = self.records[vmid]
current = instances.read(self.root, vmid)
link = self.journal.parent / ('member-%s.json' % vmid)
child_path = current.get('pending_transaction')
if not child_path and link.exists():
child_path = json.loads(link.read_text())['journal']
if child_path:
journal = Path(child_path)
synthetic = self.journal.parent / ('recovery-%s' % vmid) / 'transaction.json'
if (not journal.resolve().is_relative_to(instances.location(self.root, vmid).parent.resolve())
and journal.resolve() != synthetic.resolve()):
raise ValueError(translate('The member journal is outside the registry'))
child = json.loads(journal.read_text())
if child.get('coordinated', {}).get('id') != identity:
raise ValueError(translate('The member journal belongs to another stack operation'))
if child['phase'] == 'rolled-back':
if sha(member_tx.owned(vmid, original['installation_id'])) != child['restore_config_sha256']:
raise ValueError(translate('A member was modified after it was recovered'))
member_tx.cleanup_restored_format_dirs(vmid, original['deployment'], original['installation_id'])
return
else:
directory = self.journal.parent / ('recovery-%s' % vmid)
private_directory(directory)
journal = directory / 'transaction.json'
if journal.exists():
child = json.loads(journal.read_text())
else:
sources, _ = member_tx.freeze_host_sources(original, original, self.acknowledge)
child = {'id': uuid.uuid4().hex, 'vmid': vmid, 'record': original,
'candidate_contract': original, 'before_config': original['observed']['config'],
'backup': backup['archive'], 'backup_sha256': backup['sha256'],
'backup_compression': 'zstd', 'was_running': False,
'original_host_sources': sources,
'original_gpu_devices': member_tx.gpu_devices.planned(original['deployment']),
'coordinated': {'id': identity}, 'phase': 'backup-ready'}
instances.write(journal, child)
instances.write(link, {'journal': str(journal)})
if not child.get('stage'):
child['stage'] = int(instances.command('pvesh', 'get', '/cluster/nextid').strip())
instances.write(journal, child)
stage = child['stage']
if not Path('/etc/pve/lxc/%s.conf' % stage).exists():
archive = self.state()['prepared'][str(vmid)]['archive']
member_tx.run('pct', 'create', str(stage), archive, '--rootfs',
'%s:%s' % (original['deployment']['rootfs']['storage'], original['deployment']['rootfs']['size_gb']),
'--hostname', 'oci-stack-recovery-holder', '--ostype', 'unmanaged',
'--unprivileged', '1', '--memory', '128', '--cores', '1', '--onboot', '0',
'--description', 'proxmenux-transaction=' + child['id'])
pending = copy.deepcopy(original)
pending.update(status='updating', pending_transaction=str(journal), transaction_id=child['id'],
pending_stack_transaction=str(self.journal))
instances.write(instances.location(self.root, vmid), pending)
child['record'] = copy.deepcopy(child['record'])
child['record']['pending_stack_transaction'] = str(self.journal)
child.update(backup=backup['archive'], backup_sha256=backup['sha256'], backup_compression='zstd')
child['phase'] = 'backup-ready'
instances.write(journal, child)
member_tx.recover(self.root, journal)
def restore_contracts(self, plan, identity):
self.validate(plan)
for vmid, original in self.original_records.items():
record = copy.deepcopy(original)
config = member_tx.owned(vmid, record['installation_id'])
record['observed'] = instances.observe(vmid, record['installation_id'],
original['observed']['archive_path'], original['observed']['resolved_registry_digest'],
original['observed']['image'])
if any(self.plan.get(flag) for flag in ('nextcloud_replay', 'paperless_replay', 'tandoor_replay', 'immich_replay')):
record['deployment']['native_config'] = record['observed']['config']
record['deployment']['member_replay_projection'] = replay.normalize(record)
record['pending_stack_transaction'] = str(self.journal)
instances.write(instances.location(self.root, vmid), record)
primary_id = plan['primary_vmid']
primary = instances.read(self.root, primary_id)
snapshots = []
for vmid in plan['start_order']:
snapshot = instances.read(self.root, vmid)
snapshot.pop('stack', None)
snapshot.pop('pending_stack_transaction', None)
snapshots.append(snapshot)
primary['stack']['members'] = snapshots
instances.write(instances.location(self.root, primary_id), primary)
def finalize(self, state):
if state['phase'] not in stack_tx.TERMINAL:
raise ValueError(translate('The stack operation has not finished yet'))
self.validate(self.plan)
probes = self.journal.parent / 'image-probes'
if probes.exists():
for descriptor in probes.glob('*.json'):
probe = json.loads(descriptor.read_text())
vmid, marker = probe['vmid'], probe['marker']
if type(vmid) is not int or not marker.startswith('proxmenux-image-probe='):
raise ValueError(translate('Invalid image probe descriptor'))
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
member_tx.owned(vmid, marker)
if self.is_running(vmid):
raise ValueError(translate('An image probe container was started externally'))
if os.path.ismount('/var/lib/lxc/%s/rootfs' % vmid):
member_tx.run('pct', 'unmount', str(vmid))
member_tx.run('pct', 'destroy', str(vmid))
descriptor.unlink()
# Clear the primary last so interrupted cleanup remains discoverable.
order = [vmid for vmid in self.records if vmid != self.plan['primary_vmid']]
order.append(self.plan['primary_vmid'])
for vmid in order:
record = instances.read(self.root, vmid)
record.pop('pending_stack_transaction', None)
instances.write(instances.location(self.root, vmid), record)
try:
self.release_stages()
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}')
except (OSError, ValueError) as exc:
member_tx.log(f'cleanup: {exc}')
def release_stages(self):
"""The temporary containers that held the data of each member; one
that still has a disk attached is kept."""
for vmid in self.records:
folder = instances.location(self.root, vmid).parent / 'transactions'
for member_journal in folder.glob('*/transaction.json'):
try:
state = json.loads(member_journal.read_text())
except (OSError, ValueError):
continue
if (state.get('coordinated') or {}).get('journal') == str(self.journal):
member_tx.release_stage(state)
def prune_backups(self, include_current):
"""The backups of closed operations are removed, those of this one
when the stack works with its new images; journals and logs stay."""
for directory in self.journal.parent.parent.iterdir():
if ((directory == self.journal.parent and not include_current)
or directory.is_symlink() or not directory.is_dir()):
continue
try:
phase = json.loads((directory / 'transaction.json').read_text()).get('phase')
except (OSError, ValueError):
continue
if phase in stack_tx.TERMINAL:
for backup in directory.glob('backup-*/vzdump-lxc-*'):
if backup.is_file() and not backup.is_symlink():
backup.unlink()
# The stack journal of this run, for the summary after a failure.
_current = {'journal': None, 'primary': None}
def run(vmid, recover=False, acknowledge_external_data=False):
root = instances.ROOT
msg_info(translate('Checking the interrupted stack operation...') if recover
else translate('Checking the stack before the update...'))
with instances.locked(root):
selected = instances.read(root, vmid)
primary_id = selected.get('stack_member', {}).get('primary_vmid', vmid)
primary = instances.read(root, primary_id)
_current['primary'] = primary_id
if recover:
journal = Path(primary['pending_stack_transaction'])
if not journal.resolve().is_relative_to(instances.location(root, primary_id).parent.resolve()):
raise ValueError(translate('The stack journal is outside the registry'))
member_tx.open_log(journal.parent)
_current['journal'] = journal
state = json.loads(journal.read_text())
if state.get('plan', {}).get('primary_vmid') != primary_id:
raise ValueError(translate('The journal belongs to another stack'))
if any(mount['type'] == 'host-bind' for member in state['plan']['members']
for mount in member.get('deployment', {}).get('mounts', [])) and not acknowledge_external_data:
raise ValueError(translate('Confirm that host data is not reverted'))
adapter = NativeAdapter(root, journal, state['plan'], acknowledge_external_data)
if state.get('phase') in stack_tx.TERMINAL:
msg_ok(translate('The stack operation had already finished'))
msg_info(translate('Completing its final cleanup...'))
result = stack_tx.execute(journal, adapter)
msg_ok(translate('Final cleanup of the stack operation completed'))
return result
msg_ok(translate('Interrupted stack operation found'))
result = stack_tx.execute(journal, adapter)
msg_ok(translate('Stack recovery completed; every member is back to its previous installation.'))
return result
records, inventory = {}, {}
for snapshot in primary['stack']['members']:
member_id = snapshot['vmid']
records[member_id] = instances.read(root, member_id)
inventory[member_id] = instances.identity(instances.command('pct', 'config', str(member_id)))
adapter_name = primary.get('native_stack_intent', {}).get('adapter', {}).get('name')
builders = {'install_nextcloud_stack.sh': nextcloud_plan,
'install_paperless_stack.sh': paperless_plan,
'install_tandoor_stack.sh': tandoor_plan,
'install_immich_stack.sh': immich_plan}
if adapter_name in builders:
lifecycle = Path('/etc/pve/priv/proxmenux-stack-%s.json' % primary_id)
info = lifecycle.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
raise ValueError(translate('Unsafe dependency contract'))
builder = builders[adapter_name]
plan = builder(primary, records, inventory, json.loads(lifecycle.read_text()))
else:
plan = oci_stack_plan.build(primary, records, inventory, 'update')
stack_tx.validate_plan(plan)
directory = instances.location(root, primary_id).parent / 'stack-transactions' / uuid.uuid4().hex
private_directory(directory)
member_tx.open_log(directory)
journal = directory / 'transaction.json'
_current['journal'] = journal
adapter = NativeAdapter(root, journal, plan, acknowledge_external_data)
adapter.preflight()
msg_ok(f"{translate('Stack checked:')} {len(plan['members'])} {translate('containers')}")
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.'))
result = stack_tx.execute(journal, adapter, plan)
msg_ok(translate('Stack update completed. Data kept.'))
return result
def failure_summary(recovering):
"""What state the stack was left in after a failure, and what to do next."""
journal = _current['journal']
if journal is None or not journal.exists():
return
try:
state = json.loads(journal.read_text())
except (OSError, ValueError):
state = {}
phase = state.get('phase')
try:
pending = instances.read(instances.ROOT, _current['primary']).get('pending_stack_transaction') == str(journal)
except (OSError, ValueError, KeyError):
pending = True
if phase == 'rolled-back':
if recovering or state.get('stop_intent'):
msg_warn(translate('Every member of the stack is back to its previous installation.'))
else:
msg_warn(translate('No container of the stack was modified.'))
elif phase == 'committed':
msg_warn(translate('The stack update was saved.'))
else:
msg_warn(translate('The stack operation stopped halfway. Select the stack again in the OCI management menu to recover it.'))
return
if pending:
msg_warn(translate('Its final cleanup did not complete. Select the stack again in the OCI management menu to complete it.'))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int)
parser.add_argument('--recover', action='store_true')
parser.add_argument('--acknowledge-external-data', action='store_true')
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)
return 0
except BlockingIOError:
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))
return 1
except (ValueError, RuntimeError, OSError, KeyError, subprocess.SubprocessError) as error:
# An error that started an automatic recovery was already shown before it.
if not getattr(error, 'oci_reported', False):
member_tx.report_error(error, f'stack-{args.vmid}')
failure_summary(args.recover)
return 1
if __name__ == '__main__':
raise SystemExit(main())
+61
View File
@@ -0,0 +1,61 @@
"""Read-only validation of a coordinated stack operation, before host changes."""
import copy
from oci_ui import translate
def build(primary, records, inventory, operation):
"""Inventory maps VMIDs to installation UUIDs, including unrelated guests.
Missing members are reported, not authorized for creation: their persistent
volumes still require separate verification before any destructive operation.
"""
if operation not in ('update', 'recreate'):
raise ValueError(translate('Invalid stack operation'))
stack = primary.get('stack')
if not stack or not stack.get('members'):
raise ValueError(translate('This is not a coordinated stack'))
snapshots = stack['members']
ids = [member['vmid'] for member in snapshots]
if any(type(vmid) is not int or vmid < 100 for vmid in ids) or len(set(ids)) != len(ids):
raise ValueError(translate('Duplicated or invalid stack VMID'))
if primary['vmid'] not in ids:
raise ValueError(translate('The main member of the stack is missing'))
if stack.get('id') != primary['installation_id']:
raise ValueError(translate('Inconsistent stack identity'))
services = stack.get('deployment', {}).get('services', [])
order = [service['vmid'] for service in services]
if len(order) != len(ids) or set(order) != set(ids):
raise ValueError(translate('Incomplete dependency order'))
members, missing, blockers = [], [], []
for snapshot in snapshots:
vmid = snapshot['vmid']
identity = snapshot['installation_id']
current = records.get(vmid, snapshot)
if current.get('installation_id') != identity:
raise ValueError(f"{translate('The saved record was replaced for')} CT {vmid}")
membership = current.get('stack_member', {})
if membership.get('stack_id') != stack['id'] or membership.get('primary_vmid') != primary['vmid']:
raise ValueError(f"{translate('Inconsistent stack membership for')} CT {vmid}")
if current.get('status') != 'installed':
raise ValueError(f"{translate('A pending operation exists for')} CT {vmid}")
if vmid in inventory:
if inventory[vmid] != identity:
raise ValueError(f"{translate('Another instance uses')} VMID {vmid}")
if vmid not in records:
raise ValueError(f"{translate('The current record is missing for')} CT {vmid}")
else:
missing.append(vmid)
if current.get('deployment', {}).get('rootfs_adaptation_replay_required'):
blockers.append({'vmid': vmid, 'reason': 'dedicated-adapter-replay-required'})
members.append(copy.deepcopy(current))
if missing and operation == 'update':
raise ValueError(translate('Stack members are missing; recreate them after verifying their volumes'))
dependencies = [vmid for vmid in order if vmid != primary['vmid']]
return {
'operation': operation, 'primary_vmid': primary['vmid'],
'members': members, 'missing_members': missing,
'start_order': dependencies + [primary['vmid']],
'stop_order': [primary['vmid']] + list(reversed(dependencies)),
'blockers': blockers, 'volume_verification_required': bool(missing),
}
+571
View File
@@ -0,0 +1,571 @@
"""Capture only declared, generated rootfs adapters; never application data."""
import hashlib
import copy
from pathlib import Path
import re
import stat
import shlex
import os
from oci_ui import translate
def nextcloud_menu_ready(primary):
"""Offer only captured members whose declared persistence is fully covered."""
return captured_menu_ready(primary, 'install_nextcloud_stack.sh', nextcloud_record,
{'application', 'database', 'cache'})
def paperless_menu_ready(primary):
return captured_menu_ready(primary, 'install_paperless_stack.sh', paperless_record,
{'application', 'database', 'broker'})
def tandoor_menu_ready(primary):
return captured_menu_ready(primary, 'install_tandoor_stack.sh', tandoor_record,
{'application', 'database'})
def immich_menu_ready(primary):
try:
return captured_menu_ready(primary, 'install_immich_stack.sh', immich_record,
{'server', 'database', 'valkey', 'machine-learning'})
except (RuntimeError, OSError):
return False
def immich_prerequisites(rootfs, role, image):
required = {
'server': ('/bin/bash', 'tini', 'node', 'start.sh', 'grep', 'seq', 'sleep'),
'database': ('/bin/sh', '/usr/local/bin/immich-docker-entrypoint.sh', 'postgres', 'pg_isready'),
'valkey': ('/bin/sh', 'docker-entrypoint.sh', 'valkey-server', 'valkey-cli'),
'machine-learning': ('/bin/sh', 'env', 'tini', 'python'),
}
# PostgreSQL's official entrypoint generates its configuration at startup.
return adapter_prerequisites(rootfs, role, image, 'install_immich_stack.sh', required)
def immich_record(record):
projection = normalize(record)
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != 'install_immich_stack.sh':
raise ValueError(translate('Unrecognized Immich adapter'))
role = recipe['role']
# PostgreSQL's saved listen address is private, never guessed from the host.
net0 = projection['deployment']['network']['ipv4'].split('/')[0]
expected = {
'server': ['tini', '--', '/usr/local/bin/immich-lxc-start'],
'database': ['/usr/local/bin/immich-docker-entrypoint.sh', 'postgres', '-c',
'config_file=/etc/postgresql/postgresql.conf', '-c',
'listen_addresses=127.0.0.1,' + net0],
'valkey': ['docker-entrypoint.sh', 'valkey-server'],
'machine-learning': ['env', 'LD_PRELOAD=/usr/lib/libmimalloc.so.2', 'tini', '--',
'python', '-m', 'immich_ml'],
}
runtime = projection['runtime']
if shlex.split(runtime.get('entrypoint', '')) != expected[role]:
raise ValueError(translate('The Immich startup was modified or cannot be reproduced'))
acceleration = record['deployment'].get('machine_learning', {}).get('acceleration', 'cpu')
if role == 'machine-learning' and acceleration not in ('cpu', 'openvino', 'cuda'):
raise ValueError(translate('Immich GPU profile not validated'))
cuda = role == 'machine-learning' and acceleration == 'cuda'
devices = [{'kind': 'nvidia-runtime', 'runtime_mode': 'dynamic'}] if cuda else []
for item in projection['native_devices']:
fields = dict(p.split('=', 1) for p in item['value'].split(',') if '=' in p)
path = fields.get('path')
if cuda and path and path.startswith('/dev/nvidia'):
continue
if not path or not re.fullmatch(r'/dev/dri/renderD[0-9]+', path):
raise ValueError(translate('Immich device without a validated translation'))
devices.append({'kind': 'character-device', 'host_path': path, 'container_path': path,
'gid_strategy': 'host-device-gid', 'mode': fields.get('mode', '0660'),
'drm_vendor_ids': ['0x8086'] if role == 'machine-learning' else ['0x8086', '0x1002']})
if projection['preserved_raw_runtime'] and not cuda:
raise ValueError(translate('Immich runtime without a validated translation'))
if cuda:
import oci_accelerators
candidate = {'devices': devices, 'environment': [
{'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': 'compute,utility'}]}
oci_accelerators.check(record['observed']['config'].encode(), candidate)
translated = {'compose_entrypoint': expected[role], 'command': []}
for native, target in (('lxc.init.cwd', 'working_directory'), ('lxc.signal.halt', 'halt_signal')):
if native in runtime:
translated[target] = runtime[native]
result = portable_record(record, {'generated_files': projection['generated_files'], 'runtime': translated})
result['deployment']['devices'] = devices
if cuda:
result['deployment']['environment'] = [e for e in result['deployment']['environment']
if e['name'] != 'NVIDIA_DRIVER_CAPABILITIES']
result['deployment']['environment'].append({'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': 'compute,utility'})
result['deployment']['machine_learning'] = copy.deepcopy(record['deployment'].get('machine_learning', {}))
return result
def captured_menu_ready(primary, adapter, convert, expected_roles):
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != adapter:
return False
members = primary.get('stack', {}).get('members', [])
if len(members) != len(expected_roles):
return False
try:
converted = [convert(member) for member in members]
roles = {r['deployment']['replay_profile']['role'] for r in converted}
if roles != expected_roles:
return False
for member in converted:
targets = [m['container_path'] for m in member['deployment']['mounts']]
declared = member['observed']['image']['defaults'].get('Volumes') or {}
if any(not any(p == target or p.startswith(target.rstrip('/') + '/') for target in targets)
for p in declared):
return False
except (ValueError, KeyError, TypeError):
return False
return True
def image_executable(rootfs, path, executable=True):
"""Resolve container symlinks inside its root, never against the host root."""
root = Path(rootfs)
pending = list(Path(path).parts[1:])
resolved, links = [], 0
while pending:
part = pending.pop(0)
if part in ('', '.'):
continue
if part == '..':
if not resolved:
raise ValueError(translate('Symbolic link outside the rootfs of the new image'))
resolved.pop()
continue
destination = root.joinpath(*resolved, part)
info = destination.lstat()
if stat.S_ISLNK(info.st_mode):
links += 1
if links > 40:
raise ValueError(translate('Symbolic link loop in the new image'))
target = os.readlink(destination)
if target.startswith('/'):
resolved = []
pending = [p for p in target.split('/') if p] + pending
else:
resolved.append(part)
info = root.joinpath(*resolved).stat()
if not stat.S_ISREG(info.st_mode) or not info.st_mode & (0o111 if executable else 0o444):
raise ValueError(translate('The new image does not keep a required executable'))
return '/' + '/'.join(resolved)
def nextcloud_prerequisites(rootfs, role, image):
required = {
'application': ('/bin/sh', '/entrypoint.sh', '/cron.sh', 'apache2-foreground', 'php'),
'database': ('/bin/sh', 'docker-entrypoint.sh', 'postgres', 'pg_isready'),
'cache': ('/bin/sh', 'docker-entrypoint.sh', 'redis-server', 'redis-cli'),
}
return adapter_prerequisites(rootfs, role, image, 'install_nextcloud_stack.sh', required)
def paperless_prerequisites(rootfs, role, image):
required = {
'application': ('/bin/sh', '/init', 'python3'),
'database': ('/bin/sh', 'docker-entrypoint.sh', 'postgres', 'pg_isready'),
'broker': ('/bin/sh', 'tini', 'docker-entrypoint.sh', 'valkey-server', 'valkey-cli'),
}
return adapter_prerequisites(rootfs, role, image, 'install_paperless_stack.sh', required)
def tandoor_prerequisites(rootfs, role, image):
defaults = image.get('defaults', {})
entrypoint = defaults.get('Entrypoint') or defaults.get('Cmd') or []
if role == 'application' and (not isinstance(entrypoint, list) or not entrypoint
or not isinstance(entrypoint[0], str) or not entrypoint[0]):
raise ValueError(translate('The official Tandoor startup executable is missing'))
required = {
'application': ('/bin/sh', entrypoint[0] if entrypoint else '/bin/sh', 'python3'),
'database': ('/bin/sh', 'docker-entrypoint.sh', 'postgres', 'pg_isready'),
}
return adapter_prerequisites(rootfs, role, image, 'install_tandoor_stack.sh', required)
def adapter_prerequisites(rootfs, role, image, adapter, required):
if role not in required:
raise ValueError(translate('Unknown adapter role'))
defaults = image.get('defaults', {})
if defaults.get('User') not in (None, '', 'root', '0', '0:0'):
raise ValueError(translate('The image changes the user expected by the adapter'))
path = next((entry[5:] for entry in defaults.get('Env') or []
if entry.startswith('PATH=')), '')
directories = path.split(':') if path else []
if any(not directory.startswith('/') or '..' in Path(directory).parts for directory in directories):
raise ValueError(translate('The PATH of the new image is outside the reproducible profile'))
checked = []
for command in required[role]:
paths = [command] if command.startswith('/') else [directory.rstrip('/') + '/' + command for directory in directories]
for candidate in paths:
try:
checked.append(image_executable(rootfs, candidate))
break
except FileNotFoundError:
continue
else:
raise ValueError(translate('An executable required by the adapter is missing in the new image'))
for path in FILES[adapter][role]:
current = Path(rootfs)
for part in Path(path).parts[1:]:
current /= part
if current.is_symlink():
raise ValueError(translate('The new image adds a symbolic link in a generated path'))
return checked
def nextcloud_record(record):
"""Build portable desired state from evidence, without writing the registry."""
return portable_record(record, nextcloud_installer_profile(record))
def paperless_record(record):
"""Translate captured Paperless state; native activation remains separate."""
return portable_record(record, paperless_installer_profile(record))
def tandoor_record(record):
"""Project the two-member Tandoor recipe without activating replacement."""
return portable_record(record, tandoor_installer_profile(record))
def portable_record(record, profile):
"""Preserve data mounts and provenance without first-install preparations."""
projection = normalize(record)
saved = record['deployment'].get('member_replay_projection')
if saved is not None and saved != projection:
raise ValueError(translate('The saved projection does not match the native evidence'))
result = copy.deepcopy(record)
deployment = projection['deployment']
native = projection['preserved_native']
for mount in deployment['mounts']:
mount.pop('existing_volume_id', None)
template_storage = record['deployment'].get('archive_volume', 'local:').split(':', 1)[0]
if template_storage.startswith('/'):
raise ValueError(translate('The OCI image storage was not kept'))
deployment.update(template_storage=template_storage, features=native.get('features', '').split(',')
if native.get('features') else [], stack_managed=True,
rootfs_adaptation_replay_required=False,
replay_profile=copy.deepcopy(record['deployment']['replay_profile']),
rootfs_replay=copy.deepcopy(record['deployment']['rootfs_replay']))
if 'cpuunits' in native:
deployment['resources']['cpu_units'] = int(native['cpuunits'])
# Unknown settings cannot be silently lost during the first migration.
if native.get('ostype') == 'unmanaged':
deployment['ostype'] = 'unmanaged'
result['deployment'] = deployment
template = result['template']
template.setdefault('schema_version', '0.5.0')
template.setdefault('kind', 'proxmenux.oci-template')
template.setdefault('status', 'generated-unvalidated')
template.setdefault('catalog_ui', {}).update(
architectures=[record['observed']['image']['architecture']], category='productivity')
template.setdefault('source', {}).setdefault('provider', 'official')
template['source'].setdefault('revision', record['observed']['image']['manifest_digest'])
template['container_contract']['volumes'] = [
{'container_path': m['container_path'], 'required': True}
for m in deployment['mounts']]
if deployment['resources'].get('cpu_allocation') == 'quota':
profile = copy.deepcopy(profile)
profile['cpu_allocation'] = 'quota'
template.setdefault('proxmox', {})['installer_profile'] = profile
if 'native_stack_intent' in result:
result['dedicated_stack_recipe'] = result.pop('native_stack_intent')
return result
def paperless_installer_profile(record):
return official_application_profile(record, 'install_paperless_stack.sh', {
'database': ['/usr/local/bin/paperless-postgres-lxc-start'],
'broker': ['tini', '--', 'docker-entrypoint.sh', 'valkey-server'],
})
def tandoor_installer_profile(record):
return official_application_profile(record, 'install_tandoor_stack.sh', {
'database': ['/usr/local/bin/tandoor-postgres-lxc-start'],
})
def official_application_profile(record, adapter, adapted_entrypoints):
projection = normalize(record)
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != adapter:
raise ValueError(translate('Stack adapter not recognized by the translator'))
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
raise ValueError(translate('The stack contains devices or directives without a translation'))
runtime = projection['runtime']
entrypoint = runtime.get('entrypoint', '')
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
raise ValueError(translate('A reproducible native startup is missing'))
arguments = shlex.split(entrypoint)
role = recipe['role']
if role in adapted_entrypoints:
expected = adapted_entrypoints[role]
else:
defaults = record['observed']['image']['defaults']
inherited = defaults.get('Entrypoint') or []
command = defaults.get('Cmd') or []
if not isinstance(inherited, list) or not isinstance(command, list):
raise ValueError(translate('The official startup cannot be reproduced'))
expected = inherited + command
if not expected or any(not isinstance(arg, str) for arg in expected):
raise ValueError(translate('The official startup of the application is missing'))
if arguments != expected:
raise ValueError(translate('The startup differs from the declared adapter'))
# The application follows the new image defaults, not the old entrypoint.
translated = {} if role == 'application' else {'compose_entrypoint': arguments, 'command': []}
for native, target in (('lxc.init.cwd', 'working_directory'),
('lxc.signal.halt', 'halt_signal')):
if native in runtime:
translated[target] = runtime[native]
return {'generated_files': copy.deepcopy(projection['generated_files']),
'runtime': translated}
def nextcloud_installer_profile(record):
"""Translate captured startup settings, without authorizing replacement.
Never include first-install volume preparations: existing database and
application disks must not be reseeded during image replacement.
"""
projection = normalize(record)
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != 'install_nextcloud_stack.sh':
raise ValueError(translate('This translator only supports the Nextcloud stack'))
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
raise ValueError(translate('The stack contains devices or directives without a translation'))
runtime = projection['runtime']
entrypoint = runtime.get('entrypoint', '')
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
raise ValueError(translate('A reproducible native startup is missing'))
try:
arguments = shlex.split(entrypoint)
except ValueError as exc:
raise ValueError(translate('Invalid native entrypoint')) from exc
role = recipe['role']
expected = {
'application': ['/usr/local/bin/nextcloud-lxc-start'],
'database': ['/usr/local/bin/nextcloud-postgres-lxc-start'],
'cache': ['docker-entrypoint.sh', 'redis-server'],
}[role]
if arguments != expected:
raise ValueError(translate('The startup differs from the declared Nextcloud adapter'))
translated = {'compose_entrypoint': arguments, 'command': []}
for native, target in (('lxc.init.cwd', 'working_directory'),
('lxc.signal.halt', 'halt_signal')):
if native in runtime:
translated[target] = runtime[native]
return {'generated_files': copy.deepcopy(projection['generated_files']),
'runtime': translated}
FILES = {
'install_immich_stack.sh': {
'database': (), 'valkey': (), 'machine-learning': (),
'server': ('/usr/local/bin/immich-lxc-start',),
},
'install_nextcloud_stack.sh': {
'database': ('/usr/local/bin/nextcloud-postgres-lxc-start',),
'cache': (), 'application': ('/usr/local/bin/nextcloud-lxc-start',),
},
'install_paperless_stack.sh': {
'database': ('/usr/local/bin/paperless-postgres-lxc-start',),
'broker': (), 'application': (),
},
'install_tandoor_stack.sh': {
'database': ('/usr/local/bin/tandoor-postgres-lxc-start',),
'application': (),
},
}
def capture(rootfs, adapter, role, mounts):
if adapter not in FILES or role not in FILES[adapter]:
raise ValueError(translate('Unrecognized stack adapter or role'))
root = Path(rootfs)
if root.is_symlink() or not root.is_dir():
raise ValueError(translate('Unsafe rootfs for the capture'))
files = []
for path in FILES[adapter][role]:
if any(path == m['container_path'] or path.startswith(m['container_path'].rstrip('/') + '/')
for m in mounts):
raise ValueError(translate('A rootfs adaptation is stored in persistent storage'))
destination = root
for part in Path(path).parts[1:]:
destination = destination / part
if destination.is_symlink():
raise ValueError(translate('Symbolic link in the path of an adaptation'))
info = destination.stat()
if (not stat.S_ISREG(info.st_mode) or info.st_mode & 0o022
or stat.S_IMODE(info.st_mode) != 0o755
or info.st_uid != 100000 or info.st_gid != 100000):
raise ValueError(translate('Adaptation file with unexpected permissions or owner'))
if info.st_size > 65536:
raise ValueError(translate('Adaptation file too large'))
content = destination.read_text()
if not content.startswith(('#!/bin/sh\n', '#!/bin/bash\n')) or '\0' in content:
raise ValueError(translate('Unrecognized adaptation format'))
files.append({'container_path': path, 'mode': '0755', 'owner': 'mapped-root',
'content': content, 'sha256': hashlib.sha256(content.encode()).hexdigest()})
return {'schema_version': 1, 'adapter': adapter, 'role': role, 'files': files}
def validate(recipe):
adapter, role = recipe.get('adapter'), recipe.get('role')
if recipe.get('schema_version') != 1 or adapter not in FILES or role not in FILES[adapter]:
raise ValueError(translate('Unrecognized adaptation recipe'))
files = recipe.get('files', [])
if [item.get('container_path') for item in files] != list(FILES[adapter][role]):
raise ValueError(translate('Incomplete file recipe or unknown paths'))
for item in files:
content = item.get('content', '')
if (item.get('mode') != '0755' or item.get('owner') != 'mapped-root'
or not content.startswith(('#!/bin/sh\n', '#!/bin/bash\n'))
or len(content.encode()) > 65536 or '\0' in content
or hashlib.sha256(content.encode()).hexdigest() != item.get('sha256')):
raise ValueError(translate('The adaptation content was modified'))
return files
def normalize(record):
"""Project a dedicated member into common desired state, without enabling it.
Raw native config remains authoritative. The projection is evidence for the
future replay adapter, not authorization to discard unsupported directives.
"""
deployment = record['deployment']
recipe = deployment['rootfs_replay']
files = validate(recipe)
if deployment.get('replay_profile') != {'adapter': recipe['adapter'], 'role': recipe['role']}:
raise ValueError(translate('Inconsistent adaptation profile and recipe'))
config = record['observed']['config']
config_hash = hashlib.sha256(config.encode()).hexdigest()
if record['observed'].get('config_sha256') != config_hash:
raise ValueError(translate('The configuration evidence does not match'))
values, environment, mount_lines = {}, [], []
names = set()
def add_environment(value):
name, equals, content = value.partition('=')
if not equals or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) or name in names:
raise ValueError(translate('Ambiguous or invalid environment variable'))
names.add(name)
environment.append({'name': name, 'value': content})
for line in config.splitlines():
key, sep, value = line.partition(': ')
if not sep:
if line.strip():
raise ValueError(translate('Unrecognized native configuration'))
continue
if key == 'lxc.environment.runtime':
add_environment(value)
elif key == 'env':
for variable in value.split('\0'):
add_environment(variable)
elif re.fullmatch(r'mp[0-9]+', key):
mount_lines.append((key, value))
else:
values.setdefault(key, []).append(value)
def single(key, default=None):
matches = values.get(key, [])
if len(matches) > 1:
raise ValueError(f"{translate('Duplicated native directive:')} {key}")
if not matches:
if default is not None:
return default
raise ValueError(f"{translate('Missing native directive:')} {key}")
return matches[0]
def options(value):
result = {}
for part in value.split(','):
key, equals, content = part.partition('=')
if equals:
if key in result:
raise ValueError(translate('Duplicated native option'))
result[key] = content
return result
def size(value):
match = re.fullmatch(r'([0-9]+)([GMT])', value or '')
if not match:
raise ValueError(translate('The disk size cannot be reproduced'))
number, unit = int(match[1]), match[2]
if unit == 'M':
if number % 1024:
raise ValueError(translate('The size of existing disks is not rounded'))
number //= 1024
if unit == 'T':
number *= 1024
if number < 1:
raise ValueError(translate('Disk too small for the common profile'))
return number
if single('unprivileged') != '1':
raise ValueError(translate('The native unprivileged idmap is required'))
rootfs = single('rootfs')
source = rootfs.split(',', 1)[0]
if source.startswith('/') or ':' not in source:
raise ValueError(translate('The rootfs is not managed by Proxmox'))
net = options(single('net0'))
if not net.get('bridge') or not net.get('ip') or not net.get('hwaddr'):
raise ValueError(translate('Incomplete primary network'))
mounts, targets = [], set()
for key, value in mount_lines:
source = value.split(',', 1)[0]
opts = options(value)
target = opts.get('mp', '')
if (not target.startswith('/') or target == '/' or '..' in Path(target).parts
or str(Path(target)) != target or any(target == other or target.startswith(other + '/')
or other.startswith(target + '/') for other in targets)):
raise ValueError(translate('Invalid or duplicated mount path'))
targets.add(target)
mount = {'container_path': target, 'read_only': opts.get('ro', '0') == '1'}
if source.startswith('/'):
if opts.get('backup', '0') != '0':
raise ValueError(translate('A host bind mount cannot be included in vzdump'))
mount.update(type='host-bind', source=source, backup=False, create_if_missing=False)
else:
if ':' not in source or opts.get('backup') != '1':
raise ValueError(translate('A managed volume with backup enabled is required'))
mount.update(type='managed-volume', source=source.split(':', 1)[0], backup=True,
size_gb=size(opts.get('size')), existing_volume_id=source)
mounts.append(mount)
quota = recipe['adapter'] == 'install_immich_stack.sh' and 'cores' not in values
if quota:
limit = single('cpulimit')
if not re.fullmatch(r'[1-9][0-9]*', limit):
raise ValueError(translate('The Immich CPU quota cannot be reproduced'))
cores = int(limit)
else:
cores = int(single('cores'))
resources = {'cores': cores, 'memory_mb': int(single('memory')),
'swap_mb': int(single('swap', '0'))}
if quota:
resources['cpu_allocation'] = 'quota'
if resources['cores'] < 1 or resources['memory_mb'] < 1 or resources['swap_mb'] < 0:
raise ValueError(translate('Invalid resources'))
defaults = dict(item.split('=', 1) for item in record['observed'].get('image', {}).get('defaults', {}).get('Env', [])
if isinstance(item, str) and '=' in item)
overrides = [item for item in environment if defaults.get(item['name']) != item['value']]
plan = {'vmid': record['vmid'], 'hostname': single('hostname'),
'rootfs': {'storage': rootfs.split(':', 1)[0], 'size_gb': size(options(rootfs).get('size'))},
'resources': resources, 'security': {'unprivileged': True},
'network': {'bridge': net['bridge'], 'ipv4': net['ip'], 'mac_address': net['hwaddr'],
'firewall': net.get('firewall', '0') == '1'},
'onboot': single('onboot', '0') == '1', 'start_after_create': False,
'shutdown_timeout_seconds': 60, 'mounts': mounts, 'environment': overrides}
if net.get('gw'):
plan['network']['gateway'] = net['gw']
runtime = {key: single(key) for key in ('entrypoint', 'lxc.init.cwd', 'lxc.signal.halt') if key in values}
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)]
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')]
return {'schema_version': 1, 'deployment': plan, 'runtime': runtime,
'preserved_native': preserved, 'generated_files': copy.deepcopy(files),
'native_devices': devices, 'preserved_raw_runtime': raw_runtime,
'observed_environment': environment,
'native_config_sha256': config_hash,
'activation_requires_native_compatibility_check': True}
+209
View File
@@ -0,0 +1,209 @@
"""Durable coordination protocol; native PVE adapters are supplied explicitly.
This module does not enable stack updates by itself. The adapter must hold the
instance registry lock, verify guest identities on every call, and implement
idempotent restore/publication using the transaction ID. No individual member
may publish its contract from replace(). All adapter results must be JSON data.
"""
import copy
import fcntl
import json
import os
from pathlib import Path
import stat
import uuid
from oci_installation_state import private_directory
from oci_instances import write
import oci_instance_transaction as member_tx
from oci_ui import translate, msg_info, msg_ok, msg_warn
TERMINAL = {'committed', 'rolled-back'}
PHASES = {'prepared', 'preparing', 'stopping', 'backing-up', 'replacing',
'starting', 'checking', 'publishing', 'recovering',
'recovery-failed'} | TERMINAL
def save(path, state, phase):
if state.get('phase') != phase:
member_tx.log(f'stack phase: {phase}')
state['phase'] = phase
write(path, state)
def member(adapter, vmid):
describe = getattr(adapter, 'describe', None)
return describe(vmid) if describe else f'CT {vmid}'
def validate_plan(plan):
if plan.get('operation') not in ('update', 'recreate'):
raise ValueError(translate('Invalid stack operation'))
if plan.get('blockers') or plan.get('missing_members'):
raise ValueError(translate('The stack needs member adaptations or a verification of missing volumes'))
members = plan.get('members', [])
ids = [member['vmid'] for member in members]
if not ids or any(type(vmid) is not int or vmid < 100 for vmid in ids):
raise ValueError(translate('Invalid stack members'))
if len(set(ids)) != len(ids) or plan.get('primary_vmid') not in ids:
raise ValueError(translate('Invalid main member or duplicated members'))
for key in ('start_order', 'stop_order'):
order = plan.get(key, [])
if len(order) != len(ids) or set(order) != set(ids):
raise ValueError(translate('Incomplete stack order'))
if plan['start_order'][-1] != plan['primary_vmid'] or plan['stop_order'][0] != plan['primary_vmid']:
raise ValueError(translate('The main member must stop first and start last'))
def recover_state(path, state, adapter):
"""Recover every private backup once replacement could have begun.
A dependency may receive database writes even when only the frontend was
replaced. Consequently rollback never restores just the failing member.
External host files are deliberately outside this recovery protocol.
"""
if state['phase'] in TERMINAL:
if hasattr(adapter, 'finalize'):
adapter.finalize(state)
return state
ids = {str(vmid) for vmid in state['plan']['start_order']}
if (set(state.get('running', {})) != ids
or any(type(value) is not bool for value in state['running'].values())
or type(state.get('stop_intent')) is not bool
or type(state.get('replacement_intent')) is not bool):
raise ValueError(translate('The journal has an incomplete recovery state'))
if state['replacement_intent'] and (not state['stop_intent'] or set(state.get('backups', {})) != ids):
raise ValueError(translate('Stack backups are missing; a partial restore is not allowed'))
adapter.validate(state['plan'])
if state['replacement_intent'] and hasattr(adapter, 'verify_backups'):
msg_info(translate('Verifying the backups...'))
adapter.verify_backups(state['backups'])
msg_ok(translate('Backups verified'))
save(path, state, 'recovering')
try:
if state['stop_intent']:
msg_info(translate('Stopping the stack...'))
for vmid in state['plan']['stop_order']:
adapter.stop(vmid)
msg_ok(translate('Stack stopped'))
if state['replacement_intent']:
for vmid in state['plan']['start_order']:
backup = state['backups'].get(str(vmid))
if backup is None:
raise ValueError(translate('A stack backup is missing; a partial restore is not allowed'))
for vmid in state['plan']['start_order']:
msg_info(f"{translate('Restoring')} {member(adapter, vmid)}...")
adapter.restore(vmid, state['backups'][str(vmid)], state['id'])
msg_ok(f"{translate('Restored:')} {member(adapter, vmid)}")
msg_info(translate('Restoring the stack records...'))
adapter.restore_contracts(state['plan'], state['id'])
msg_ok(translate('Stack records restored'))
if state['stop_intent']:
msg_info(translate('Returning the containers to their previous state...'))
adapter.restore_running_state(state['running'], state['plan']['start_order'])
msg_ok(translate('Containers returned to their previous state'))
save(path, state, 'rolled-back')
except Exception:
save(path, state, 'recovery-failed')
raise
if hasattr(adapter, 'finalize'):
adapter.finalize(state)
return state
def _apply(path, plan, adapter):
validate_plan(plan)
if path.exists():
raise ValueError(translate('A journal already exists; review or recover it before trying again'))
adapter.validate(plan)
running = {str(vmid): adapter.is_running(vmid) for vmid in plan['start_order']}
if any(type(value) is not bool for value in running.values()):
raise ValueError(translate('Invalid running state'))
state = {'schema_version': 1, 'id': str(uuid.uuid4()),
'plan': copy.deepcopy(plan), 'running': running,
'prepared': {}, 'backups': {}, 'stop_intent': False,
'replacement_intent': False}
save(path, state, 'prepared')
try:
# Resolve/download/verify every candidate before stopping any service.
save(path, state, 'preparing')
for candidate in plan['members']:
state['prepared'][str(candidate['vmid'])] = adapter.prepare(candidate, plan['operation'])
save(path, state, 'preparing')
adapter.validate(plan)
state['stop_intent'] = True
save(path, state, 'stopping')
msg_info(translate('Stopping the stack...'))
for vmid in plan['stop_order']:
adapter.stop(vmid)
msg_ok(translate('Stack stopped'))
save(path, state, 'backing-up')
for vmid in plan['start_order']:
msg_info(f"{translate('Creating a backup of')} {member(adapter, vmid)}...")
state['backups'][str(vmid)] = adapter.backup(vmid, state['id'])
save(path, state, 'backing-up')
msg_ok(f"{translate('Backup created:')} {member(adapter, vmid)}")
# The adapter must verify all archives, free space and device identities.
msg_info(translate('Verifying the backups...'))
adapter.verify_backups(state['backups'])
adapter.validate(plan)
msg_ok(translate('Backups verified'))
state['replacement_intent'] = True
save(path, state, 'replacing')
for vmid in plan['start_order']:
msg_info(f"{translate('Updating')} {member(adapter, vmid)}...")
adapter.replace(vmid, state['prepared'][str(vmid)], state['id'])
msg_ok(f"{translate('Updated:')} {member(adapter, vmid)}")
save(path, state, 'starting')
for vmid in plan['start_order']:
msg_info(f"{translate('Starting')} {member(adapter, vmid)}...")
adapter.start(vmid)
adapter.healthcheck(vmid)
msg_ok(f"{translate('Service responding:')} {member(adapter, vmid)}")
save(path, state, 'checking')
msg_info(translate('Checking the updated stack...'))
adapter.validate_candidates(state)
adapter.restore_running_state(running, plan['start_order'])
msg_ok(translate('Updated stack checked'))
save(path, state, 'publishing')
msg_info(translate('Saving the stack records...'))
adapter.publish(state)
save(path, state, 'committed')
msg_ok(translate('Stack records saved'))
except Exception as error:
member_tx.report_error(error)
try:
error.oci_reported = True
except AttributeError:
pass
if state['stop_intent']:
msg_warn(translate('Restoring the previous state of the stack...'))
recover_state(path, state, adapter)
raise
if hasattr(adapter, 'finalize'):
adapter.finalize(state)
return state
def execute(journal, adapter, plan=None):
"""Apply when plan is supplied; otherwise explicitly recover an old journal."""
path = Path(journal)
private_directory(path.parent)
lock = path.with_name(path.name + '.lock')
if path.is_symlink() or lock.is_symlink():
raise ValueError(translate('Unsafe journal or lock file'))
with lock.open('a') as handle:
lock.chmod(0o600)
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
if plan is not None:
return _apply(path, plan, adapter)
attributes = path.lstat()
if (not stat.S_ISREG(attributes.st_mode) or attributes.st_uid != os.geteuid()
or attributes.st_mode & 0o077):
raise ValueError(translate('The private journal has an unsafe owner or permissions'))
state = json.loads(path.read_text())
if state.get('schema_version') != 1 or state.get('phase') not in PHASES:
raise ValueError(translate('Invalid stack journal'))
validate_plan(state['plan'])
return recover_state(path, state, adapter)
+118
View File
@@ -0,0 +1,118 @@
"""Output helpers for the OCI host helpers written in Python: the look of the
ProxMenux utils.sh messages and the same translation cache (lang/<language>.json)."""
from __future__ import annotations
import json
import os
from pathlib import Path
import sys
import threading
BASE_DIR = Path(os.environ.get("PMX_BASE_DIR", "/usr/local/share/proxmenux"))
MG = "\033[1;35m"
GN = "\033[1;92m"
RD = "\033[01;31m"
YW = "\033[33m"
YWB = "\033[1;33m"
BOLD = "\033[1m"
CL = "\033[m"
TAB = " "
FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
_language: str | None = None
_cache: dict[str, str] | None = None
_spinner: tuple[threading.Thread, threading.Event] | None = None
def _load_language() -> str:
global _language
if _language is None:
try:
value = json.loads((BASE_DIR / "config.json").read_text(encoding="utf-8")).get("language")
except (OSError, ValueError, AttributeError):
value = None
_language = value if isinstance(value, str) and value else "en"
return _language
def translate(text: str) -> str:
global _cache
if _load_language() == "en":
return text
if _cache is None:
try:
data = json.loads((BASE_DIR / "lang" / f"{_load_language()}.json").read_text(encoding="utf-8"))
_cache = {str(k): str(v) for k, v in data.items()} if isinstance(data, dict) else {}
except (OSError, ValueError):
_cache = {}
return _cache.get(text) or text
def _write(text: str) -> None:
sys.stdout.write(text)
sys.stdout.flush()
def _spin(stop: threading.Event) -> None:
index = 0
_write("\033[?25l")
while not stop.wait(0.1):
_write(f"\r {MG}{FRAMES[index]}{CL}")
index = (index + 1) % len(FRAMES)
def stop_spinner() -> None:
global _spinner
if _spinner is not None:
thread, stop = _spinner
stop.set()
thread.join()
_spinner = None
_write("\033[?25h")
def msg_info(text: str) -> None:
global _spinner
stop_spinner()
_write(f"\r\033[K{TAB}{MG}-{text}{CL}")
if sys.stdout.isatty() or os.environ.get("OCI_SPINNER") == "1":
stop = threading.Event()
thread = threading.Thread(target=_spin, args=(stop,), daemon=True)
_spinner = (thread, stop)
thread.start()
else:
_write("\n")
def msg_progress(text: str) -> None:
stop_spinner()
_write(f"\r\033[K{TAB}{MG}-{text}{CL}")
def msg_ok(text: str) -> None:
stop_spinner()
_write(f"\r\033[K{TAB}{GN}✓ {CL}{GN}{text}{CL}\n")
def msg_warn(text: str) -> None:
stop_spinner()
_write(f"\r\033[K{TAB}{CL} {YWB}{text}{CL}\n")
def msg_error(text: str) -> None:
stop_spinner()
_write(f"\r\033[K{TAB}{RD}[ERROR] {text}{CL}\n")
def msg_info2(text: str) -> None:
stop_spinner()
_write(f"\r\033[K{TAB}{BOLD}{YW}- {text}{CL}\n")
def log(path: str | os.PathLike | None, text: str) -> None:
"""Appends a line to a private log; output that the user does not need goes here."""
if not path:
return
with open(path, "a", encoding="utf-8") as handle:
handle.write(text.rstrip("\n") + "\n")
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Helpers shared by the OCI installers: the look of the ProxMenux utils.sh
# messages, the same translation cache and the private log of each run.
# Safe under set -Eeuo pipefail.
PMX_BASE_DIR=${PMX_BASE_DIR:-/usr/local/share/proxmenux}
OCI_LOG_DIR=${OCI_LOG_DIR:-/var/log/proxmenux/oci}
OCI_LOG=${OCI_LOG:-}
_OCI_LANGUAGE=$(jq -r '.language // "en"' "$PMX_BASE_DIR/config.json" 2>/dev/null || true)
[[ -n $_OCI_LANGUAGE && $_OCI_LANGUAGE != null ]] || _OCI_LANGUAGE=en
_OCI_LANG_FILE="$PMX_BASE_DIR/lang/${_OCI_LANGUAGE}.json"
_OCI_MG=$'\033[1;35m'
_OCI_GN=$'\033[1;92m'
_OCI_RD=$'\033[01;31m'
_OCI_YW=$'\033[33m'
_OCI_YWB=$'\033[1;33m'
_OCI_BOLD=$'\033[1m'
_OCI_CL=$'\033[m'
_OCI_TAB=" "
_OCI_SPINNER_PID=""
translate() {
if [[ $_OCI_LANGUAGE == en || ! -s $_OCI_LANG_FILE ]]; then
printf '%s' "$1"
return 0
fi
local value
value=$(jq -r --arg text "$1" '.[$text] // empty' "$_OCI_LANG_FILE" 2>/dev/null || true)
printf '%s' "${value:-$1}"
}
_oci_spinner() {
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') i=0
printf '\033[?25l'
while :; do
printf '\r %s%s%s' "$_OCI_MG" "${frames[i]}" "$_OCI_CL"
i=$(( (i + 1) % ${#frames[@]} ))
sleep 0.1
done
}
stop_spinner() {
if [[ -n $_OCI_SPINNER_PID ]]; then
kill "$_OCI_SPINNER_PID" 2>/dev/null || true
wait "$_OCI_SPINNER_PID" 2>/dev/null || true
_OCI_SPINNER_PID=""
fi
printf '\033[?25h'
}
# The spinner is shown when the caller's terminal is interactive; OCI_SPINNER=1
# is set by the Python front end, which relays this output to a terminal.
msg_info() {
stop_spinner
printf '\r\033[K%s%s-%s%s' "$_OCI_TAB" "$_OCI_MG" "$1" "$_OCI_CL"
if [[ -t 1 || ${OCI_SPINNER:-0} == 1 ]]; then
_oci_spinner &
_OCI_SPINNER_PID=$!
else
printf '\n'
fi
}
# One line rewritten in place, for progress counters (no spinner).
msg_progress() {
stop_spinner
printf '\r\033[K%s%s-%s%s' "$_OCI_TAB" "$_OCI_MG" "$1" "$_OCI_CL"
}
msg_ok() {
stop_spinner
printf '\r\033[K%s%s✓ %s%s%s%s\n' "$_OCI_TAB" "$_OCI_GN" "$_OCI_CL" "$_OCI_GN" "$1" "$_OCI_CL"
}
msg_warn() {
stop_spinner
printf '\r\033[K%s%s %s%s%s\n' "$_OCI_TAB" "$_OCI_CL" "$_OCI_YWB" "$1" "$_OCI_CL"
}
msg_error() {
stop_spinner
printf '\r\033[K%s%s[ERROR] %s%s\n' "$_OCI_TAB" "$_OCI_RD" "$1" "$_OCI_CL"
}
msg_info2() {
stop_spinner
printf '\r\033[K%s%s%s- %s%s\n' "$_OCI_TAB" "$_OCI_BOLD" "$_OCI_YW" "$1" "$_OCI_CL"
}
# Starts the private log of one run; every quiet command writes into it.
oci_log_init() {
local name=${1:-oci}
[[ -n $OCI_LOG ]] && return 0
mkdir -p "$OCI_LOG_DIR"
chmod 0700 "$OCI_LOG_DIR" 2>/dev/null || true
OCI_LOG="$OCI_LOG_DIR/${name//[^A-Za-z0-9._-]/_}-$(date +%Y%m%d-%H%M%S).log"
: >"$OCI_LOG"
chmod 0600 "$OCI_LOG"
export OCI_LOG
}
oci_log() {
[[ -n $OCI_LOG ]] && printf '%s\n' "$*" >>"$OCI_LOG"
return 0
}
# Runs a command with its output in the log instead of the terminal.
oci_quiet() {
if [[ -n $OCI_LOG ]]; then
"$@" >>"$OCI_LOG" 2>&1
else
"$@" >/dev/null 2>&1
fi
}
# Last lines of the log, for the error report.
oci_log_tail() {
[[ -n $OCI_LOG && -s $OCI_LOG ]] || return 0
tail -n "${1:-15}" "$OCI_LOG" | sed "s/^/${_OCI_TAB} /"
}
# ip= and gw= options of an access interface, DHCP or a static IPv4 with an
# optional gateway, in OCI_ACCESS_NET. Returns 1 when a value is not valid.
oci_access_net() {
local octet='(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])'
OCI_ACCESS_NET=""
if [[ $1 == dhcp ]]; then
OCI_ACCESS_NET="ip=dhcp"
return 0
fi
[[ $1 =~ ^($octet\.){3}$octet/([89]|[12][0-9]|3[0-2])$ ]] || return 1
[[ -z ${2:-} || $2 =~ ^($octet\.){3}$octet$ ]] || return 1
OCI_ACCESS_NET="ip=$1${2:+,gw=$2}"
}
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Resolve a saved image channel and invoke the native update transaction."""
import argparse
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
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
def repository(reference):
repo = reference.split('@', 1)[0]
if ':' in repo.rsplit('/', 1)[-1]:
repo = repo.rsplit(':', 1)[0]
return repo
def run_quiet(args, error, capture=False):
"""Runs a helper with its output in the private log; error is the message of a failure."""
transaction.log('$ ' + shlex.join(args))
process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env=dict(os.environ, PYTHONPATH=str(Path(__file__).parent)))
try:
while True:
try:
output, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
continue
if process.returncode:
transaction.log(f' exit {process.returncode}')
transaction.log_output(None if capture else output, errors)
if process.returncode:
raise RuntimeError(error)
return output
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def resolve_archive(desired, config, current=None, check=None):
# Shared by individual and coordinated operations; no guest mutation here.
# When the registry still serves the current digest nothing is downloaded.
reference = desired['template']['container_contract']['image']['reference']
architecture = transaction.parse_config(config)['arch']
msg_info(translate('Checking the image in the registry...'))
transaction.log(f'image: {reference} ({architecture})')
code = ('import json,sys; from oci_installation_state import resolve_candidate; '
'print(json.dumps(resolve_candidate(sys.argv[1],sys.argv[2])))')
candidate = json.loads(run_quiet([sys.executable, '-c', code, reference, architecture],
translate('Could not query the image registry'), capture=True))
digest = candidate['manifest_digest']
if not re.fullmatch(r'sha256:[a-f0-9]{64}', digest):
raise ValueError(translate('Invalid registry digest'))
msg_ok(f"{translate('Image:')} {reference} ({candidate.get('version') or digest[7:19]})")
if digest == current:
return None, digest
if check:
check()
storage = desired['deployment']['template_storage']
if not re.fullmatch(r'[A-Za-z0-9_-]+', storage):
raise ValueError(translate('Invalid template storage'))
archive = Path(instances.command('pvesm', 'path',
f'{storage}:vztmpl/proxmenux-update-{architecture}-{digest[7:]}.tar').decode().strip())
archive.parent.mkdir(parents=True, exist_ok=True)
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:
msg_info(transaction.fit(f"{translate('Downloading the image:')} {reference}"))
fd, name = tempfile.mkstemp(prefix='.proxmenux-update-', suffix='.tar', dir=archive.parent)
os.close(fd)
partial = Path(name)
try:
run_quiet(['skopeo', 'copy', '--override-arch', architecture,
'docker://' + repository(reference) + '@' + digest,
'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)
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
def kept_settings(changes, deployment):
"""Names of the settings changed in Proxmox that the new container keeps."""
labels = {'memory': translate('Memory'), 'swap': translate('Swap'), 'cores': translate('CPU cores'),
'cpulimit': translate('CPU cores'), 'cpuunits': translate('CPU priority'),
'onboot': translate('Start with Proxmox')}
kept = []
for key, value in changes.items():
section, name = transaction.ADOPTABLE[key]
if (deployment.get(section, {}) if section else deployment).get(name) == value:
kept.append(labels[key])
return kept
def update(vmid, acknowledge_external_data=False, proposal=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...'))
with instances.locked(instances.ROOT):
record = instances.read(instances.ROOT, vmid)
if record['status'] != 'installed' or record.get('pending_transaction') or record.get('pending_stack_transaction'):
raise ValueError(translate('The instance is not ready to be updated'))
config = instances.command('pct', 'config', str(vmid))
desired = transaction.candidate_contract(record, operation, proposal)
changes = transaction.external_changes(record, config)
transaction.preflight(record, desired, config)
msg_ok(translate('Container checked'))
current = record['observed']['image']['manifest_digest'] if operation == 'update' else None
archive, digest = resolve_archive(desired, config, current, lambda: transaction.require_backup_space(
instances.location(instances.ROOT, vmid).parent, [vmid]))
if archive is None:
msg_ok(translate('The image is already up to date; nothing was changed.'))
return
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)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int)
parser.add_argument('--acknowledge-external-data', action='store_true')
parser.add_argument('--proposal', type=Path)
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)
return 0
except BlockingIOError:
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))
return 1
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
transaction.report_error(error, f'update-{args.vmid}')
if transaction.pending_journal():
transaction.recovery_hint()
return 1
if __name__ == '__main__':
raise SystemExit(main())
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Recoverable nginx laboratory transaction. NOT a general OCI updater."""
from __future__ import annotations
import argparse
import copy
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
import uuid
from oci_installation_state import image_from_archive, parse_config, private_directory, save_record, sha
from verify_oci_archive import verify_archive, VerificationError
def run(*args):
print('Paso:', args[0], args[1] if len(args) > 1 else '', flush=True)
p = subprocess.run(args, capture_output=True, timeout=600)
if p.returncode:
raise RuntimeError(f'{args[0]} fallo con codigo {p.returncode}; transaccion conservada')
return p.stdout
def atomic(path, value):
fd, name = tempfile.mkstemp(dir=path.parent, prefix='.journal-')
try:
with os.fdopen(fd, 'w') as out:
json.dump(value, out, indent=2)
out.flush()
os.fsync(out.fileno())
os.replace(name, path)
fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
finally:
if os.path.exists(name):
os.unlink(name)
def checkpoint(path, state, phase):
state['phase'] = phase
atomic(path, state)
print('Estado:', phase, flush=True)
def filehash(path):
h = hashlib.sha256()
with open(path, 'rb') as f:
for block in iter(lambda: f.read(1024 * 1024), b''):
h.update(block)
return h.hexdigest()
def preflight(record, config):
cfg = parse_config(config)
if record['config_sha256'] != sha(config):
raise ValueError('Configuracion modificada desde el registro')
if cfg.get('hostname') != 'oci-update-lab' or record['reference'] != 'docker.io/library/nginx:alpine':
raise ValueError('Solo se admite el nginx de laboratorio')
allowed = {'arch', 'cores', 'description', 'entrypoint', 'env', 'hostname', 'memory', 'mp0', 'net0',
'onboot', 'ostype', 'rootfs', 'swap', 'tags', 'unprivileged', 'lxc.init.cwd',
'lxc.signal.halt', 'cmode', 'console', 'tty'}
if set(cfg) - allowed or '[' in config.decode():
raise ValueError('Configuracion avanzada/snapshots no soportada')
if cfg.get('unprivileged') != '1' or cfg.get('onboot', '0') != '0':
raise ValueError('El laboratorio requiere unprivileged=1 y onboot=0')
mp = cfg.get('mp0', '')
if not mp.startswith('local-lvm:') or 'mp=/usr/share/nginx/html' not in mp or 'backup=1' not in mp:
raise ValueError('Solo se admite mp0 gestionado y respaldado del laboratorio')
if not cfg.get('rootfs', '').startswith('local-lvm:'):
raise ValueError('Storage de laboratorio no soportado')
return cfg
def health(vmid):
for _ in range(30):
try:
data = run('pct', 'exec', str(vmid), '--', 'wget', '-qO-', 'http://127.0.0.1/')
if data == b'oci-persistence-proof':
return
except RuntimeError:
pass
time.sleep(1)
raise RuntimeError('Healthcheck de datos/HTTP fallido')
def stop(vmid):
if b'running' in run('pct', 'status', str(vmid)):
run('pct', 'shutdown', str(vmid), '--timeout', '60')
def create(vmid, archive, cfg, hostname, marker):
run('pct', 'create', str(vmid), archive, '--rootfs', 'local-lvm:2',
'--hostname', hostname, '--cores', cfg.get('cores', '1'),
'--memory', cfg.get('memory', '256'), '--swap', cfg.get('swap', '128'),
'--unprivileged', '1', '--onboot', '0', '--tags', cfg.get('tags', 'lab'),
'--net0', cfg['net0'], '--description', marker)
def transaction(args, journal):
if journal.exists():
raise ValueError('Ya existe una transaccion; consultar status o recover')
record = json.loads((args.state_dir / f'{args.vmid}.json').read_text())
if record.get('vmid') != args.vmid or record.get('schema_version') != 1:
raise ValueError('Registro incompatible')
before = run('pct', 'config', str(args.vmid))
cfg = preflight(record, before)
# Check HA separately; hostname/tags alone must never authorize mutation.
resources = json.loads(run('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json'))
if any(r.get('sid') == f'ct:{args.vmid}' for r in resources):
raise ValueError('HA no soportado')
if shutil.disk_usage(journal.parent).free < 8 * 1024**3:
raise ValueError('Se requieren 8 GiB libres para esta prueba y su backup')
archive = str(Path(args.archive).resolve())
verify_archive(Path(archive))
candidate = image_from_archive(archive)
if candidate['architecture'] != record['image']['architecture']:
raise ValueError('Arquitectura incompatible')
if candidate['manifest_digest'] == record['image']['manifest_digest']:
raise ValueError('La imagen ya coincide; no se requiere actualizar')
# This lab contract has no user runtime overrides or custom entrypoint.
if candidate['defaults'].get('Entrypoint') != record['image']['defaults'].get('Entrypoint') or candidate['defaults'].get('Cmd') != record['image']['defaults'].get('Cmd'):
raise ValueError('Cambio de comando fuera del alcance del laboratorio')
state = {'id': uuid.uuid4().hex, 'vmid': args.vmid, 'record': record, 'cfg': cfg,
'archive': archive, 'candidate': candidate, 'was_running': b'running' in run('pct', 'status', str(args.vmid))}
checkpoint(journal, state, 'prepared')
stop(args.vmid)
checkpoint(journal, state, 'backing-up')
backup_dir = journal.parent / state['id']
private_directory(backup_dir)
run('vzdump', str(args.vmid), '--mode', 'stop', '--compress', 'zstd', '--dumpdir', str(backup_dir), '--tmpdir', '/var/tmp')
backups = list(backup_dir.glob('vzdump-lxc-*.tar.zst'))
if len(backups) != 1:
raise ValueError('Backup no identificado')
run('zstd', '-t', str(backups[0]))
state.update(backup=str(backups[0]), backup_sha256=filehash(backups[0]))
checkpoint(journal, state, 'backup-ready')
stage = int(run('pvesh', 'get', '/cluster/nextid').strip())
state['stage'] = stage
checkpoint(journal, state, 'creating-stage')
# Staging never starts, so it can retain the original MAC without collisions.
create(stage, archive, cfg, 'oci-update-stage', state['id'])
checkpoint(journal, state, 'parking-data')
run('pct', 'move-volume', str(args.vmid), 'mp0', '--target-vmid', str(stage), '--target-volume', 'mp0')
checkpoint(journal, state, 'data-parked')
if args.interrupt_after == 'data-parked':
raise RuntimeError('Interrupcion de laboratorio solicitada; ejecutar recover')
current = parse_config(run('pct', 'config', str(args.vmid)))
if current != {k: v for k, v in cfg.items() if k != 'mp0'}:
raise ValueError('Cambio concurrente detectado; no se destruye el CT')
checkpoint(journal, state, 'replacing-root')
run('pct', 'destroy', str(args.vmid))
create(args.vmid, archive, cfg, cfg['hostname'], state['id'])
checkpoint(journal, state, 'root-replaced')
if args.interrupt_after == 'root-replaced':
raise RuntimeError('Interrupcion de laboratorio solicitada; ejecutar recover')
checkpoint(journal, state, 'returning-data')
run('pct', 'move-volume', str(stage), 'mp0', '--target-vmid', str(args.vmid), '--target-volume', 'mp0')
checkpoint(journal, state, 'checking-service')
run('pct', 'start', str(args.vmid))
health(args.vmid)
if not state['was_running']:
stop(args.vmid)
updated = copy.deepcopy(record)
config = run('pct', 'config', str(args.vmid))
updated.update(image=candidate, archive_path=archive, config=config.decode(), config_sha256=sha(config),
installation_id=str(uuid.uuid4()), previous_installation_id=record['installation_id'],
resolved_registry_digest=candidate['manifest_digest'], last_update_transaction=state['id'])
save_record(args.state_dir, updated)
checkpoint(journal, state, 'committed')
def recover(args, journal):
state = json.loads(journal.read_text())
if state['phase'] in ('committed', 'rolled-back'):
raise ValueError('Transaccion terminada; no se restaura automaticamente')
backup = state.get('backup')
if not backup or filehash(backup) != state['backup_sha256']:
raise ValueError('No hay backup verificado; recuperar manualmente sin destruir datos')
vmid = state['vmid']
path = Path(f'/etc/pve/lxc/{vmid}.conf')
if path.exists():
config = run('pct', 'config', str(vmid))
cfg = parse_config(config)
if state['id'] not in config.decode() and cfg.get('rootfs') != state['cfg']['rootfs']:
raise ValueError('VMID posiblemente reutilizado; recuperacion bloqueada')
stop(vmid)
if state.get('stage') and Path(f"/etc/pve/lxc/{state['stage']}.conf").exists():
config = run('pct', 'config', str(state['stage']))
if state['id'] not in config.decode():
raise ValueError('Staging ajeno; recuperacion bloqueada')
stop(state['stage'])
checkpoint(journal, state, 'restoring-backup')
run('pct', 'restore', str(vmid), backup, '--force', '1', '--storage', 'local-lvm', '--description', state['id'])
run('pct', 'start', str(vmid))
health(vmid)
if not state['was_running']:
stop(vmid)
restored = copy.deepcopy(state['record'])
config = run('pct', 'config', str(vmid))
restored.update(config=config.decode(), config_sha256=sha(config), recovered_transaction=state['id'])
save_record(args.state_dir, restored)
checkpoint(journal, state, 'rolled-back')
print('Staging y backup conservados, sin limpieza automatica.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['apply', 'status', 'recover'])
parser.add_argument('vmid', type=int)
parser.add_argument('--state-dir', type=Path, required=True)
parser.add_argument('--transaction-dir', type=Path, required=True)
parser.add_argument('--archive')
parser.add_argument('--interrupt-after', choices=['data-parked', 'root-replaced'])
args = parser.parse_args()
if os.geteuid() != 0:
parser.error('Se requiere root')
private_directory(args.transaction_dir)
journal = args.transaction_dir / f'{args.vmid}.json'
with Path(f'/run/lock/proxmenux-oci-lab-{args.vmid}.lock').open('w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
if args.action == 'status':
state = json.loads(journal.read_text())
print(json.dumps({k: state.get(k) for k in ('id', 'vmid', 'stage', 'phase')}, indent=2))
elif args.action == 'recover':
recover(args, journal)
else:
if not args.archive:
parser.error('apply requiere --archive')
transaction(args, journal)
except (OSError, ValueError, RuntimeError, KeyError, VerificationError, subprocess.TimeoutExpired) as error:
print(f'Proceso detenido ({type(error).__name__}). Diario privado: {journal}. Consultar status/recover.')
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Clone a FUSE mount from an LXC namespace into the Proxmox host namespace."""
from __future__ import annotations
import ctypes
import os
import platform
import sys
AT_FDCWD = -100
AT_EMPTY_PATH = 0x1000
AT_RECURSIVE = 0x8000
CLONE_NEWNS = 0x00020000
MOVE_MOUNT_F_EMPTY_PATH = 0x00000004
MOUNT_ATTR_RDONLY = 0x00000001
OPEN_TREE_CLONE = 1
SYSCALLS = {
"x86_64": (428, 429, 442),
"amd64": (428, 429, 442),
"aarch64": (428, 429, 442),
"arm64": (428, 429, 442),
}
class MountAttr(ctypes.Structure):
_fields_ = [
("attr_set", ctypes.c_uint64),
("attr_clr", ctypes.c_uint64),
("propagation", ctypes.c_uint64),
("userns_fd", ctypes.c_uint64),
]
def fail(step: str) -> None:
error = ctypes.get_errno()
raise OSError(error, f"{step}: {os.strerror(error)}")
def main() -> int:
if len(sys.argv) != 5 or sys.argv[4] not in {"rw", "ro"}:
print(f"usage: {sys.argv[0]} PID SOURCE TARGET rw|ro", file=sys.stderr)
return 2
machine = platform.machine().lower()
if machine not in SYSCALLS:
print(f"unsupported host architecture: {machine}", file=sys.stderr)
return 2
open_tree_nr, move_mount_nr, mount_setattr_nr = SYSCALLS[machine]
pid, source, target, mode = sys.argv[1:]
libc = ctypes.CDLL(None, use_errno=True)
libc.syscall.restype = ctypes.c_long
libc.setns.argtypes = (ctypes.c_int, ctypes.c_int)
libc.setns.restype = ctypes.c_int
host_ns = os.open("/proc/self/ns/mnt", os.O_RDONLY | os.O_CLOEXEC)
host_root = os.open("/", os.O_PATH | os.O_DIRECTORY | os.O_CLOEXEC)
ct_ns = os.open(f"/proc/{pid}/ns/mnt", os.O_RDONLY | os.O_CLOEXEC)
ct_root = os.open(f"/proc/{pid}/root", os.O_PATH | os.O_DIRECTORY | os.O_CLOEXEC)
try:
if libc.setns(ct_ns, CLONE_NEWNS) != 0:
fail("enter container namespace")
os.fchdir(ct_root)
os.chroot(".")
os.chdir("/")
tree = libc.syscall(
open_tree_nr,
AT_FDCWD,
os.fsencode(source),
OPEN_TREE_CLONE | os.O_CLOEXEC,
)
if tree < 0:
fail("clone source mount tree")
try:
if mode == "ro":
attributes = MountAttr(attr_set=MOUNT_ATTR_RDONLY)
result = libc.syscall(
mount_setattr_nr,
tree,
ctypes.c_char_p(b""),
AT_EMPTY_PATH | AT_RECURSIVE,
ctypes.byref(attributes),
ctypes.sizeof(attributes),
)
if result != 0:
fail("make cloned mount tree read-only")
if libc.setns(host_ns, CLONE_NEWNS) != 0:
fail("return to host namespace")
os.fchdir(host_root)
os.chroot(".")
os.chdir("/")
result = libc.syscall(
move_mount_nr,
tree,
ctypes.c_char_p(b""),
AT_FDCWD,
os.fsencode(target),
MOVE_MOUNT_F_EMPTY_PATH,
)
if result != 0:
fail("publish mount tree")
finally:
os.close(tree)
finally:
for descriptor in (ct_root, ct_ns, host_root, host_ns):
os.close(descriptor)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except OSError as exc:
print(exc, file=sys.stderr)
raise SystemExit(1)
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -Eeuo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
find_snippet_storage() {
local storage
if pvesm status --content snippets 2>/dev/null \
| awk 'NR > 1 && $1 == "local" && $3 == "active" {found=1} END {exit !found}'; then
printf 'local'
return
fi
storage=$(pvesm status --content snippets 2>/dev/null \
| awk 'NR > 1 && $3 == "active" {print $1; exit}')
[[ -n $storage ]] || die "No active storage supports snippets"
printf '%s' "$storage"
}
install_hook() {
local main_id=${1:?missing main VMID} source_config=${2:?missing lifecycle JSON}
local storage hook_volume hook_path target_config
[[ $main_id =~ ^[0-9]+$ ]] || die "Invalid main VMID"
jq -e '
.schema == 1 and
(.dependencies | type == "array") and
(.dependencies | length > 0) and
(all(.dependencies[];
(.vmid | type == "number") and
(.label | type == "string") and
(.healthcheck.type | IN("exec", "http", "running")) and
(.healthcheck.timeout_seconds | type == "number")
))
' "$source_config" >/dev/null || die "Invalid dependency contract"
storage=$(find_snippet_storage)
hook_volume="${storage}:snippets/proxmenux-stack-dependencies.sh"
hook_path=$(pvesm path "$hook_volume")
install -D -m 0755 "$0" "$hook_path"
target_config="/etc/pve/priv/proxmenux-stack-${main_id}.json"
umask 077
cat "$source_config" >"$target_config"
pct set "$main_id" --hookscript "$hook_volume" >/dev/null
printf 'Proxmox hookscript installed: CT %s starts its dependencies through %s\n' \
"$main_id" "$hook_volume"
}
dependency_is_healthy() {
local id=$1 healthcheck=$2 type url
type=$(jq -r '.type' <<<"$healthcheck")
case "$type" in
running)
[[ $(pct status "$id" 2>/dev/null || true) == "status: running" ]]
;;
exec)
local -a command=()
mapfile -t command < <(jq -r '.argv[]' <<<"$healthcheck")
((${#command[@]} > 0)) || return 1
pct exec "$id" -- "${command[@]}" >/dev/null 2>&1
;;
http)
url=$(jq -r '.url' <<<"$healthcheck")
curl -fsS --max-time 3 "$url" >/dev/null 2>&1
;;
*) return 1 ;;
esac
}
start_dependencies() {
local main_id=$1 config="/etc/pve/priv/proxmenux-stack-${1}.json"
local encoded dependency id label healthcheck timeout elapsed
[[ -r $config ]] || die "Missing dependency contract for main CT $main_id"
exec 9>"/run/lock/proxmenux-stack-${main_id}.lock"
flock 9
while IFS= read -r encoded; do
[[ -n $encoded ]] || continue
dependency=$(base64 -d <<<"$encoded")
id=$(jq -r '.vmid' <<<"$dependency")
label=$(jq -r '.label' <<<"$dependency")
healthcheck=$(jq -c '.healthcheck' <<<"$dependency")
timeout=$(jq -r '.healthcheck.timeout_seconds' <<<"$dependency")
[[ $id =~ ^[0-9]+$ && $timeout =~ ^[0-9]+$ && $timeout -gt 0 ]] \
|| die "Invalid dependency in $config"
pct config "$id" >/dev/null 2>&1 \
|| die "Dependency $label (CT $id) does not exist"
if [[ $(pct status "$id" 2>/dev/null || true) != "status: running" ]]; then
printf 'Starting dependency %s (CT %s)...\n' "$label" "$id"
pct start "$id" || die "Could not start $label (CT $id)"
else
printf 'Dependency %s (CT %s) was already running.\n' "$label" "$id"
fi
elapsed=0
while (( elapsed < timeout )); do
if dependency_is_healthy "$id" "$healthcheck"; then
printf 'Dependency %s (CT %s): ready.\n' "$label" "$id"
break
fi
[[ $(pct status "$id" 2>/dev/null || true) == "status: running" ]] \
|| die "$label (CT $id) stopped while starting"
sleep 2
elapsed=$((elapsed + 2))
done
(( elapsed < timeout )) \
|| die "$label (CT $id) did not pass its health check within ${timeout}s"
done < <(jq -r '.dependencies[] | @base64' "$config")
}
if [[ ${1:-} == "--install" ]]; then
shift
install_hook "$@"
exit 0
fi
vmid=${1:?missing VMID}
phase=${2:?missing lifecycle phase}
case "$phase" in
pre-start) start_dependencies "$vmid" ;;
post-start|pre-stop|post-stop) ;;
*) die "Unknown lifecycle phase: $phase" ;;
esac
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Convert an OCI rootfs imported with the default LXC idmap to host IDs."""
from __future__ import annotations
import os
import stat
import sys
from oci_ui import log, translate
def iter_paths(root: str):
yield root
for directory, names, files in os.walk(root, topdown=True, followlinks=False):
for name in names:
yield os.path.join(directory, name)
for name in files:
yield os.path.join(directory, name)
def note(text: str) -> None:
"""Progress goes to the run log; without one, to stderr."""
path = os.environ.get("OCI_LOG")
try:
if path:
log(path, text)
return
except OSError:
pass
print(text, file=sys.stderr, flush=True)
def main() -> int:
if len(sys.argv) != 2:
print(f"{translate('Usage:')} {sys.argv[0]} ROOTFS", file=sys.stderr)
return 2
root = os.path.realpath(sys.argv[1])
if not root.startswith("/var/lib/lxc/") or not root.endswith("/rootfs"):
print(f"{translate('Refusing an unexpected rootfs path:')} {root}", file=sys.stderr)
return 2
root_device = os.lstat(root).st_dev
shifted = 0
for path in iter_paths(root):
metadata = os.lstat(path)
if metadata.st_dev != root_device:
continue
uid = metadata.st_uid - 100000 if 100000 <= metadata.st_uid < 165536 else metadata.st_uid
gid = metadata.st_gid - 100000 if 100000 <= metadata.st_gid < 165536 else metadata.st_gid
if uid == metadata.st_uid and gid == metadata.st_gid:
continue
attributes: dict[str, bytes] = {}
for name in os.listxattr(path, follow_symlinks=False):
attributes[name] = os.getxattr(path, name, follow_symlinks=False)
os.chown(path, uid, gid, follow_symlinks=False)
for name, value in attributes.items():
os.setxattr(path, name, value, follow_symlinks=False)
shifted += 1
if shifted % 10000 == 0:
note(f" Owners converted: {shifted}")
note(f"OCI rootfs converted to privileged: {shifted} entries")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Verify blob digests and gzip integrity in an OCI image archive."""
from __future__ import annotations
import argparse
import hashlib
import os
import sys
import tarfile
import zlib
from pathlib import Path
from oci_ui import log, translate
CHUNK_SIZE = 4 * 1024 * 1024
PROGRESS_STEP = 128 * 1024 * 1024
class VerificationError(RuntimeError):
pass
def human_mib(size: int) -> str:
return f"{size / (1024 * 1024):.1f} MiB"
def progress(text: str) -> None:
"""Per-blob detail belongs to the run log, never to the terminal."""
try:
log(os.environ.get("OCI_LOG"), text)
except OSError:
pass
def verify_blob(archive: tarfile.TarFile, member: tarfile.TarInfo, position: int, total: int) -> None:
expected_digest = member.name.rsplit("/", 1)[-1]
source = archive.extractfile(member)
if source is None:
raise VerificationError(f"{translate('Cannot read')} {member.name}")
progress(f" Verifying blob {position}/{total}: {expected_digest[:12]} ({human_mib(member.size)})")
digest = hashlib.sha256()
decompressor: zlib.Decompress | None = None
processed = 0
next_progress = PROGRESS_STEP
while True:
chunk = source.read(CHUNK_SIZE)
if not chunk:
break
digest.update(chunk)
if processed == 0 and chunk.startswith(b"\x1f\x8b"):
decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS)
if decompressor is not None:
try:
decompressor.decompress(chunk)
except zlib.error as exc:
raise VerificationError(
f"{translate('Corrupted gzip layer')} {expected_digest[:16]}: {exc}"
) from exc
processed += len(chunk)
if member.size >= PROGRESS_STEP and processed >= next_progress:
percentage = min(100, processed * 100 // member.size)
progress(f" {human_mib(processed)} / {human_mib(member.size)} ({percentage}%)")
next_progress += PROGRESS_STEP
if processed != member.size:
raise VerificationError(
f"{translate('Wrong size in')} {expected_digest[:16]}: {processed} != {member.size}"
)
actual_digest = digest.hexdigest()
if actual_digest != expected_digest:
raise VerificationError(
f"{translate('Wrong SHA-256 in')} {expected_digest[:16]}: {actual_digest[:16]}"
)
if decompressor is not None:
try:
decompressor.flush()
except zlib.error as exc:
raise VerificationError(
f"{translate('Corrupted gzip layer')} {expected_digest[:16]}: {exc}"
) from exc
if not decompressor.eof:
raise VerificationError(f"{translate('Incomplete gzip layer')} {expected_digest[:16]}")
def verify_archive(path: Path) -> None:
if not path.is_file() or path.stat().st_size == 0:
raise VerificationError(f"{translate('The OCI archive does not exist or is empty:')} {path}")
try:
with tarfile.open(path, mode="r:*") as archive:
members = archive.getmembers()
names = {member.name.lstrip("./") for member in members}
missing = {"index.json", "oci-layout"} - names
if missing:
raise VerificationError(
f"{translate('Missing OCI metadata:')} " + ", ".join(sorted(missing))
)
blobs = [
member
for member in members
if member.isfile()
and member.name.lstrip("./").startswith("blobs/sha256/")
]
if not blobs:
raise VerificationError(translate("The OCI archive contains no SHA-256 blobs"))
for position, member in enumerate(blobs, start=1):
verify_blob(archive, member, position, len(blobs))
except (tarfile.TarError, OSError) as exc:
raise VerificationError(f"{translate('Cannot read the OCI archive:')} {exc}") from exc
progress(f"OCI integrity verified: {path}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("archive", type=Path)
args = parser.parse_args()
try:
verify_archive(args.archive)
except VerificationError as exc:
print(f"{translate('OCI verification failed:')} {exc}", file=sys.stderr, flush=True)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())