mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 14:36:38 +00:00
The installation record of an OCI application travels with its container: a copy inside the container and another in /etc/pve, written together with the one kept on the host. A container restored on a newly installed Proxmox, restored with another ID or moved to another node of a cluster is recognised and registered again, with its private network, hookscript, Rclone mount, host firewall rule and NVIDIA runtime. The Monitor offers the same recovery from the Updates tab. AMD GPUs are offered by generation. A GPU the ROCm image supports takes the profile as it is; one of a supported family (Radeon 680M, 780M) is an experimental option that asks for confirmation and is never proposed; an older one is not offered. The GPU is checked with a real inference before the installation accepts it. Recreate changes what runs recognition in an installed Immich, between the CPU and a GPU of the host. Updates: - A failed update that is restored and checked removes its temporary container and the disks of the failed attempt. - Every container volume is part of the backups, so Jellyfin, Plex and Hugo update with their default installation. - An image published with a Docker-format manifest is recognised by its layers and build time and updates. - The Proxmox notes of a multi-container application link to its LAN address.
214 lines
9.7 KiB
Bash
Executable File
214 lines
9.7 KiB
Bash
Executable File
#!/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
|
|
if [[ $STATUS == running ]]; then
|
|
msg_ok "$(translate "Dry run completed; mount configuration was not applied. The CT was already running.")"
|
|
else
|
|
msg_ok "$(translate "Dry run completed; mount configuration was not applied. The CT was started for the remote check.")"
|
|
fi
|
|
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"
|
|
# A new Proxmox installation accepts no snippets on `local`.
|
|
if ! pvesm status --content snippets 2>/dev/null | awk 'NR > 1 && $1 == "local" {found=1} END {exit !found}'; then
|
|
LOCAL_CONTENT=$(pvesh get /storage/local --output-format json 2>/dev/null | jq -r '.content // empty')
|
|
[[ -n $LOCAL_CONTENT ]] || die "$(translate "The local storage does not accept snippets")"
|
|
oci_quiet pvesm set local --content "${LOCAL_CONTENT},snippets"
|
|
fi
|
|
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)"
|