mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
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:
@@ -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())
|
||||
@@ -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()
|
||||
Executable
+203
@@ -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)"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Executable
+4
@@ -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" "$@"
|
||||
Executable
+570
@@ -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
|
||||
Executable
+553
@@ -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)"
|
||||
Executable
+1918
File diff suppressed because it is too large
Load Diff
Executable
+542
@@ -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)"
|
||||
Executable
+505
@@ -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)"
|
||||
Executable
+30
@@ -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"
|
||||
@@ -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'))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Executable
+112
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
@@ -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())
|
||||
Executable
+42
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Executable
+81
@@ -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")"
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
Executable
+128
@@ -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
|
||||
@@ -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())
|
||||
Executable
+130
@@ -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())
|
||||
Reference in New Issue
Block a user