mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
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>
204 lines
9.0 KiB
Bash
Executable File
204 lines
9.0 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
|
|
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)"
|