Files
ProxMenux/oci/remote/install_oci.sh
T
MacRimiandGitHub 328a690712 Merge pull request #402 from f3rs3n/fix/oci-removal-firewall-wording
Clarify OCI removal outcomes and host firewall diagnostics
2026-09-28 22:15:50 +02:00

2180 lines
99 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"
# A transaction captures this output in a private file: no spinner there.
[[ -z ${PROXMENUX_OCI_TRANSACTION:-} ]] || OCI_SPINNER=0
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
}
container_is_running() {
local status attempt
for (( attempt=1; attempt<=3; attempt++ )); do
if status=$(pct status "$VMID" 2>/dev/null); then
case "$status" in
'status: running') return 0 ;;
'status: stopped') return 1 ;;
esac
fi
# A failed pct probe must not be mistaken for a stopped container.
oci_log "pct status could not confirm CT $VMID (attempt $attempt); checking lxc-info"
if status=$(lxc-info -n "$VMID" -sH 2>/dev/null); then
case "$status" in
RUNNING) return 0 ;;
STOPPED) return 1 ;;
esac
fi
(( attempt < 3 )) && sleep 1
done
return 1
}
mount_ct_rootfs() {
oci_quiet pct mount "$VMID" || die "$(translate "Could not mount the container filesystem:") CT $VMID"
}
merge_json_defaults() {
jq -n --slurpfile existing "$1" --slurpfile defaults "$2" '
def fill($d):
if type != "object" then error("Existing JSON section is not an object")
else reduce ($d | keys_unsorted[]) as $key (.;
if has($key) then
if ($d[$key] | type) == "object" then .[$key] |= fill($d[$key]) else . end
else .[$key] = $d[$key] end)
end;
if ($existing | length) != 1 or ($defaults | length) != 1
or ($defaults[0] | type) != "object"
then error("Expected one JSON object per file")
else $existing[0] | fill($defaults[0]) end'
}
gpu_vendor_name() {
case "$1" in
0x1002) printf 'AMD' ;;
0x8086) printf 'Intel' ;;
0x10de) printf 'NVIDIA' ;;
*) printf '%s' "$1" ;;
esac
}
validate_gpu_vendors() {
local device path node vendor
while IFS= read -r device; do
path=$(jq -er '.host_path' <<<"$device")
[[ $path =~ ^/dev/dri/renderD[0-9]+$ && -c $path ]] \
|| die "$(translate "The selected render device does not exist:") $path"
node=${path##*/}
vendor=$(cat "/sys/class/drm/${node}/device/vendor" 2>/dev/null) \
|| die "$(translate "Cannot identify the vendor of the device:") $path"
jq -e --arg vendor "$vendor" '.drm_vendor_ids | index($vendor) != null' <<<"$device" >/dev/null \
|| die "$(translate "The GPU vendor does not match the selected GPU profile:") $path ($vendor)"
msg_ok "$(translate "GPU verified:") $path ($(gpu_vendor_name "$vendor"), GID $(stat -c '%g' "$path"))"
done < <(jq -c '.devices[]? | select(.drm_vendor_ids != null)' "$DEPLOYMENT_FILE")
}
show_gpu_inventory() {
jq -e '.devices[]? | select(.kind == "nvidia-runtime" or
((.host_path // "") | startswith("/dev/dri")) or .host_path == "/dev/kfd")' \
"$DEPLOYMENT_FILE" >/dev/null || return 0
local node vendor kind
oci_log "Host DRM inventory (additional GPUs are not passed through automatically):"
for node in /dev/dri/renderD*; do
[[ -c $node ]] || continue
vendor=$(cat "/sys/class/drm/${node##*/}/device/vendor" 2>/dev/null || true)
oci_log " $node vendor=${vendor:-unknown} uid=$(stat -c '%u' "$node") gid=$(stat -c '%g' "$node") mode=$(stat -c '%a' "$node")"
done
while IFS=$'\t' read -r node kind; do
if [[ $kind == character-device-tree ]]; then
[[ -d $node ]] || die "$(translate "The selected GPU directory does not exist:") $node"
else
[[ -c $node ]] || die "$(translate "The selected GPU device does not exist:") $node"
fi
done < <(jq -r '.devices[]? | select(
((.host_path // "") | startswith("/dev/dri")) or .host_path == "/dev/kfd") |
[.host_path, (.kind // "character-device")] | @tsv' "$DEPLOYMENT_FILE")
}
apply_native_device_permissions() {
[[ $(jq -r '.device_permissions.strategy // empty' "$DEPLOYMENT_FILE") == linuxserver-native-init ]] || return 0
(( ${#RESOLVED_CHARACTER_DEVICES[@]} > 0 )) || return 0
# Expand after NVIDIA/DVB discovery: directories and fixed renderD128 miss devices.
local value temporary
value=$(IFS=' '; printf '%s' "${RESOLVED_CHARACTER_DEVICES[*]}")
temporary=$(mktemp)
jq --arg value "$value" '.environment =
([.environment[] | select(.name != "ATTACHED_DEVICES_PERMS")] +
[{name:"ATTACHED_DEVICES_PERMS", value:$value, sensitive:false}])' \
"$DEPLOYMENT_FILE" >"$temporary"
cat "$temporary" >"$DEPLOYMENT_FILE"
rm -f "$temporary"
}
check_native_device_permissions() {
[[ $(jq -r '.device_permissions.strategy // empty' "$DEPLOYMENT_FILE") == linuxserver-native-init ]] || return 0
(( ${#RESOLVED_CHARACTER_DEVICES[@]} > 0 )) || return 0
local attempt
msg_info "$(translate "Checking the device permissions for the application user...")"
oci_log "Checking device access as abc (this is not a codec test)."
for (( attempt=0; attempt<60; attempt++ )); do
container_is_running \
|| die "$(translate "The container stopped before the GPU permissions were verified:") CT $VMID"
if pct exec "$VMID" -- s6-setuidgid abc sh -c \
'for path do test -r "$path" && test -w "$path" || exit 1; done' \
check "${RESOLVED_CHARACTER_DEVICES[@]}" >/dev/null 2>&1; then
msg_ok "$(translate "Device permissions verified for the application user")"
return 0
fi
sleep 2
done
die "$(translate "The image did not grant the application user access to the devices; check its native init. Host permissions were not relaxed.")"
}
configure_cpu_allocation() {
local mode
mode=$(jq -er '.proxmox.installer_profile.cpu_allocation // "cpuset"' "$TEMPLATE_FILE")
case "$mode" in
cpuset|quota) ;;
*) die "$(translate "Unsupported CPU allocation mode:") $mode" ;;
esac
if [[ $mode == quota || -n ${HOST_MONITOR:-} ]]; then
CPU_CREATE_ARGS=(--cpulimit "$CORES")
else
CPU_CREATE_ARGS=(--cores "$CORES")
fi
}
validate_rlimits() {
local name soft hard
jq -e '(.resources.rlimits // []) | type == "array" and
(all(.[]; type == "object" and (.name|type)=="string" and
(.soft|type)=="string" and (.hard|type)=="string")) and
((map(.name)|unique|length) == length)' "$DEPLOYMENT_FILE" >/dev/null \
|| die "$(translate "Invalid process limits format")"
while IFS=$'\t' read -r name soft hard; do
[[ $name =~ ^(as|core|cpu|data|fsize|locks|memlock|msgqueue|nice|nofile|nproc|rss|rtprio|rttime|sigpending|stack)$ ]] \
|| die "$(translate "Unsupported prlimit resource")"
[[ $soft =~ ^(unlimited|0|[1-9][0-9]{0,17})$ && $hard =~ ^(unlimited|0|[1-9][0-9]{0,17})$ ]] \
|| die "$(translate "Invalid prlimit value")"
if [[ $hard != unlimited ]]; then
[[ $soft != unlimited ]] && (( soft <= hard )) || die "$(translate "The prlimit soft value exceeds the hard value")"
fi
done < <(jq -r '.resources.rlimits[]? | [.name,.soft,.hard] | @tsv' "$DEPLOYMENT_FILE")
}
apply_rlimits() {
local name soft hard
while IFS=$'\t' read -r name soft hard; do
set_lxc_directive "lxc.prlimit.${name}" "${soft}:${hard}"
done < <(jq -r '.resources.rlimits[]? | [.name,.soft,.hard] | @tsv' "$DEPLOYMENT_FILE")
}
validate_host_monitor() {
HOST_MONITOR=$(jq -r '.host_monitor // empty' "$DEPLOYMENT_FILE")
[[ -n $HOST_MONITOR ]] || return 0
local expected port
case "$HOST_MONITOR" in
glances) expected=nicolargo/glances:latest; port=61208 ;;
netdata) expected=netdata/netdata:latest; port=19999 ;;
*) die "$(translate "Unknown host monitor")" ;;
esac
[[ $APP_ID == "image-${HOST_MONITOR}" && $IMAGE_REF == "$expected" ]] \
|| die "$(translate "Image not allowed for the host monitor profile")"
[[ $(jq -r '.proxmox.installer_profile.host_monitor // empty' "$TEMPLATE_FILE") == "$HOST_MONITOR" ]] \
|| die "$(translate "Inconsistent host monitor profile")"
jq -e '.security.unprivileged == false and .security.privileged_acknowledged == true' "$DEPLOYMENT_FILE" >/dev/null \
|| die "$(translate "The host monitor needs consent for privileged access to the host")"
[[ $IPV4 == host && -z $MAC_ADDRESS && -z $GATEWAY ]] \
|| die "$(translate "The host monitor uses the host network, without DHCP or its own gateway")"
HOST_MONITOR_IP=$(ip -4 -o addr show dev "$BRIDGE" scope global | awk 'NR==1 {split($4,a,"/"); print a[1]}')
[[ -n $HOST_MONITOR_IP ]] || die "$(translate "The host has no IPv4 address on the selected bridge")"
require_command ss
[[ -z $(ss -H -ltn "sport = :${port}") ]] || die "$(translate "The host port is already in use:") ${port}"
}
validate_host_monitor_firewall() {
HOST_FIREWALL_ENABLED=0
HOST_FIREWALL_BRIDGE=""
HOST_FIREWALL_SOURCE=""
HOST_FIREWALL_PORT=""
[[ $(jq -r '.host_firewall == null or .host_firewall == {}' "$DEPLOYMENT_FILE") == true ]] && return 0
[[ -n ${HOST_MONITOR:-} ]] || die "$(translate "A host firewall rule is only valid for a host-monitor profile")"
jq -e '.host_firewall | type == "object"
and (.confirmed == true)
and (.bridge | type == "string" and test("^[A-Za-z0-9_.-]+$"))
and (.source | type == "string" and test("^[0-9./]+$"))
and (.protocol == "tcp")
and (.port | type == "number" and floor == . and . >= 1 and . <= 65535)' \
"$DEPLOYMENT_FILE" >/dev/null \
|| die "$(translate "Invalid host-monitor firewall plan")"
HOST_FIREWALL_BRIDGE=$(jq -r '.host_firewall.bridge' "$DEPLOYMENT_FILE")
HOST_FIREWALL_SOURCE=$(jq -r '.host_firewall.source' "$DEPLOYMENT_FILE")
HOST_FIREWALL_PORT=$(jq -r '.host_firewall.port' "$DEPLOYMENT_FILE")
[[ $HOST_FIREWALL_BRIDGE == "$BRIDGE" ]] \
|| die "$(translate "The host-monitor firewall bridge does not match the selected bridge")"
local cidr subnet declared_port contract_count
cidr=$(ip -4 -o addr show dev "$BRIDGE" scope global | awk 'NR==1 {print $4}')
[[ -n $cidr ]] || die "$(translate "The selected bridge has no IPv4 subnet for the host-monitor firewall")"
subnet=$(python3 - "$cidr" <<'PY'
import ipaddress
import sys
try:
print(ipaddress.ip_interface(sys.argv[1]).network)
except ValueError:
raise SystemExit(1)
PY
) || die "$(translate "The selected bridge has no IPv4 subnet for the host-monitor firewall")"
[[ $HOST_FIREWALL_SOURCE == "$subnet" ]] \
|| die "$(translate "The host-monitor firewall subnet changed; no firewall rule was added")"
declared_port=$(jq -r '.proxmox.installer_profile.host_monitor_firewall.web_port // empty' "$TEMPLATE_FILE")
[[ $declared_port == "$HOST_FIREWALL_PORT" ]] \
|| die "$(translate "The host-monitor firewall port is not declared by this profile")"
contract_count=$(jq --argjson port "$HOST_FIREWALL_PORT" '[.container_contract.ports[]? | select(
.protocol == "tcp" and .container_port == $port)] | length' "$TEMPLATE_FILE")
[[ $contract_count == 1 ]] \
|| die "$(translate "The host-monitor firewall port does not match exactly one TCP port in the container contract")"
HOST_FIREWALL_ENABLED=1
}
apply_host_monitor_firewall() {
[[ ${HOST_FIREWALL_ENABLED:-0} == 1 ]] || return 0
# Updates/recreates use a saved plan non-interactively. They never add a
# missing host rule; only the original, separately confirmed installation
# may modify the host firewall.
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
oci_log "Host firewall plan retained without changing firewall during an OCI transaction"
return 0
fi
local node comment rules existing
node=$(hostname)
# This UUID makes removal safe even after a VMID is reused. Only a rule
# carrying this exact marker belongs to this installation.
comment="ProxMenux OCI firewall ${INSTANCE_ID}"
rules=$(pvesh get "/nodes/${node}/firewall/rules" --output-format json) \
|| die "$(translate "Could not read the host firewall rules")"
existing=$(jq -r --arg source "$HOST_FIREWALL_SOURCE" --arg port "$HOST_FIREWALL_PORT" '
[.[]? | select((.type | ascii_downcase) == "in" and (.action | ascii_upcase) == "ACCEPT"
and (.proto | ascii_downcase) == "tcp" and (.source // "") == $source
and ((.dport | tostring) == $port))] | length' <<<"$rules")
if (( existing > 0 )); then
msg_ok "$(translate "Host firewall already allows:") TCP ${HOST_FIREWALL_PORT} $(translate "from") ${HOST_FIREWALL_SOURCE}"
return 0
fi
if ! pvesh create "/nodes/${node}/firewall/rules" --type in --action ACCEPT --proto tcp \
--dport "$HOST_FIREWALL_PORT" --source "$HOST_FIREWALL_SOURCE" --enable 1 --comment "$comment"; then
# The CT and its saved contract are already complete. Do not destroy a
# usable installation merely because the optional network exposure failed.
msg_warn "$(translate "Could not add the confirmed host firewall rule")"
return 0
fi
msg_ok "$(translate "Host firewall rule added:") TCP ${HOST_FIREWALL_PORT} $(translate "from") ${HOST_FIREWALL_SOURCE}"
}
apply_host_monitor() {
[[ -n ${HOST_MONITOR:-} ]] || return 0
# PVE permits lxc.include but not namespace keys directly in the CT config.
# This static, cluster-persistent companion must accompany cross-host restores.
# /etc/pve/proxmenux is the same on every node of a cluster; /etc/pve/lxc
# is the folder of this node only.
local include=/etc/pve/proxmenux/host-monitor native
native=$'lxc.namespace.share.pid = 1\nlxc.namespace.share.net = 1'
mkdir -p /etc/pve/proxmenux || die "$(translate "Could not create the ProxMenux folder in /etc/pve")"
if [[ -e $include ]]; then
[[ $(cat "$include") == "$native" ]] || die "$(translate "A different host monitor include already exists; it is not overwritten:") $include"
else
printf '%s\n' "$native" >"$include"
fi
[[ -z ${SYSCTL_INCLUDE:-} ]] || die "$(translate "The host monitor profile does not support another sysctl include")"
set_lxc_directive lxc.include "$include"
set_lxc_directive lxc.net.0.type none
# LXCFS reports cgroup-limited values, which would misrepresent the monitored host.
# Do not remove the Proxmox pre-start, autodev or post-stop hooks.
set_lxc_directive lxc.hook.mount ""
msg_ok "$(translate "Host monitor configured: shared PID and network namespaces, LXCFS disabled in this container")"
msg_info2 "$(translate "Every node of this cluster already has this file. If you restore this container on any other Proxmox host, copy it to the same path first, because the container backup does not include it:") $include"
}
verify_host_monitor() {
[[ -n ${HOST_MONITOR:-} ]] || return 0
local pid namespace actual
pid=$(lxc-info -n "$VMID" -pH)
[[ $pid =~ ^[0-9]+$ && $pid -gt 1 ]] || die "$(translate "Invalid host monitor PID")"
for namespace in pid net; do
actual=$(readlink "/proc/$pid/ns/$namespace")
[[ $actual == "$(readlink "/proc/1/ns/$namespace")" ]] \
|| die "$(translate "The host monitor does not share this host namespace:") $namespace"
done
[[ $(pct exec "$VMID" -- cat /proc/meminfo | sed -n '/^MemTotal:/p') == "$(sed -n '/^MemTotal:/p' /proc/meminfo)" ]] \
|| die "$(translate "The host monitor does not see the real host memory")"
msg_ok "$(translate "Host monitor verified: PID and network namespaces and memory match the host")"
}
RUNTIME_CONSOLE_LOG=""
SYSCTL_INCLUDE=""
SECCOMP_PROFILE_FILE=""
CT_CREATED=0
INSTALL_COMPLETE=0
PRESERVE_FAILED_CT=0
VOLUME_SEED_STAGE_ROOT=""
VOLUME_SEED_TARGETS=()
VOLUME_SEED_STAGES=()
VOLUME_SEED_MOUNT_TYPES=()
CANONICAL_INPUT_DIR=""
cleanup_runtime_console_log() {
# The console log is the container's own log from here on, and its line in
# the configuration stays with it: there is nothing to undo. A failed
# installation keeps the file too, since it holds why the application did
# not come up.
RUNTIME_CONSOLE_LOG=""
}
cleanup_canonical_inputs() {
[[ -n ${CANONICAL_INPUT_DIR:-} ]] || return 0
[[ $CANONICAL_INPUT_DIR == "/var/tmp/proxmenux-oci-inputs-${VMID}."* && -d $CANONICAL_INPUT_DIR && ! -L $CANONICAL_INPUT_DIR ]] \
|| { oci_log "Refusing to remove an unexpected canonical input directory: ${CANONICAL_INPUT_DIR}"; return 1; }
rm -rf -- "$CANONICAL_INPUT_DIR" || return 1
CANONICAL_INPUT_DIR=""
}
# A derived check accepts any HTTP answer below 500: the application is up,
# even when its first page is a redirect or asks for a login.
healthcheck_probe() {
if [[ ${HC_ANY_STATUS:-false} == true ]]; then
local code
code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || return 1
[[ $code =~ ^[1-4][0-9][0-9]$ ]]
else
curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null
fi
}
cleanup_failed_install() {
local status=$?
stop_spinner
cleanup_volume_seed_staging || true
cleanup_canonical_inputs || true
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
# The transaction owns recovery. Destroying this CT could destroy reused data.
cleanup_runtime_console_log || true
return "$status"
fi
if (( status != 0 )) && [[ -n ${INSTANCE_ID:-} ]]; then
oci_quiet python3 "${SCRIPT_DIR}/oci_instances.py" failed "$VMID" || true
fi
# A step may fail while the rootfs is mounted; the mount lock would block
# both keeping the CT usable and destroying it.
if (( status != 0 && CT_CREATED == 1 )); then
pct unmount "$VMID" >/dev/null 2>&1 || true
fi
if (( status != 0 && CT_CREATED == 1 && INSTALL_COMPLETE == 0 && PRESERVE_FAILED_CT == 1 )); then
oci_log "Container kept for inspection; console log: $RUNTIME_CONSOLE_LOG"
msg_warn "$(translate "Container kept with its data; the installation was not validated:") CT $VMID · $(translate "Full log:") ${OCI_LOG:-$RUNTIME_CONSOLE_LOG}"
[[ -z $RUNTIME_CONSOLE_LOG ]] || msg_info2 "$(translate "Console log:") $RUNTIME_CONSOLE_LOG"
return "$status"
fi
cleanup_runtime_console_log || true
if (( status != 0 && CT_CREATED == 1 && INSTALL_COMPLETE == 0 )); then
msg_info "$(translate "Rolling back the incomplete container") CT $VMID..."
oci_quiet pct stop "$VMID" || true
if oci_quiet pct destroy "$VMID" --purge 1 || oci_quiet pct destroy "$VMID"; then
msg_ok "$(translate "Incomplete container removed:") CT $VMID"
else
msg_warn "$(translate "The container could not be removed automatically:") CT $VMID"
fi
fi
# The include files belong to the CT: only remove them once it is gone.
if (( status != 0 )) && ! { [[ -n ${VMID:-} ]] && pct config "$VMID" >/dev/null 2>&1; }; then
[[ -z $SYSCTL_INCLUDE ]] || rm -f "$SYSCTL_INCLUDE"
[[ -z $SECCOMP_PROFILE_FILE ]] || rm -f "$SECCOMP_PROFILE_FILE"
fi
return "$status"
}
ensure_rootfs_directory() {
local rootfs=$1 directory=$2 current parent i
local -a missing=()
[[ $directory == "$rootfs" || $directory == "$rootfs"/* ]] \
|| die "$(translate "The prepared directory escapes the rootfs:") $directory"
current=$directory
while [[ ! -e $current ]]; do
missing+=("$current")
current=$(dirname "$current")
done
[[ -d $current ]] || die "$(translate "The parent of the target is not a directory:") $current"
for ((i = ${#missing[@]} - 1; i >= 0; i--)); do
parent=$(dirname "${missing[$i]}")
mkdir "${missing[$i]}"
chown --reference="$parent" "${missing[$i]}"
chmod 0755 "${missing[$i]}"
done
}
is_reused_managed_mount() {
local target=$1
[[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]] || return 1
jq -e --arg path "$target" \
'[.transaction_reuse_mounts[]? | select(.container_path == $path)] | length > 0' \
"$DEPLOYMENT_FILE" >/dev/null
}
cleanup_volume_seed_staging() {
[[ -n ${VOLUME_SEED_STAGE_ROOT:-} ]] || return 0
[[ $VOLUME_SEED_STAGE_ROOT == "/var/tmp/proxmenux-oci-seed-${VMID}."* && -d $VOLUME_SEED_STAGE_ROOT && ! -L $VOLUME_SEED_STAGE_ROOT ]] \
|| { oci_log "Refusing to remove an unexpected image-volume staging directory: ${VOLUME_SEED_STAGE_ROOT}"; return 1; }
rm -rf -- "$VOLUME_SEED_STAGE_ROOT" || return 1
VOLUME_SEED_STAGE_ROOT=""
}
capture_volume_seeds() {
local seed_count=0 rootfs="/var/lib/lxc/${VMID}/rootfs"
local encoded item target source_path selected_mount_type mount_types source stage index=0 mounted=0 failed=0
seed_count=$(jq '.proxmox.installer_profile.volume_seeds? // [] | length' "$TEMPLATE_FILE")
(( seed_count > 0 )) || return 0
mount_ct_rootfs
mounted=1
VOLUME_SEED_STAGE_ROOT=$(mktemp -d "/var/tmp/proxmenux-oci-seed-${VMID}.XXXXXX") || failed=1
while (( failed == 0 )) && IFS= read -r encoded; do
[[ -n $encoded ]] || continue
item=$(printf '%s' "$encoded" | base64 -d)
target=$(jq -er '.container_path' <<<"$item")
source_path=$(jq -er '.source_path' <<<"$item")
mount_types=$(jq -c '.mount_types // ["managed-volume"]' <<<"$item")
selected_mount_type=$(jq -r --arg target "$target" \
'[.mounts[]? | select(.container_path == $target) | .type] | first // empty' \
"$DEPLOYMENT_FILE")
[[ $target == /* && $target != *[[:space:]]* && $target != *","* ]] \
|| { oci_log "Invalid volume seed target: $target"; failed=1; break; }
[[ $source_path == /* && $source_path != *[[:space:]]* && $source_path != *","* ]] \
|| { oci_log "Invalid image volume seed source: $source_path"; failed=1; break; }
jq -e --arg type "$selected_mount_type" '
type == "array" and length > 0
and all(.[]; . == "managed-volume" or . == "host-bind")
and index($type) != null' <<<"$mount_types" >/dev/null \
|| { oci_log "Skipping image volume seed for $target: the selected mount type is not allowed"; continue; }
if [[ $selected_mount_type != managed-volume && $selected_mount_type != host-bind ]]; then
oci_log "Skipping image volume seed for $target: unsupported mount type ${selected_mount_type:-none}"
continue
fi
if [[ $selected_mount_type == managed-volume ]] && is_reused_managed_mount "$target"; then
oci_log "Keeping the reused persistent disk without seeding it: $target"
continue
fi
[[ ! -L "${rootfs}${source_path}" ]] \
|| { oci_log "The image volume seed source must not be a link: $source_path"; failed=1; break; }
source=$(readlink -e -- "${rootfs}${source_path}") \
|| { oci_log "The image volume seed source does not exist: $source_path"; failed=1; break; }
[[ $source == "${rootfs}"/* && -d $source ]] \
|| { oci_log "The image volume seed source escapes the rootfs or is not a directory: $source_path"; failed=1; break; }
stage="${VOLUME_SEED_STAGE_ROOT}/${index}"
install -d -m 0700 "$stage" && cp -a -- "${source}/." "${stage}/" || { failed=1; break; }
VOLUME_SEED_TARGETS+=("$target")
VOLUME_SEED_STAGES+=("$stage")
VOLUME_SEED_MOUNT_TYPES+=("$selected_mount_type")
index=$((index + 1))
done < <(jq -r '.proxmox.installer_profile.volume_seeds[]? | @base64' "$TEMPLATE_FILE")
if (( mounted == 1 )); then
oci_quiet pct unmount "$VMID" || failed=1
fi
if (( failed != 0 )); then
oci_log "Could not capture the image data for a persistent volume"
die "$(translate "Could not apply the installer profile")"
fi
if (( ${#VOLUME_SEED_TARGETS[@]} == 0 )); then
cleanup_volume_seed_staging || die "$(translate "Could not apply the installer profile")"
fi
}
apply_volume_seeds() {
local rootfs="/var/lib/lxc/${VMID}/rootfs" index target stage mount_type destination existing failed=0
(( ${#VOLUME_SEED_TARGETS[@]} > 0 )) || return 0
for index in "${!VOLUME_SEED_TARGETS[@]}"; do
target=${VOLUME_SEED_TARGETS[$index]}
stage=${VOLUME_SEED_STAGES[$index]}
mount_type=${VOLUME_SEED_MOUNT_TYPES[$index]}
[[ -d $stage && ! -L $stage ]] \
|| { oci_log "The prepared image data is not available for the persistent volume: $target"; failed=1; break; }
[[ ! -L "${rootfs}${target}" ]] \
|| { oci_log "The persistent volume target must not be a link: $target"; failed=1; break; }
destination=$(readlink -e -- "${rootfs}${target}") \
|| { oci_log "The persistent volume target does not exist: $target"; failed=1; break; }
[[ $destination == "${rootfs}"/* && -d $destination ]] \
|| { oci_log "The persistent volume target escapes the rootfs or is not a directory: $target"; failed=1; break; }
existing=$(find "$destination" -mindepth 1 -maxdepth 1 ! -name lost+found -print -quit)
if [[ -n $existing ]]; then
if [[ $mount_type == host-bind ]]; then
oci_log "Keeping existing host data without seeding it: $target"
rm -rf -- "$stage" || { failed=1; break; }
continue
fi
oci_log "Refusing to seed a persistent volume that already contains data: $target"
failed=1
break
fi
cp -a -- "${stage}/." "${destination}/" \
|| { oci_log "Could not seed the persistent volume from the image: $target"; failed=1; break; }
rm -rf -- "$stage" \
|| { oci_log "Could not remove the temporary image data: $target"; failed=1; break; }
oci_log "Image data copied into the new persistent volume: $target"
done
cleanup_volume_seed_staging || { oci_log "Could not remove the temporary image-volume staging directory"; failed=1; }
(( failed == 0 ))
}
prepare_file_mount_target() {
local requested_target=$1
local rootfs="/var/lib/lxc/${VMID}/rootfs"
local requested_parent resolved_parent target_path
local failed=0
mount_ct_rootfs
requested_parent=$(dirname "$requested_target")
resolved_parent=$(readlink -m "${rootfs}${requested_parent}")
if [[ $resolved_parent != "$rootfs" && $resolved_parent != "$rootfs"/* ]]; then
oci_quiet pct unmount "$VMID" || true
die "$(translate "The bind mount target escapes the rootfs:") $requested_target"
fi
target_path="${resolved_parent}/$(basename "$requested_target")"
PREPARED_FILE_TARGET="/${target_path#"${rootfs}/"}"
ensure_rootfs_directory "$rootfs" "$(dirname "$target_path")"
if [[ -L $target_path ]]; then
rm -f "$target_path" || failed=1
elif [[ -e $target_path && ! -f $target_path ]]; then
oci_log "The file bind mount target cannot be replaced: $requested_target"
failed=1
fi
if (( failed == 0 )) && [[ ! -e $target_path ]]; then
: >"$target_path" || failed=1
chown --reference="$(dirname "$target_path")" "$target_path" || failed=1
chmod 0644 "$target_path" || failed=1
fi
oci_quiet pct unmount "$VMID" || failed=1
(( failed == 0 )) \
|| die "$(translate "Could not prepare the file bind mount target:") $requested_target"
}
run_pre_start_repair() {
local repair_json=$1
local repair_id check_type python_path module check_package minimum_version
local repair_type package index_url target_version break_system_packages no_dependencies
local rootfs="/var/lib/lxc/${VMID}/rootfs"
local check_passed=0 failed=0 repaired=0
local -a pip_args
repair_id=$(jq -er '.id' <<<"$repair_json")
if ! jq -e --arg architecture "$ARCH" '.architectures | index($architecture) != null' \
<<<"$repair_json" >/dev/null; then
oci_log "Skipping repair $repair_id for architecture $ARCH"
return 0
fi
check_type=$(jq -er '.check.type' <<<"$repair_json")
python_path=$(jq -er '.check.python_path' <<<"$repair_json")
repair_type=$(jq -er '.repair.type' <<<"$repair_json")
package=$(jq -er '.repair.package' <<<"$repair_json")
index_url=$(jq -er '.repair.index' <<<"$repair_json")
break_system_packages=$(jq -r '.repair.break_system_packages // false' <<<"$repair_json")
no_dependencies=$(jq -r '.repair.no_dependencies // false' <<<"$repair_json")
[[ $python_path == /* && $python_path != *[[:space:]]* ]] \
|| die "$(translate "Invalid Python path in the repair:") $repair_id"
[[ $package =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "$(translate "Invalid Python package:") $package"
[[ $index_url == https://* && $index_url != *[[:space:]]* ]] \
|| die "$(translate "Invalid Python index:") $index_url"
[[ $break_system_packages == true && $no_dependencies == true ]] \
|| die "$(translate "The repair must preserve the image dependencies:") $repair_id"
case "$check_type" in
python-import)
module=$(jq -er '.check.module' <<<"$repair_json")
[[ $module =~ ^[A-Za-z_][A-Za-z0-9_.]*$ ]] || die "$(translate "Invalid Python module:") $module"
;;
python-package-minimum-version)
check_package=$(jq -er '.check.package' <<<"$repair_json")
minimum_version=$(jq -er '.check.minimum_version' <<<"$repair_json")
[[ $check_package =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|| die "$(translate "Invalid check package:") $check_package"
[[ $minimum_version =~ ^[0-9]+([.][0-9]+)*$ ]] \
|| die "$(translate "Invalid minimum version:") $minimum_version"
;;
*) die "$(translate "Unsupported pre-start check:") $check_type" ;;
esac
case "$repair_type" in
pip-reinstall-installed-version) ;;
pip-install-exact-version)
target_version=$(jq -er '.repair.version' <<<"$repair_json")
[[ $target_version =~ ^[A-Za-z0-9][A-Za-z0-9.+_-]*$ ]] \
|| die "$(translate "Invalid repair version:") $target_version"
;;
*) die "$(translate "Unsupported pre-start repair:") $repair_type" ;;
esac
mount_ct_rootfs
msg_info "$(translate "Checking the image compatibility:") $repair_id..."
case "$check_type" in
python-import)
chroot "$rootfs" "$python_path" -c "import ${module}" >/dev/null 2>&1 \
&& check_passed=1
;;
python-package-minimum-version)
chroot "$rootfs" "$python_path" -c \
'from importlib.metadata import version; from packaging.version import Version; import sys; sys.exit(0 if Version(version(sys.argv[1])) >= Version(sys.argv[2]) else 1)' \
"$check_package" "$minimum_version" >/dev/null 2>&1 && check_passed=1
;;
esac
if (( check_passed == 1 )); then
oci_log "Compatibility check passed for $repair_id; no changes applied"
else
if [[ $repair_type == pip-reinstall-installed-version ]]; then
target_version=$(chroot "$rootfs" "$python_path" -c \
'import importlib.metadata,sys; print(importlib.metadata.version(sys.argv[1]))' \
"$package" 2>/dev/null) || failed=1
if (( failed == 0 )) && [[ ! $target_version =~ ^[A-Za-z0-9][A-Za-z0-9.+_-]*$ ]]; then
oci_log "Invalid package version for $package: $target_version"
failed=1
fi
fi
if (( failed == 0 )); then
oci_log "Compatibility check failed; installing ${package}==${target_version} without changing dependencies"
pip_args=(--disable-pip-version-check --no-cache-dir --force-reinstall)
[[ $break_system_packages == true ]] && pip_args+=(--break-system-packages)
[[ $no_dependencies == true ]] && pip_args+=(--no-deps)
oci_quiet chroot "$rootfs" "$python_path" -m pip install "${pip_args[@]}" \
--index-url "$index_url" "${package}==${target_version}" || failed=1
fi
if (( failed == 0 )); then
check_passed=0
case "$check_type" in
python-import)
chroot "$rootfs" "$python_path" -c "import ${module}" >/dev/null 2>&1 \
&& check_passed=1
;;
python-package-minimum-version)
chroot "$rootfs" "$python_path" -c \
'from importlib.metadata import version; from packaging.version import Version; import sys; sys.exit(0 if Version(version(sys.argv[1])) >= Version(sys.argv[2]) else 1)' \
"$check_package" "$minimum_version" >/dev/null 2>&1 && check_passed=1
;;
esac
(( check_passed == 1 )) || failed=1
repaired=1
fi
fi
oci_quiet pct unmount "$VMID" || failed=1
(( failed == 0 )) || die "$(translate "Could not apply the pre-start repair:") $repair_id"
if (( repaired == 1 )); then
msg_ok "$(translate "Image compatibility restored:") ${package}==${target_version}"
else
msg_ok "$(translate "Image compatibility verified:") $repair_id"
fi
}
apply_post_start_configuration() {
local configuration=$1 type configuration_id timeout required path found=0 elapsed=0
local rootfs="/var/lib/lxc/${VMID}/rootfs" mounted=0 failed=0
configuration_id=$(jq -er '.id' <<<"$configuration")
type=$(jq -er '.type' <<<"$configuration")
timeout=$(jq -r '.timeout_seconds // 120' <<<"$configuration")
required=$(jq -r '.required // true' <<<"$configuration")
[[ $timeout =~ ^[0-9]+$ && $timeout -gt 0 ]] \
|| die "$(translate "Invalid post-start timeout in the configuration:") $configuration_id"
case "$type" in
jellyfin-encoding-xml)
msg_info "$(translate "Waiting for the initial Jellyfin configuration...")"
while (( elapsed < timeout )); do
while IFS= read -r path; do
[[ $path == /* && $path != *[[:space:]]* ]] \
|| die "$(translate "Invalid Jellyfin path:") $path"
if pct exec "$VMID" -- test -f "$path" >/dev/null 2>&1; then
found=1
break
fi
done < <(jq -r '.candidate_paths[]?' <<<"$configuration")
(( found == 1 )) && break
sleep 2
elapsed=$((elapsed + 2))
msg_progress "$(translate "Waiting for the initial Jellyfin configuration...") ${elapsed}/${timeout} s"
done
if (( found == 0 )); then
if [[ $required == true ]]; then
die "$(translate "Jellyfin did not create encoding.xml before the timeout")"
fi
msg_warn "$(translate "Skipped because Jellyfin did not create encoding.xml:") $configuration_id"
return 0
fi
msg_info "$(translate "Applying the Jellyfin configuration:") $configuration_id..."
if ! pct shutdown "$VMID" --timeout "$SHUTDOWN_TIMEOUT" >/dev/null 2>&1; then
if [[ $(pct status "$VMID" 2>/dev/null || true) == "status: running" ]]; then
oci_quiet pct stop "$VMID" \
|| die "$(translate "The container did not stop to update its persistent configuration:") CT $VMID"
fi
fi
[[ $(pct status "$VMID" 2>/dev/null || true) == "status: stopped" ]] \
|| die "$(translate "The container did not stop to update its persistent configuration:") CT $VMID"
mount_ct_rootfs
mounted=1
oci_quiet python3 "$JELLYFIN_CONFIGURATOR" "$rootfs" "$configuration" || failed=1
oci_quiet pct unmount "$VMID" || failed=1
mounted=0
if (( failed != 0 )); then
(( mounted == 0 )) || pct unmount "$VMID" >/dev/null 2>&1 || true
die "$(translate "Could not apply the Jellyfin configuration:") $configuration_id"
fi
oci_quiet pct start "$VMID" || die "$(translate "The container could not be started:") CT $VMID"
msg_ok "$(translate "Jellyfin configuration applied:") $configuration_id"
;;
*) die "$(translate "Unsupported post-start configuration:") $type" ;;
esac
}
# $1: optional result label shown before the address.
detect_container_ipv4() {
local attempt addresses label=${1:-}
if [[ -n ${HOST_MONITOR:-} ]]; then
IP=$HOST_MONITOR_IP
msg_ok "${label:+$label · }$(translate "IP address:") $IP"
return 0
fi
IP=""
for attempt in $(seq 1 15); do
IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
if [[ -z $IP ]]; then
addresses=$(pct exec "$VMID" -- hostname -I 2>/dev/null || true)
IP=$(tr ' ' '\n' <<<"$addresses" | grep -m1 -E '^[0-9]+\.' || true)
fi
[[ -n $IP ]] && break
msg_progress "$(translate "Waiting for the network address...") $((attempt * 2))/30 s"
sleep 2
done
if [[ -n $IP ]]; then
msg_ok "${label:+$label · }$(translate "IP address:") $IP"
else
[[ -z $label ]] || msg_ok "$label"
msg_warn "$(translate "No IPv4 address was detected after 30 seconds.")"
fi
}
trap cleanup_failed_install EXIT
# A credential the image prints on its boot console, captured with the regular
# expression of the template (one capture group, the last match wins).
capture_console_credential() {
local logfile=$1 pattern=$2 timeout=$3
local elapsed=0 password=""
while (( elapsed < timeout )); do
if [[ -s $logfile ]]; then
password=$(tr -d '\r' <"$logfile" | python3 -c 'import re, sys
matches = re.findall(sys.argv[1], sys.stdin.read())
print(matches[-1] if matches else "")' "$pattern")
[[ -n $password ]] && break
fi
sleep 1
elapsed=$((elapsed + 1))
msg_progress "$(translate "Waiting for the temporary password...") ${elapsed}/${timeout} s" >&2
done
printf '%s' "$password"
}
# A credential that the application writes into a file of its own container.
capture_container_file_credential() {
local vmid=$1 path=$2 pattern=$3 timeout=$4
local elapsed=0 content="" value=""
while (( elapsed < timeout )); do
content=$(pct exec "$vmid" -- cat "$path" 2>/dev/null) || content=""
if [[ -n $content ]]; then
if [[ -n $pattern ]]; then
value=$(printf '%s' "$content" | python3 -c 'import re, sys
match = re.search(sys.argv[1], sys.stdin.read(), re.M)
print(match.group(1) if match else "")' "$pattern")
else
value=$(head -n 1 <<<"$content")
fi
value=$(tr -d '\r\n' <<<"$value")
[[ -n $value ]] && { printf '%s' "$value"; return 0; }
fi
sleep 3
elapsed=$((elapsed + 3))
msg_progress "$(translate "Waiting for the password of the application...") ${elapsed}/${timeout} s" >&2
done
return 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$(translate "Required command not found:") $1"
}
json_value() {
jq -er "$1" "$2"
}
append_deployment_environment_csv() {
local name=$1 value=$2 temporary
temporary=$(mktemp)
jq --arg name "$name" --arg value "$value" '
.environment = ((.environment // []) as $environment |
if ($environment | any(.name == $name)) then
$environment | map(
if .name == $name then
.value = (((.value // "") | split(",")) + [$value]
| map(select(length > 0)) | unique | join(","))
else . end
)
else
$environment + [{name: $name, value: $value, sensitive: false}]
end)
' "$DEPLOYMENT_FILE" >"$temporary"
cat "$temporary" >"$DEPLOYMENT_FILE"
rm -f "$temporary"
}
set_lxc_directive() {
local key=$1 value=$2 escaped_key temporary_conf
escaped_key=${key//./\.}
temporary_conf=$(mktemp)
sed -E "/^${escaped_key}:/d" "$CONF" >"$temporary_conf"
printf '%s: %s\n' "$key" "$value" >>"$temporary_conf"
cat "$temporary_conf" >"$CONF"
rm -f "$temporary_conf"
}
add_device() {
local host_path=$1 expected_type=$2 mode_spec=$3 gid_strategy=$4 deny_write=$5
local device_uid=${6:-}
local mode gid dev_value
[[ $host_path == /dev/* && $host_path != *[[:space:]]* && $host_path != *","* ]] \
|| die "$(translate "Invalid device path:") $host_path"
if [[ $expected_type == character ]]; then
[[ -c $host_path ]] || die "$(translate "The character device does not exist:") $host_path"
elif [[ $expected_type == block ]]; then
[[ -b $host_path ]] || die "$(translate "The block device does not exist:") $host_path"
else
die "$(translate "Unsupported native device type:") $expected_type"
fi
if [[ $mode_spec == preserve-host ]]; then
mode=$(stat -c '%a' "$host_path")
mode="0${mode}"
else
mode=$mode_spec
fi
[[ $mode =~ ^0?[0-7]{3}$ ]] || die "$(translate "Invalid device mode:") $mode"
dev_value="path=${host_path},mode=${mode},deny-write=${deny_write}"
if [[ -n $device_uid ]]; then
[[ $device_uid =~ ^[0-9]{1,10}$ ]] && (( device_uid < 4294967295 )) \
|| die "$(translate "Invalid device UID:") $device_uid"
dev_value="${dev_value},uid=${device_uid}"
fi
if [[ $gid_strategy == host-device-gid ]]; then
gid=$(stat -c '%g' "$host_path")
dev_value="${dev_value},gid=${gid}"
elif [[ $gid_strategy != none ]]; then
die "$(translate "Unsupported GID strategy:") $gid_strategy"
fi
oci_quiet pct set "$VMID" "--dev${DEVICE_INDEX}" "$dev_value" \
|| die "$(translate "Could not add the device to the container:") $host_path"
if [[ $expected_type == character ]]; then
RESOLVED_CHARACTER_DEVICES+=("$host_path")
fi
DEVICE_INDEX=$((DEVICE_INDEX + 1))
}
add_character_device() {
add_device "$1" character "$2" "$3" "$4" "${5:-}"
}
prepare_nvidia_driver_links() {
local source=$1 target=$2
local rootfs="/var/lib/lxc/${VMID}/rootfs"
local host_link link_target container_link failed=0
[[ $target == /usr/lib/* ]] || return 0
mount_ct_rootfs
while IFS= read -r host_link; do
[[ $(readlink -f "$host_link") == "$source" ]] || continue
link_target=$(readlink "$host_link")
container_link="${rootfs}${host_link}"
if [[ -e $container_link && ! -f $container_link && ! -L $container_link ]]; then
oci_log "The NVIDIA link cannot replace this target: $host_link"
failed=1
break
fi
ensure_rootfs_directory "$rootfs" "$(dirname "$container_link")" || failed=1
rm -f "$container_link" || failed=1
ln -s "$link_target" "$container_link" || failed=1
done < <(find "$(dirname "$target")" -maxdepth 1 -type l -print 2>/dev/null | sort)
oci_quiet pct unmount "$VMID" || failed=1
(( failed == 0 )) || die "$(translate "Could not prepare the NVIDIA driver links")"
}
source "$SCRIPT_DIR/oci_nvidia_setup.sh"
apply_extra_hosts() {
local count rootfs hosts_file temporary address hostname bridge_address failed=0
count=$(jq '.extra_hosts? // [] | length' "$DEPLOYMENT_FILE")
(( count > 0 )) || return 0
rootfs="/var/lib/lxc/${VMID}/rootfs"
hosts_file="${rootfs}/etc/hosts"
bridge_address=$(ip -4 -o addr show dev "$BRIDGE" 2>/dev/null \
| awk '{split($4, parts, "/"); print parts[1]; exit}')
mount_ct_rootfs
if [[ ! -e $hosts_file ]]; then
install -D -m 0644 /dev/null "$hosts_file" || failed=1
fi
temporary=$(mktemp)
awk '
$0 == "# BEGIN PROXMENUX EXTRA HOSTS" {skip=1; next}
$0 == "# END PROXMENUX EXTRA HOSTS" {skip=0; next}
!skip {print}
' "$hosts_file" >"$temporary" || failed=1
if (( failed == 0 )); then
printf '\n# BEGIN PROXMENUX EXTRA HOSTS\n' >>"$temporary"
while IFS=$'\t' read -r hostname address; do
[[ -n $hostname ]] || continue
if [[ $address == host-gateway ]]; then
[[ -n $bridge_address ]] \
|| { oci_log "Could not resolve host-gateway on $BRIDGE"; failed=1; break; }
address=$bridge_address
fi
[[ $hostname =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]] \
|| { oci_log "Invalid extra hostname: $hostname"; failed=1; break; }
[[ $address =~ ^[0-9A-Fa-f:.]+$ ]] \
|| { oci_log "Invalid extra host address: $address"; failed=1; break; }
printf '%s %s\n' "$address" "$hostname" >>"$temporary"
done < <(jq -r '.extra_hosts[]? | [.hostname,.address] | @tsv' "$DEPLOYMENT_FILE")
printf '# END PROXMENUX EXTRA HOSTS\n' >>"$temporary"
fi
if (( failed == 0 )); then
cat "$temporary" >"$hosts_file" || failed=1
chown "$HOST_ROOT_UID:$HOST_ROOT_GID" "$hosts_file" || failed=1
fi
rm -f "$temporary"
oci_quiet pct unmount "$VMID" || failed=1
(( failed == 0 )) || die "$(translate "Could not apply the Compose extra hosts")"
}
# Settings an update gives back to the rebuilt container before it starts:
# the LAN leg of a suite or stack member and its start order.
apply_kept_settings() {
local key value
while IFS=$'\t' read -r key value; do
[[ -n $key ]] || continue
[[ $key == net1 || $key == startup ]] \
|| die "$(translate "Unsupported kept setting:") $key"
oci_quiet pct set "$VMID" "--$key" "$value" \
|| die "$(translate "Could not restore the container setting:") $key"
done < <(jq -r '.kept_proxmox_settings // {} | to_entries[] | [.key, .value] | @tsv' "$DEPLOYMENT_FILE")
}
apply_runtime_user() {
local user_spec=$1 rootfs passwd_file group_file user_part group_part uid gid failed=0
rootfs="/var/lib/lxc/${VMID}/rootfs"
passwd_file="${rootfs}/etc/passwd"
group_file="${rootfs}/etc/group"
user_part=${user_spec%%:*}
group_part=""
[[ $user_spec == *:* ]] && group_part=${user_spec#*:}
mount_ct_rootfs
if [[ $user_part =~ ^[0-9]+$ ]]; then
uid=$user_part
gid=$(awk -F: -v id="$uid" '$3 == id {print $4; exit}' "$passwd_file" 2>/dev/null || true)
else
uid=$(awk -F: -v name="$user_part" '$1 == name {print $3; exit}' "$passwd_file" 2>/dev/null || true)
gid=$(awk -F: -v name="$user_part" '$1 == name {print $4; exit}' "$passwd_file" 2>/dev/null || true)
fi
if [[ -n $group_part ]]; then
if [[ $group_part =~ ^[0-9]+$ ]]; then
gid=$group_part
else
gid=$(awk -F: -v name="$group_part" '$1 == name {print $3; exit}' "$group_file" 2>/dev/null || true)
fi
fi
oci_quiet pct unmount "$VMID" || failed=1
[[ $uid =~ ^[0-9]+$ && $gid =~ ^[0-9]+$ ]] || failed=1
(( failed == 0 )) || die "$(translate "Could not resolve the Compose user:") $user_spec"
set_lxc_directive lxc.init.uid "$uid"
set_lxc_directive lxc.init.gid "$gid"
}
apply_runtime_groups() {
local groups_csv=$1 rootfs="/var/lib/lxc/${VMID}/rootfs"
local group_file="${rootfs}/etc/group" existing resolved group failed=0
local combined=""
existing=$(awk -F': ' '$1 == "lxc.init.groups" {print $2; exit}' "$CONF" 2>/dev/null || true)
[[ -n $existing ]] && combined=$existing
mount_ct_rootfs
IFS=',' read -ra GROUP_SPECS <<<"$groups_csv"
for group in "${GROUP_SPECS[@]}"; do
[[ -n $group ]] || continue
if [[ $group =~ ^[0-9]+$ ]]; then
resolved=$group
else
resolved=$(awk -F: -v name="$group" '$1 == name {print $3; exit}' "$group_file" 2>/dev/null || true)
fi
if [[ ! $resolved =~ ^[0-9]+$ ]]; then
oci_log "Could not resolve the Compose supplementary group: $group"
failed=1
break
fi
if [[ ",${combined}," != *",${resolved},"* ]]; then
combined="${combined:+${combined},}${resolved}"
fi
done
oci_quiet pct unmount "$VMID" || failed=1
(( failed == 0 )) || die "$(translate "Could not apply the Compose supplementary groups")"
[[ -n $combined ]] && set_lxc_directive lxc.init.groups "$combined"
}
apply_installer_profile() {
local generated_count preparation_count tls_count seeded_volumes mounted=0 failed=0
local rootfs="/var/lib/lxc/${VMID}/rootfs"
local encoded item path mode owner destination target remove_lost_found owner_strategy
local only_when_mount_type selected_mount_type
local entrypoint working_directory halt_signal command_json compose_entrypoint_json user_spec
local supplemental_groups tls_config cert_path key_path cert_destination key_destination
local tls_directory valid_days common_name san
local generated_created=0 volumes_prepared=0 tls_state=""
generated_count=$(jq '.proxmox.installer_profile.generated_files? // [] | length' "$TEMPLATE_FILE")
preparation_count=$(jq '.proxmox.installer_profile.volume_preparations? // [] | length' "$TEMPLATE_FILE")
tls_count=$(jq 'if .proxmox.installer_profile.self_signed_tls? then 1 else 0 end' "$TEMPLATE_FILE")
seeded_volumes=${#VOLUME_SEED_TARGETS[@]}
if (( generated_count > 0 || preparation_count > 0 || tls_count > 0 || seeded_volumes > 0 )); then
mount_ct_rootfs
mounted=1
fi
if (( seeded_volumes > 0 )); then
apply_volume_seeds || failed=1
fi
while IFS= read -r encoded; do
[[ -n $encoded ]] || continue
item=$(printf '%s' "$encoded" | base64 -d)
path=$(jq -er '.container_path' <<<"$item")
mode=$(jq -er '.mode' <<<"$item")
owner=$(jq -er '.owner' <<<"$item")
[[ $path == /* && $path != *[[:space:]]* && $path != *","* ]] \
|| { oci_log "Invalid generated file path: $path"; failed=1; break; }
[[ $mode =~ ^0?[0-7]{3}$ ]] \
|| { oci_log "Invalid generated file mode: $mode"; failed=1; break; }
destination="${rootfs}${path}"
[[ ! -L $destination ]] \
|| { oci_log "A generated file is not written over a link: $path"; failed=1; break; }
[[ ! -e $destination || -f $destination ]] \
|| { oci_log "The generated file target is not a regular file: $path"; failed=1; break; }
if [[ $(jq -r '.json_defaults // false' <<<"$item") == true ]]; then
local merged_file
merged_file=$(mktemp)
if ! merge_json_defaults \
<(if [[ -e $destination ]]; then cat "$destination"; else printf '{}'; fi) \
<(jq -er '.content' <<<"$item") >"$merged_file" 2>>"${OCI_LOG:-/dev/stderr}"; then
rm -f "$merged_file"
oci_log "Cannot merge the default JSON values: $path"
failed=1; break
fi
item=$(jq --rawfile content "$merged_file" '.content=$content | .only_if_missing=false' <<<"$item")
rm -f "$merged_file"
fi
if [[ $(jq -r '.only_if_missing // false' <<<"$item") == true && -e $destination ]]; then
oci_log "Keeping the existing persistent file: $path"
continue
fi
install -D -m "$mode" /dev/null "$destination" || { failed=1; break; }
jq -er '.content' <<<"$item" >"$destination" || { failed=1; break; }
case "$owner" in
mapped-root) chown "$HOST_ROOT_UID:$HOST_ROOT_GID" "$destination" || failed=1 ;;
mapped-application-user) chown "$HOST_BIND_UID:$HOST_BIND_GID" "$destination" || failed=1 ;;
*) oci_log "Unsupported generated file owner: $owner"; failed=1 ;;
esac
(( failed == 0 )) || break
oci_log "Generated file created: $path"
generated_created=$((generated_created + 1))
done < <(jq -r '.proxmox.installer_profile.generated_files[]? | @base64' "$TEMPLATE_FILE")
if (( failed == 0 )); then
while IFS= read -r encoded; do
[[ -n $encoded ]] || continue
item=$(printf '%s' "$encoded" | base64 -d)
target=$(jq -er '.container_path' <<<"$item")
remove_lost_found=$(jq -r '.remove_lost_found // false' <<<"$item")
owner_strategy=$(jq -er '.owner_strategy' <<<"$item")
only_when_mount_type=$(jq -r '.only_when_mount_type // empty' <<<"$item")
[[ $target == /* && $target != *[[:space:]]* && $target != *","* ]] \
|| { oci_log "Invalid volume preparation path: $target"; failed=1; break; }
if [[ -n $only_when_mount_type ]]; then
selected_mount_type=$(jq -r --arg target "$target" \
'[.mounts[]? | select(.container_path == $target) | .type] | first // empty' \
"$DEPLOYMENT_FILE")
if [[ $selected_mount_type != "$only_when_mount_type" ]]; then
oci_log "Skipping the preparation of $target for mount type ${selected_mount_type:-none}"
continue
fi
fi
[[ -d ${rootfs}${target} ]] \
|| { oci_log "The volume to prepare does not exist: $target"; failed=1; break; }
if [[ $remove_lost_found == true ]]; then
rm -rf "${rootfs}${target}/lost+found" || { failed=1; break; }
fi
case "$owner_strategy" in
mapped-root) chown "$HOST_ROOT_UID:$HOST_ROOT_GID" "${rootfs}${target}" || failed=1 ;;
mapped-application-user)
chown "$HOST_BIND_UID:$HOST_BIND_GID" "${rootfs}${target}" || failed=1
;;
*) oci_log "Unsupported owner strategy: $owner_strategy"; failed=1 ;;
esac
(( failed == 0 )) || break
oci_log "Volume prepared before the first start: $target"
volumes_prepared=$((volumes_prepared + 1))
done < <(jq -r '.proxmox.installer_profile.volume_preparations[]? | @base64' "$TEMPLATE_FILE")
fi
if (( failed == 0 && tls_count == 1 )); then
require_command openssl
tls_config=$(jq -c '.proxmox.installer_profile.self_signed_tls' "$TEMPLATE_FILE")
cert_path=$(jq -er '.certificate_path' <<<"$tls_config")
key_path=$(jq -er '.private_key_path' <<<"$tls_config")
valid_days=$(jq -er '.valid_days' <<<"$tls_config")
[[ $cert_path == /* && $key_path == /* ]] \
|| { oci_log "The TLS paths must be absolute"; failed=1; }
[[ $valid_days =~ ^[0-9]+$ && $valid_days -ge 1 ]] \
|| { oci_log "Invalid TLS validity"; failed=1; }
cert_destination="${rootfs}${cert_path}"
key_destination="${rootfs}${key_path}"
tls_directory=$(dirname "$cert_destination")
install -d -m 0700 "$tls_directory" || failed=1
if (( failed == 0 )); then
chown "$HOST_ROOT_UID:$HOST_ROOT_GID" "$tls_directory" || failed=1
fi
if (( failed == 0 )) && [[ ! -s $cert_destination || ! -s $key_destination ]]; then
common_name=$HOSTNAME
san="DNS:${HOSTNAME},DNS:${HOSTNAME}.local"
if [[ $IPV4 != dhcp ]]; then
san="${san},IP:${IPV4%%/*}"
fi
if (( failed == 0 )); then
openssl req -x509 -newkey rsa:3072 -sha256 -nodes \
-days "$valid_days" -subj "/CN=${common_name}" \
-addext "subjectAltName=${san}" \
-keyout "$key_destination" -out "$cert_destination" >/dev/null 2>&1 \
|| failed=1
fi
(( failed != 0 )) || tls_state=created
else
tls_state=reused
fi
if (( failed == 0 )); then
chmod 0600 "$key_destination"
chmod 0644 "$cert_destination"
chown "$HOST_ROOT_UID:$HOST_ROOT_GID" "$key_destination" "$cert_destination"
fi
fi
if (( mounted == 1 )); then
oci_quiet pct unmount "$VMID" || failed=1
fi
(( failed == 0 )) || die "$(translate "Could not apply the installer profile")"
if (( generated_created > 0 )); then
msg_ok "$(translate "Configuration files generated:") $generated_created"
fi
if (( volumes_prepared > 0 )); then
msg_ok "$(translate "Volumes prepared for the first start:") $volumes_prepared"
fi
case "$tls_state" in
created) msg_ok "$(translate "Self-signed TLS certificate created:") $cert_path" ;;
reused) msg_ok "$(translate "Existing TLS certificate reused:") $cert_path" ;;
esac
entrypoint=$(jq -r '.proxmox.installer_profile.runtime.entrypoint // empty' "$TEMPLATE_FILE")
# An empty Compose command is no command at all: the image keeps its own.
command_json=$(jq -c '(.proxmox.installer_profile.runtime? // {}) as $runtime
| if ($runtime | has("command")) and ($runtime.command != null)
and ((($runtime.command | type) != "array") or (($runtime.command | length) > 0))
then $runtime.command else null end' "$TEMPLATE_FILE")
compose_entrypoint_json=$(jq -c 'if (.proxmox.installer_profile.runtime? // {}) | has("compose_entrypoint") then .proxmox.installer_profile.runtime.compose_entrypoint else null end' "$TEMPLATE_FILE")
working_directory=$(jq -r '.proxmox.installer_profile.runtime.working_directory // empty' "$TEMPLATE_FILE")
user_spec=$(jq -r '.proxmox.installer_profile.runtime.user // empty' "$TEMPLATE_FILE")
supplemental_groups=$(jq -r '.proxmox.installer_profile.runtime.supplemental_groups? // [] | join(",")' "$TEMPLATE_FILE")
halt_signal=$(jq -r '.proxmox.installer_profile.runtime.halt_signal // empty' "$TEMPLATE_FILE")
if [[ -n $entrypoint ]]; then
[[ $entrypoint == /* && $entrypoint != *$'\n'* ]] || die "$(translate "Invalid declarative entrypoint")"
oci_quiet pct set "$VMID" --entrypoint "$entrypoint" \
|| die "$(translate "Could not set the container entrypoint")"
elif [[ $command_json != null || $compose_entrypoint_json != null ]]; then
entrypoint=$(python3 "$OCI_RUNTIME_RESOLVER" "$ARCHIVE_PATH" \
"$command_json" "$compose_entrypoint_json" 2>>"${OCI_LOG:-/dev/stderr}") \
|| die "$(translate "Could not translate the Compose command/entrypoint")"
oci_quiet pct set "$VMID" --entrypoint "$entrypoint" \
|| die "$(translate "Could not set the container entrypoint")"
fi
if [[ -n $working_directory ]]; then
[[ $working_directory == /* && $working_directory != *[[:space:]]* ]] \
|| die "$(translate "Invalid declarative working directory")"
set_lxc_directive lxc.init.cwd "$working_directory"
fi
if [[ -n $user_spec ]]; then
apply_runtime_user "$user_spec"
fi
if [[ -n $supplemental_groups ]]; then
apply_runtime_groups "$supplemental_groups"
fi
if [[ -n $halt_signal ]]; then
[[ $halt_signal =~ ^SIG[A-Z0-9]+$ ]] || die "$(translate "Invalid declarative stop signal")"
set_lxc_directive lxc.signal.halt "$halt_signal"
fi
}
[[ $EUID -eq 0 ]] || die "$(translate "The remote installer must run as root on Proxmox VE")"
for command in pct pvesh pvesm skopeo jq sha256sum python3 mktemp flock; do
require_command "$command"
done
VERIFY_OCI_ARCHIVE="${SCRIPT_DIR}/verify_oci_archive.py"
OCI_RUNTIME_RESOLVER="${SCRIPT_DIR}/oci_runtime.py"
OCI_ROOTFS_UNSHIFTER="${SCRIPT_DIR}/unshift_oci_rootfs.py"
JELLYFIN_CONFIGURATOR="${SCRIPT_DIR}/configure_jellyfin_encoding.py"
[[ -r $VERIFY_OCI_ARCHIVE ]] || die "$(translate "Installer file not found:") $VERIFY_OCI_ARCHIVE"
[[ -r $OCI_RUNTIME_RESOLVER ]] || die "$(translate "Installer file not found:") $OCI_RUNTIME_RESOLVER"
[[ -r $OCI_ROOTFS_UNSHIFTER ]] || die "$(translate "Installer file not found:") $OCI_ROOTFS_UNSHIFTER"
[[ -r $JELLYFIN_CONFIGURATOR ]] || die "$(translate "Installer file not found:") $JELLYFIN_CONFIGURATOR"
APP_ID=$(json_value '.id' "$TEMPLATE_FILE")
LOG_NAME=${APP_ID#image-}
oci_log_init "${LOG_NAME#linuxserver-}"
oci_log "OCI install: $APP_ID (dry run: $DRY_RUN)"
STATUS=$(json_value '.status' "$TEMPLATE_FILE")
IMAGE_REF=$(json_value '.container_contract.image.reference' "$TEMPLATE_FILE")
REVISION=$(json_value '.source.revision' "$TEMPLATE_FILE")
PROVIDER=$(jq -r '.source.provider // "unknown"' "$TEMPLATE_FILE")
CATEGORY=$(jq -r '.catalog_ui.category // "misc"' "$TEMPLATE_FILE")
CATEGORY=$(tr '[:upper:]' '[:lower:]' <<<"$CATEGORY" | tr -cs 'a-z0-9_.-' '-')
CATEGORY=${CATEGORY#-}
CATEGORY=${CATEGORY%-}
[[ -n $CATEGORY && $CATEGORY != linuxserver ]] || CATEGORY="misc"
STARTUP_HEALTHCHECK=$(jq -c '.proxmox.installer_profile.startup_healthcheck? // null' "$TEMPLATE_FILE")
HAOS_HEALTHCHECK=$(jq -r '.proxmox.installer_profile.haos_healthcheck.timeout_seconds? // 0' "$TEMPLATE_FILE")
if [[ $HAOS_HEALTHCHECK != 0 ]]; then
[[ $HAOS_HEALTHCHECK =~ ^[0-9]+$ && $HAOS_HEALTHCHECK -ge 60 && $HAOS_HEALTHCHECK -le 3600 ]] || die "$(translate "Invalid Home Assistant OS check timeout")"
[[ -r ${SCRIPT_DIR}/haos_healthcheck.py ]] || die "$(translate "Installer file not found:") ${SCRIPT_DIR}/haos_healthcheck.py"
fi
HAS_STARTUP_HEALTHCHECK=$(jq -r 'if . == null then 0 else 1 end' <<<"$STARTUP_HEALTHCHECK")
HAS_RUNNING_CHECK=0
if [[ $HAS_STARTUP_HEALTHCHECK == 1 && $(jq -r '.type // "http"' <<<"$STARTUP_HEALTHCHECK") == running ]]; then
HAS_STARTUP_HEALTHCHECK=0
HAS_RUNNING_CHECK=1
fi
if [[ $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
require_command curl
fi
HOST_MODULE_COUNT=$(jq '.security.host_modules? // [] | length' "$DEPLOYMENT_FILE")
if (( HOST_MODULE_COUNT > 0 )); then
require_command modprobe
while IFS= read -r HOST_MODULE; do
[[ $HOST_MODULE =~ ^[A-Za-z0-9_-]+$ ]] \
|| die "$(translate "Invalid host kernel module name:") $HOST_MODULE"
msg_info "$(translate "Loading the host kernel module:") $HOST_MODULE..."
oci_quiet modprobe "$HOST_MODULE" || die "$(translate "Could not load the host kernel module:") $HOST_MODULE"
MODULE_SYSFS=${HOST_MODULE//-/_}
[[ -d /sys/module/$MODULE_SYSFS ]] \
|| die "$(translate "The kernel module is not active:") $HOST_MODULE"
msg_ok "$(translate "Host kernel module loaded:") $HOST_MODULE"
done < <(jq -r '.security.host_modules[]?' "$DEPLOYMENT_FILE")
fi
VMID=$(jq -r '.vmid // empty' "$DEPLOYMENT_FILE")
if [[ -z $VMID ]]; then
VMID=$(pvesh get /cluster/nextid)
fi
[[ $VMID =~ ^[0-9]+$ ]] || die "$(translate "Invalid VMID:") $VMID"
pct config "$VMID" >/dev/null 2>&1 && die "$(translate "The CT already exists:") $VMID"
HOSTNAME=$(json_value '.hostname' "$DEPLOYMENT_FILE")
ROOTFS_STORAGE=$(json_value '.rootfs.storage' "$DEPLOYMENT_FILE")
ROOTFS_SIZE=$(json_value '.rootfs.size_gb' "$DEPLOYMENT_FILE")
TEMPLATE_STORAGE=$(json_value '.template_storage' "$DEPLOYMENT_FILE")
CORES=$(json_value '.resources.cores' "$DEPLOYMENT_FILE")
CPU_UNITS=$(jq -r '.resources.cpu_units // empty' "$DEPLOYMENT_FILE")
MEMORY=$(json_value '.resources.memory_mb' "$DEPLOYMENT_FILE")
validate_rlimits
SWAP=$(json_value '.resources.swap_mb' "$DEPLOYMENT_FILE")
BRIDGE=$(json_value '.network.bridge' "$DEPLOYMENT_FILE")
IPV4=$(json_value '.network.ipv4' "$DEPLOYMENT_FILE")
MAC_ADDRESS=$(jq -r '.network.mac_address // empty' "$DEPLOYMENT_FILE")
GATEWAY=$(jq -r '.network.gateway // empty' "$DEPLOYMENT_FILE")
FIREWALL=$(json_value '.network.firewall | if . then 1 else 0 end' "$DEPLOYMENT_FILE")
validate_host_monitor
validate_host_monitor_firewall
ONBOOT=$(json_value '.onboot | if . then 1 else 0 end' "$DEPLOYMENT_FILE")
START_AFTER=$(json_value '.start_after_create | if . then 1 else 0 end' "$DEPLOYMENT_FILE")
SHUTDOWN_TIMEOUT=$(json_value '.shutdown_timeout_seconds' "$DEPLOYMENT_FILE")
[[ $SHUTDOWN_TIMEOUT =~ ^[0-9]+$ && $SHUTDOWN_TIMEOUT -gt 0 ]] \
|| die "$(translate "Invalid shutdown timeout")"
ARCH=$(dpkg --print-architecture)
case "$ARCH" in
amd64|arm64) ;;
*) die "$(translate "Unsupported architecture:") $ARCH" ;;
esac
jq -e --arg architecture "$ARCH" '.catalog_ui.architectures | index($architecture) != null' \
"$TEMPLATE_FILE" >/dev/null || die "$(translate "The image does not declare support for this architecture:") $ARCH"
SELECTED_HARDWARE_PROFILE=$(jq -r '.hardware_profile // empty' "$DEPLOYMENT_FILE")
if [[ -n $SELECTED_HARDWARE_PROFILE ]]; then
jq -e --arg profile "$SELECTED_HARDWARE_PROFILE" --arg architecture "$ARCH" '
[.proxmox.installer_profile.hardware_acceleration.profiles[]?
| select(.id == $profile)]
| if length == 1 then
((.[0].architectures // ["amd64", "arm64"]) | index($architecture) != null)
else false end
' "$TEMPLATE_FILE" >/dev/null \
|| die "$(translate "The acceleration profile does not support this architecture:") $SELECTED_HARDWARE_PROFILE ($ARCH)"
fi
show_gpu_inventory
validate_gpu_vendors
oci_log "Application: $APP_ID; image: $IMAGE_REF; CT $VMID ($HOSTNAME); architecture: $ARCH"
if [[ $DRY_RUN == 1 ]]; then
msg_info2 "$(translate "Application:") $APP_ID"
msg_info2 "$(translate "Image:") $IMAGE_REF"
msg_info2 "CT $VMID ($HOSTNAME)"
msg_info2 "$(translate "Architecture:") $ARCH"
msg_ok "$(translate "Remote dry run completed; no container was created.")"
exit 0
fi
# Hold the registry lock through installation so reconciliation cannot race it.
INSTANCE_ROOT=${PROXMENUX_OCI_INSTANCE_ROOT:-/usr/local/share/proxmenux/oci/instances}
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
INSTANCE_ID=$(python3 "${SCRIPT_DIR}/oci_instance_transaction.py" --root "$INSTANCE_ROOT" \
authorize-candidate "$VMID" --journal "$PROXMENUX_OCI_TRANSACTION" \
--template "$TEMPLATE_FILE" --deployment "$DEPLOYMENT_FILE")
else
[[ ! -L $INSTANCE_ROOT && ! -L $INSTANCE_ROOT/.lock ]] || die "$(translate "The instance registry is not safe")"
install -d -m 0700 "$INSTANCE_ROOT"
exec 9>>"$INSTANCE_ROOT/.lock"
chmod 600 "$INSTANCE_ROOT/.lock"
flock -n 9 || die "$(translate "Another OCI operation is using the instance registry")"
export PROXMENUX_INSTANCE_LOCK_FD=9
# pct start spawns long-lived monitors; they must not retain our registry lock.
pct() { command pct "$@" 9>&-; }
INSTANCE_ID=$(python3 "${SCRIPT_DIR}/oci_instances.py" prepare "$VMID" \
--template "$TEMPLATE_FILE" --deployment "$DEPLOYMENT_FILE")
INSTANCE_CONTRACT="$INSTANCE_ROOT/$VMID/oci-compose.json"
# The persisted contract is the source for the actual installation inputs.
# Do not rewrite caller-owned files, notably a regular user's file in sticky /tmp.
CANONICAL_INPUT_DIR=$(mktemp -d "/var/tmp/proxmenux-oci-inputs-${VMID}.XXXXXX") \
|| die "$(translate "Could not prepare canonical installation inputs")"
jq '.template' "$INSTANCE_CONTRACT" >"${CANONICAL_INPUT_DIR}/template.json"
jq '.deployment' "$INSTANCE_CONTRACT" >"${CANONICAL_INPUT_DIR}/deployment.json"
TEMPLATE_FILE="${CANONICAL_INPUT_DIR}/template.json"
DEPLOYMENT_FILE="${CANONICAL_INPUT_DIR}/deployment.json"
fi
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 image=$1 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 registry for ${image} (${ARCH})"
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
[[ -z ${OCI_LOG:-} ]] || cat "$error_file" >>"$OCI_LOG"
rm -f "$manifest_file" "$error_file"
return "$status"
fi
oci_log "Manifest resolved in ${elapsed}s"
cat "$manifest_file"
rm -f "$manifest_file" "$error_file"
}
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
ARCHIVE_PATH=$(jq -er '.archive' "$PROXMENUX_OCI_TRANSACTION")
ARCHIVE_VOLUME=$ARCHIVE_PATH
DIGEST=$(jq -er '.registry_digest' "$PROXMENUX_OCI_TRANSACTION")
CREATED=""
IMAGE_VERSION=""
msg_ok "$(translate "Using the image verified by the transaction")"
else
SKOPEO_IMAGE_REF=$(skopeo_transport_reference "$IMAGE_REF")
if [[ $SKOPEO_IMAGE_REF != "$IMAGE_REF" ]]; then
oci_log "Digest-pinned reference; Skopeo-compatible form: $SKOPEO_IMAGE_REF"
fi
msg_info "$(translate "Checking the image in the registry...")"
INSPECT=$(resolve_image_manifest "$SKOPEO_IMAGE_REF") \
|| die "$(translate "Could not resolve the OCI manifest of the image:") $IMAGE_REF"
DIGEST=$(jq -er '.Digest' <<<"$INSPECT")
oci_log "Selected digest: $DIGEST"
CREATED=$(jq -r '.Created // empty' <<<"$INSPECT")
IMAGE_VERSION=$(jq -r '.Labels["org.opencontainers.image.version"] // .Labels.build_version // empty' <<<"$INSPECT")
TOTAL_LAYER_BYTES=$(jq -r '[.LayersData[]?.Size] | add // 0' <<<"$INSPECT")
TOTAL_LAYER_COUNT=$(jq -r '[.LayersData[]?] | length' <<<"$INSPECT")
DIGEST_SHORT=${DIGEST#sha256:}
DIGEST_SHORT=${DIGEST_SHORT:0:16}
msg_ok "$(translate "Image:") $IMAGE_REF (${IMAGE_VERSION:-sha256:${DIGEST_SHORT:0:12}})"
SAFE_APP=$(tr -cs 'a-zA-Z0-9._-' '_' <<<"$APP_ID" | sed 's/_$//')
ARCHIVE_NAME="${SAFE_APP}_${ARCH}_${DIGEST_SHORT}.tar"
ARCHIVE_VOLUME="${TEMPLATE_STORAGE}:vztmpl/${ARCHIVE_NAME}"
ARCHIVE_PATH=$(pvesm path "$ARCHIVE_VOLUME")
mkdir -p "$(dirname "$ARCHIVE_PATH")"
download_and_verify_archive() {
local attempt=$1
local partial_path="${ARCHIVE_PATH}.partial.$$"
local download_log="${partial_path}.log"
local copy_pid elapsed current_bytes process_bytes display_bytes current_mib total_mib
local percentage copy_status started_layers last_bytes=-1 stalled_seconds=0
local stall_timeout_seconds=180
rm -f "$partial_path" "$download_log"
oci_log "Downloading $IMAGE_REF by digest $DIGEST (attempt $attempt/2)"
skopeo copy --override-os linux --override-arch "$ARCH" \
--retry-times 3 --retry-delay 5s --image-parallel-copies 1 \
"docker://$SKOPEO_IMAGE_REF" "oci-archive:${partial_path}:${APP_ID}" \
>"$download_log" 2>&1 &
copy_pid=$!
elapsed=0
total_mib=$(( (TOTAL_LAYER_BYTES + 1048575) / 1048576 ))
while kill -0 "$copy_pid" 2>/dev/null; do
current_bytes=$(stat -c %s "$partial_path" 2>/dev/null || printf '0')
process_bytes=$(awk '$1 == "rchar:" { print $2 }' "/proc/${copy_pid}/io" 2>/dev/null || printf '0')
if (( process_bytes > current_bytes )); then
current_bytes=$process_bytes
fi
if (( current_bytes > last_bytes )); then
last_bytes=$current_bytes
stalled_seconds=0
else
stalled_seconds=$((stalled_seconds + 2))
fi
started_layers=$(awk '/^Copying blob / { count++ } END { print count + 0 }' "$download_log" 2>/dev/null || printf '0')
if (( TOTAL_LAYER_BYTES > 0 )); then
display_bytes=$current_bytes
if (( display_bytes >= TOTAL_LAYER_BYTES )); then
display_bytes=$((TOTAL_LAYER_BYTES * 99 / 100))
fi
current_mib=$(( display_bytes / 1048576 ))
percentage=$(( display_bytes * 100 / TOTAL_LAYER_BYTES ))
if (( started_layers > TOTAL_LAYER_COUNT )); then
started_layers=$TOTAL_LAYER_COUNT
fi
if (( percentage > 99 )); then
percentage=99
fi
msg_progress "$(translate "Downloading the image:") ${current_mib} / ${total_mib} MiB (${percentage}%), $(translate "layers") ${started_layers}/${TOTAL_LAYER_COUNT}, ${elapsed}s"
else
current_mib=$(( current_bytes / 1048576 ))
msg_progress "$(translate "Downloading the image:") ${current_mib} MiB, ${elapsed}s"
fi
if (( stalled_seconds >= stall_timeout_seconds )); then
oci_log "The download made no progress for ${stall_timeout_seconds}s; cancelling this attempt"
msg_warn "$(translate "The download stopped progressing; cancelling this attempt.")"
kill "$copy_pid" 2>/dev/null || true
sleep 2
kill -9 "$copy_pid" 2>/dev/null || true
break
fi
sleep 2
elapsed=$((elapsed + 2))
done
copy_status=0
wait "$copy_pid" || copy_status=$?
if (( copy_status != 0 )); then
[[ -z ${OCI_LOG:-} ]] || cat "$download_log" >>"$OCI_LOG"
rm -f "$partial_path" "$download_log"
oci_log "The image download failed (exit code $copy_status)"
return 1
fi
[[ -z ${OCI_LOG:-} ]] || cat "$download_log" >>"$OCI_LOG"
rm -f "$download_log"
msg_ok "$(translate "Image downloaded") ($(( ($(stat -c %s "$partial_path") + 1048575) / 1048576 )) MiB)"
msg_info "$(translate "Verifying the image integrity...")"
if ! oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$partial_path"; then
rm -f "$partial_path"
return 1
fi
mv -f "$partial_path" "$ARCHIVE_PATH"
msg_ok "$(translate "Image integrity verified")"
return 0
}
if [[ -s $ARCHIVE_PATH ]]; then
oci_log "Verifying the cached image: $ARCHIVE_VOLUME"
msg_info "$(translate "Verifying the image integrity...")"
if oci_quiet python3 "$VERIFY_OCI_ARCHIVE" "$ARCHIVE_PATH"; then
msg_ok "$(translate "Using the verified image from the cache")"
else
msg_warn "$(translate "The cached image is damaged; it will be downloaded again.")"
rm -f "$ARCHIVE_PATH"
fi
fi
if [[ ! -s $ARCHIVE_PATH ]]; then
for attempt in 1 2; do
download_and_verify_archive "$attempt" && break
if (( attempt == 2 )); then
die "$(translate "Could not obtain an intact image after two attempts")"
fi
msg_warn "$(translate "The image download did not complete correctly; downloading it again...")"
done
fi
fi
DESCRIPTION="proxmenux-instance=${INSTANCE_ID}"
UNPRIVILEGED=$(jq -r 'if .security | has("unprivileged") then .security.unprivileged else true end' "$DEPLOYMENT_FILE")
case "$UNPRIVILEGED" in
true) UNPRIVILEGED_FLAG=1 ;;
false) UNPRIVILEGED_FLAG=0 ;;
*) die "$(translate "Invalid security.unprivileged value:") $UNPRIVILEGED" ;;
esac
if [[ $UNPRIVILEGED_FLAG == 0 ]] && \
[[ $(jq -r '.security.privileged_acknowledged // false' "$DEPLOYMENT_FILE") != true ]]; then
die "$(translate "The privileged deployment does not include the required explicit consent")"
fi
PVE_TAGS="${CATEGORY};oci"
if [[ $PROVIDER == linuxserver.io ]]; then
PVE_TAGS="linuxserver;${PVE_TAGS}"
fi
PVE_TAGS="${PVE_TAGS};proxmenux"
NET="name=eth0,bridge=${BRIDGE},firewall=${FIREWALL},host-managed=1,ip=${IPV4},type=veth"
if [[ -n $GATEWAY ]]; then
NET="${NET},gw=${GATEWAY}"
fi
if [[ -n $MAC_ADDRESS ]]; then
[[ $MAC_ADDRESS =~ ^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$ ]] \
|| die "$(translate "Invalid MAC address:") $MAC_ADDRESS"
NET="${NET},hwaddr=${MAC_ADDRESS}"
fi
CREATE_UNPRIVILEGED_FLAG=$UNPRIVILEGED_FLAG
if [[ $UNPRIVILEGED_FLAG == 0 ]]; then
# Proxmox 9.2 cannot extract OCI archives directly as privileged LXCs.
# Import with the standard idmap, unshift once while stopped, then switch the config.
CREATE_UNPRIVILEGED_FLAG=1
fi
CREATE_ARGS=(
"$VMID" "$ARCHIVE_VOLUME"
--hostname "$HOSTNAME"
--rootfs "${ROOTFS_STORAGE}:${ROOTFS_SIZE}"
--memory "$MEMORY"
--swap "$SWAP"
--unprivileged "$CREATE_UNPRIVILEGED_FLAG"
--onboot "$ONBOOT"
--description "$DESCRIPTION"
--tags "$PVE_TAGS"
)
configure_cpu_allocation
CREATE_ARGS+=("${CPU_CREATE_ARGS[@]}")
if [[ -z $HOST_MONITOR ]]; then
CREATE_ARGS+=(--net0 "$NET")
fi
OSTYPE=$(jq -r '.ostype // "auto-from-image"' "$DEPLOYMENT_FILE")
case "$OSTYPE" in
auto-from-image) ;;
unmanaged) CREATE_ARGS+=(--ostype unmanaged) ;;
*) die "$(translate "Unsupported declarative ostype:") $OSTYPE" ;;
esac
if [[ -n $CPU_UNITS ]]; then
[[ $CPU_UNITS =~ ^[0-9]+$ && $CPU_UNITS -ge 8 && $CPU_UNITS -le 10000 ]] \
|| die "$(translate "cpuunits must be between 8 and 10000")"
CREATE_ARGS+=(--cpuunits "$CPU_UNITS")
fi
FEATURES=$(jq -r '.features | join(",")' "$DEPLOYMENT_FILE")
if [[ -n $FEATURES ]]; then
CREATE_ARGS+=(--features "$FEATURES")
fi
# pct keeps the OCI Entrypoint/Cmd/Env/User/WorkingDir/StopSignal metadata.
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "${CREATE_ARGS[@]}" \
|| die "$(translate "Could not create the container:") CT $VMID"
CT_CREATED=1
msg_ok "$(translate "Container created:") CT $VMID ($HOSTNAME)"
CONF="/etc/pve/lxc/${VMID}.conf"
if [[ $UNPRIVILEGED_FLAG == 0 ]]; then
msg_info "$(translate "Converting the container to privileged...")"
mount_ct_rootfs
oci_quiet python3 "$OCI_ROOTFS_UNSHIFTER" "/var/lib/lxc/${VMID}/rootfs" || {
pct unmount "$VMID" >/dev/null 2>&1 || true
die "$(translate "Could not convert the OCI rootfs to privileged")"
}
oci_quiet pct unmount "$VMID" \
|| die "$(translate "Could not unmount the container filesystem:") CT $VMID"
sed -i -E 's/^unprivileged: 1$/unprivileged: 0/' "$CONF"
grep -q '^unprivileged: 0$' "$CONF" \
|| die "$(translate "Could not enable the privileged profile before the first start")"
msg_ok "$(translate "Container converted to privileged")"
fi
capture_volume_seeds
MOUNT_INDEX=0
CONTAINER_PUID=$(jq -r '[.environment[]? | select(.name == "PUID" or .name == "USER_ID" or .name == "UID") | .value] | last // "0"' "$DEPLOYMENT_FILE")
CONTAINER_PGID=$(jq -r '[.environment[]? | select(.name == "PGID" or .name == "GROUP_ID" or .name == "GID") | .value] | last // "0"' "$DEPLOYMENT_FILE")
IMAGE_VOLUME_UID=$(jq -r '.proxmox.installer_profile.volume_owner.uid // empty' "$TEMPLATE_FILE")
IMAGE_VOLUME_GID=$(jq -r '.proxmox.installer_profile.volume_owner.gid // empty' "$TEMPLATE_FILE")
[[ -z $IMAGE_VOLUME_UID ]] || CONTAINER_PUID=$IMAGE_VOLUME_UID
[[ -z $IMAGE_VOLUME_GID ]] || CONTAINER_PGID=$IMAGE_VOLUME_GID
[[ $CONTAINER_PUID =~ ^[0-9]+$ ]] || CONTAINER_PUID=0
[[ $CONTAINER_PGID =~ ^[0-9]+$ ]] || CONTAINER_PGID=0
if [[ $UNPRIVILEGED_FLAG == 1 ]]; then
HOST_ROOT_UID=100000
HOST_ROOT_GID=100000
HOST_BIND_UID=$((100000 + CONTAINER_PUID))
HOST_BIND_GID=$((100000 + CONTAINER_PGID))
else
HOST_ROOT_UID=0
HOST_ROOT_GID=0
HOST_BIND_UID=$CONTAINER_PUID
HOST_BIND_GID=$CONTAINER_PGID
fi
SYSCTL_COUNT=$(jq '.security.sysctls? // [] | length' "$DEPLOYMENT_FILE")
if (( SYSCTL_COUNT > 0 )); then
SYSCTL_INCLUDE="/etc/pve/proxmenux/${VMID}.sysctls"
mkdir -p /etc/pve/proxmenux || die "$(translate "Could not create the ProxMenux folder in /etc/pve")"
SYSCTL_TEMP=$(mktemp)
while IFS=$'\t' read -r SYSCTL_NAME SYSCTL_VALUE; do
[[ -n $SYSCTL_NAME ]] || continue
[[ $SYSCTL_NAME =~ ^net\.(ipv4|ipv6)\.[A-Za-z0-9_.-]+$ ]] \
|| die "$(translate "Sysctl not namespaced or not valid:") $SYSCTL_NAME"
[[ -n $SYSCTL_VALUE && $SYSCTL_VALUE != *$'\n'* && $SYSCTL_VALUE != *$'\r'* ]] \
|| die "$(translate "Invalid sysctl value:") $SYSCTL_NAME"
printf 'lxc.sysctl.%s = %s\n' "$SYSCTL_NAME" "$SYSCTL_VALUE" >>"$SYSCTL_TEMP"
oci_log "Network sysctl prepared: ${SYSCTL_NAME}=${SYSCTL_VALUE}"
done < <(jq -r '.security.sysctls[]? | [.name,.value] | @tsv' "$DEPLOYMENT_FILE")
[[ ! -L $SYSCTL_INCLUDE ]] || die "$(translate "The sysctl include is a link:") $SYSCTL_INCLUDE"
# pmxcfs assigns its own permissions and rejects chmod.
cat "$SYSCTL_TEMP" >"$SYSCTL_INCLUDE"
rm -f "$SYSCTL_TEMP"
set_lxc_directive "lxc.include" "$SYSCTL_INCLUDE"
msg_ok "$(translate "Network sysctls prepared:") $SYSCTL_COUNT"
fi
REQUIRED_CAPABILITIES=$(jq -r '.security.required_capabilities? // [] | join(",")' "$DEPLOYMENT_FILE")
if [[ -n $REQUIRED_CAPABILITIES ]]; then
msg_ok "$(translate "Compose capabilities validated in the LXC user namespace:") $REQUIRED_CAPABILITIES"
fi
NO_NEW_PRIVILEGES=$(jq -r '.security.options.no_new_privileges? // false' "$DEPLOYMENT_FILE")
APPARMOR_PROFILE=$(jq -r '.security.options.apparmor_profile? // empty' "$DEPLOYMENT_FILE")
SECCOMP_PROFILE=$(jq -r '.security.options.seccomp_profile? // empty' "$DEPLOYMENT_FILE")
SELINUX_LABEL_DISABLED=$(jq -r '.security.options.selinux_label_disabled? // false' "$DEPLOYMENT_FILE")
if [[ $NO_NEW_PRIVILEGES == true ]]; then
set_lxc_directive "lxc.no_new_privs" "1"
fi
if [[ $(jq -r '.security.options.drop_all_capabilities? // false' "$DEPLOYMENT_FILE") == true ]]; then
[[ -z $REQUIRED_CAPABILITIES ]] || die "$(translate "Capabilities cannot be kept and all dropped at the same time")"
set_lxc_directive "lxc.cap.drop" ""
set_lxc_directive "lxc.cap.keep" "none"
fi
if [[ -n $APPARMOR_PROFILE || -n $SECCOMP_PROFILE ]]; then
[[ $(jq -r '.security.relaxation_acknowledged // false' "$DEPLOYMENT_FILE") == true ]] \
|| die "$(translate "The AppArmor/seccomp relaxation does not include the required consent")"
fi
if [[ -n $APPARMOR_PROFILE ]]; then
[[ $APPARMOR_PROFILE == unconfined ]] \
|| die "$(translate "Unsupported OCI-LXC AppArmor profile:") $APPARMOR_PROFILE"
set_lxc_directive "lxc.apparmor.profile" "unconfined"
fi
if [[ -n $SECCOMP_PROFILE ]]; then
[[ $SECCOMP_PROFILE == unconfined ]] \
|| die "$(translate "Unsupported OCI-LXC seccomp profile:") $SECCOMP_PROFILE"
SECCOMP_PROFILE_FILE="/etc/pve/lxc/${VMID}.proxmenux-seccomp"
printf '2\ndenylist\n[all]\n' >"$SECCOMP_PROFILE_FILE"
chmod 0640 "$SECCOMP_PROFILE_FILE"
set_lxc_directive "lxc.seccomp.profile" "$SECCOMP_PROFILE_FILE"
fi
if [[ $SELINUX_LABEL_DISABLED == true ]]; then
oci_log "label:disable kept as metadata; Proxmox uses AppArmor, not SELinux, for this LXC"
fi
MOUNT_ENTRIES=$(jq '.mounts | if type == "array" then length else 0 end' "$DEPLOYMENT_FILE")
MOUNT_NOTES=()
if (( MOUNT_ENTRIES > 0 )); then
msg_info "$(translate "Adding the mount points...")"
fi
while IFS=$'\t' read -r TYPE TARGET SOURCE SIZE BACKUP READ_ONLY CREATE_IF_MISSING; do
[[ -n $TYPE ]] || continue
[[ $TARGET == /* && $TARGET != *","* ]] || die "$(translate "Invalid container path:") $TARGET"
RO_OPT=""
[[ $READ_ONLY == true ]] && RO_OPT=",ro=1"
if [[ $TYPE == managed-volume ]]; then
[[ $SIZE =~ ^[0-9]+$ ]] || die "$(translate "Invalid volume size:") $TARGET"
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
REUSE_KEY=$(jq -r --arg path "$TARGET" \
'.transaction_reuse_mounts[]? | select(.container_path == $path) | .key' "$DEPLOYMENT_FILE")
if [[ -n $REUSE_KEY ]]; then
REUSE_VMID=$(jq -er '.transaction_source_vmid' "$DEPLOYMENT_FILE")
oci_quiet pct move-volume "$REUSE_VMID" "$REUSE_KEY" --target-vmid "$VMID" \
--target-volume "mp${MOUNT_INDEX}" \
|| die "$(translate "Could not reuse the persistent disk:") $TARGET"
MP_VALUE=$(pct config "$VMID" | awk -v key="mp${MOUNT_INDEX}: " \
'index($0,key)==1 { print substr($0,length(key)+1) }')
REUSED_VOLUME=${MP_VALUE%%,*}
[[ -n $REUSED_VOLUME ]] || die "$(translate "Cannot verify the reused disk:") $TARGET"
oci_quiet pct set "$VMID" "--mp${MOUNT_INDEX}" \
"${REUSED_VOLUME},mp=${TARGET},backup=${BACKUP}${RO_OPT}" \
|| die "$(translate "Could not add the mount point:") $TARGET"
oci_log "Persistent disk reused: $TARGET"
MOUNT_NOTES+=("$(translate "Persistent disk reused:") $TARGET")
MOUNT_INDEX=$((MOUNT_INDEX + 1))
continue
fi
fi
MP_VALUE="${SOURCE}:${SIZE},mp=${TARGET},backup=${BACKUP}${RO_OPT}"
elif [[ $TYPE == host-bind ]]; then
[[ $SOURCE == /* && $SOURCE != *","* ]] || die "$(translate "Invalid host path:") $SOURCE"
if [[ ! -e $SOURCE && $CREATE_IF_MISSING == true ]]; then
install -d -m 0775 -o "$HOST_BIND_UID" -g "$HOST_BIND_GID" "$SOURCE"
oci_log "Shared directory created: $SOURCE (uid=$HOST_BIND_UID gid=$HOST_BIND_GID)"
MOUNT_NOTES+=("$(translate "Shared directory created:") $SOURCE")
fi
[[ -e $SOURCE ]] || die "$(translate "The host bind source does not exist:") $SOURCE"
if [[ -d $SOURCE ]]; then
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
python3 "${SCRIPT_DIR}/oci_instance_transaction.py" --root "$INSTANCE_ROOT" \
pin-host-source "$VMID" --journal "$PROXMENUX_OCI_TRANSACTION" --source "$SOURCE"
fi
MP_VALUE="${SOURCE},mp=${TARGET},backup=0${RO_OPT}"
elif [[ -f $SOURCE ]]; then
[[ $SOURCE != *[[:space:]]* && $TARGET != *[[:space:]]* ]] \
|| die "$(translate "File bind mounts do not support spaces:") $SOURCE -> $TARGET"
prepare_file_mount_target "$TARGET"
TARGET=$PREPARED_FILE_TARGET
TARGET_RELATIVE=${TARGET#/}
FILE_OPTIONS="bind,create=file"
[[ $READ_ONLY == true ]] && FILE_OPTIONS="${FILE_OPTIONS},ro"
printf 'lxc.mount.entry: %s %s none %s 0 0\n' \
"$SOURCE" "$TARGET_RELATIVE" "$FILE_OPTIONS" >>"$CONF"
continue
else
die "$(translate "The host bind source is not a regular file or directory:") $SOURCE"
fi
else
die "$(translate "Unsupported mount type:") $TYPE"
fi
oci_quiet pct set "$VMID" "--mp${MOUNT_INDEX}" "$MP_VALUE" \
|| die "$(translate "Could not add the mount point:") $TARGET"
MOUNT_INDEX=$((MOUNT_INDEX + 1))
done < <(jq -r '.mounts[] | [.type,.container_path,.source,(.size_gb // "-"),(.backup | if . then 1 else 0 end),.read_only,(.create_if_missing // false)] | @tsv' "$DEPLOYMENT_FILE")
if (( MOUNT_ENTRIES > 0 )); then
msg_ok "$(translate "Mount points added:") $MOUNT_ENTRIES"
fi
for MOUNT_NOTE in ${MOUNT_NOTES[@]+"${MOUNT_NOTES[@]}"}; do
msg_ok "$MOUNT_NOTE"
done
while IFS=$'\t' read -r TARGET SIZE_MB OPTIONS; do
[[ -n $TARGET ]] || continue
[[ $TARGET == /* && $TARGET != *","* && $TARGET != *[[:space:]]* ]] \
|| die "$(translate "Invalid tmpfs path:") $TARGET"
[[ $SIZE_MB =~ ^[0-9]+$ && $SIZE_MB -gt 0 ]] || die "$(translate "Invalid tmpfs size:") $TARGET"
[[ $OPTIONS =~ ^(rw|ro|nosuid|nodev|noexec|mode=0[0-7]{3})(,(rw|ro|nosuid|nodev|noexec|mode=0[0-7]{3}))*$ ]] || die "$(translate "Invalid tmpfs options:") $TARGET"
TARGET_RELATIVE=${TARGET#/}
printf 'lxc.mount.entry: tmpfs %s tmpfs %s,size=%sM,create=dir 0 0\n' \
"$TARGET_RELATIVE" "$OPTIONS" "$SIZE_MB" >>"$CONF"
done < <(jq -r '.tmpfs_mounts[]? | [.container_path,.size_mb,(.mount_options | join(","))] | @tsv' "$DEPLOYMENT_FILE")
DEVICE_INDEX=0
RESOLVED_CHARACTER_DEVICES=()
NVIDIA_RUNTIME_CONFIGURED=0
NVIDIA_GID_ENV=""
NVIDIA_DEVICE_COUNT=0
while IFS= read -r DEVICE_ENCODED; do
[[ -n $DEVICE_ENCODED ]] || continue
DEVICE=$(printf '%s' "$DEVICE_ENCODED" | base64 -d)
DEVICE_KIND=$(jq -r '.kind // "character-device"' <<<"$DEVICE")
DEVICE_GID_ENV=$(jq -r '.append_host_device_gid_to_environment // empty' <<<"$DEVICE")
if [[ $DEVICE_KIND == nvidia-runtime ]]; then
NVIDIA_GID_ENV=$DEVICE_GID_ENV
if (( NVIDIA_RUNTIME_CONFIGURED == 0 )); then
NVIDIA_FIRST_INDEX=$DEVICE_INDEX
configure_nvidia_runtime
NVIDIA_DEVICE_COUNT=$((DEVICE_INDEX - NVIDIA_FIRST_INDEX))
fi
continue
fi
HOST_PATH=$(jq -er '.host_path' <<<"$DEVICE")
CONTAINER_PATH=$(jq -er '.container_path' <<<"$DEVICE")
MODE=$(jq -er '.mode' <<<"$DEVICE")
DENY_WRITE=$(jq -r '.deny_write | if . then 1 else 0 end' <<<"$DEVICE")
GID_STRATEGY=$(jq -er '.gid_strategy' <<<"$DEVICE")
[[ $HOST_PATH == /dev/* && $HOST_PATH == "$CONTAINER_PATH" ]] \
|| die "$(translate "The device must keep its /dev path inside the LXC:") $HOST_PATH"
case "$DEVICE_KIND" in
character-device)
DEVICE_UID=$(jq -r '.uid // empty' <<<"$DEVICE")
add_character_device "$HOST_PATH" "$MODE" "$GID_STRATEGY" "$DENY_WRITE" "$DEVICE_UID"
if [[ -n $DEVICE_GID_ENV ]]; then
append_deployment_environment_csv "$DEVICE_GID_ENV" "$(stat -c '%g' "$HOST_PATH")"
fi
;;
block-device)
add_device "$HOST_PATH" block "$MODE" "$GID_STRATEGY" "$DENY_WRITE"
if [[ -n $DEVICE_GID_ENV ]]; then
append_deployment_environment_csv "$DEVICE_GID_ENV" "$(stat -c '%g' "$HOST_PATH")"
fi
;;
character-device-tree)
[[ -d $HOST_PATH ]] || die "$(translate "The device directory does not exist:") $HOST_PATH"
FOUND_TREE_DEVICE=0
while IFS= read -r TREE_DEVICE; do
[[ -n $TREE_DEVICE ]] || continue
add_character_device "$TREE_DEVICE" "$MODE" "$GID_STRATEGY" "$DENY_WRITE"
FOUND_TREE_DEVICE=1
done < <(find "$HOST_PATH" -print 2>/dev/null | while IFS= read -r path; do
[[ -c $path ]] && printf '%s\n' "$path"
done | sort)
(( FOUND_TREE_DEVICE == 1 )) \
|| die "$(translate "The directory contains no character devices:") $HOST_PATH"
;;
*) die "$(translate "Unsupported device type:") $DEVICE_KIND" ;;
esac
done < <(jq -r '.devices[]? | @base64' "$DEPLOYMENT_FILE")
if (( DEVICE_INDEX > NVIDIA_DEVICE_COUNT )); then
msg_ok "$(translate "Devices added to the container:") $((DEVICE_INDEX - NVIDIA_DEVICE_COUNT))"
fi
apply_native_device_permissions
# PVE represents the public env property as repeated native LXC runtime lines.
# Merge only Compose overrides while the newly-created CT is stopped.
while IFS=$'\t' read -r NAME ENCODED; do
[[ $NAME =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "$(translate "Invalid variable name:") $NAME"
VALUE=$(printf '%s' "$ENCODED" | base64 -d)
[[ $VALUE != *$'\n'* && $VALUE != *$'\r'* ]] || die "$(translate "The variable contains line breaks:") $NAME"
if LC_ALL=C grep -q '[[:cntrl:]]' <<<"$VALUE"; then
die "$(translate "The variable contains control characters:") $NAME"
fi
TEMP_CONF=$(mktemp)
awk -v prefix="lxc.environment.runtime: ${NAME}=" 'index($0, prefix) != 1' "$CONF" >"$TEMP_CONF"
printf 'lxc.environment.runtime: %s=%s\n' "$NAME" "$VALUE" >>"$TEMP_CONF"
cat "$TEMP_CONF" >"$CONF"
rm -f "$TEMP_CONF"
done < <(jq -r '.environment[] | [.name, (.value | @base64)] | @tsv' "$DEPLOYMENT_FILE")
apply_extra_hosts
apply_installer_profile
apply_rlimits
apply_host_monitor
apply_kept_settings
while IFS= read -r REPAIR_ENCODED; do
[[ -n $REPAIR_ENCODED ]] || continue
run_pre_start_repair "$(printf '%s' "$REPAIR_ENCODED" | base64 -d)"
done < <(jq -r '.proxmox.installer_profile.pre_start_repairs[]? | @base64' "$TEMPLATE_FILE")
DEPLOYMENT_ENVIRONMENT=$(jq -c '.environment // []' "$DEPLOYMENT_FILE")
CREDENTIALS=$(jq -c --argjson environment "$DEPLOYMENT_ENVIRONMENT" '
def env_value($name):
[$environment[]? | select(.name == $name) | .value] | last // null;
[.first_run.credentials[]? |
if .username_environment? then
.username = (env_value(.username_environment) // .username)
else . end |
if .password_environment? then
.password = (env_value(.password_environment) // .password)
else . end |
del(.username_environment, .password_environment)
]
' "$TEMPLATE_FILE")
RUNTIME_CREDENTIALS=$(jq '[.[] | select(.retrieval.method? == "container-console-pattern")] | length' <<<"$CREDENTIALS")
# The console of the container is kept as its log, the way `docker logs` keeps
# it, and the Proxmox console opens a shell when the image has one. Both are
# set before the configuration is recorded, so the record carries them and an
# update, which rebuilds the container through this installer, sets them again.
# The first-boot credentials are read from the same log.
CONSOLE_STATE=$(python3 "${SCRIPT_DIR}/oci_console.py" configure "$VMID") \
|| die "$(translate "The console of the container could not be configured")"
RUNTIME_CONSOLE_LOG=$(jq -r '.log' <<<"$CONSOLE_STATE")
oci_log "Configuration created for CT $VMID"
PASSWORD_STATE=""
if [[ $START_AFTER == 1 ]]; then
[[ $HAOS_HEALTHCHECK == 0 ]] || PRESERVE_FAILED_CT=1
msg_info "$(translate "Starting the container...")"
oci_quiet pct start "$VMID" || die "$(translate "The container could not be started:") CT $VMID"
verify_host_monitor
if (( RUNTIME_CREDENTIALS > 0 )); then
while IFS= read -r encoded; do
[[ -n $encoded ]] || continue
credential=$(printf '%s' "$encoded" | base64 -d)
credential_label=$(jq -r '.label' <<<"$credential")
credential_pattern=$(jq -r '.retrieval.pattern // empty' <<<"$credential")
credential_timeout=$(jq -r '.retrieval.timeout_seconds // 90' <<<"$credential")
if [[ -z $credential_pattern ]]; then
case "$(jq -r '.retrieval.pattern_id // empty' <<<"$credential")" in
linuxserver-temporary-password)
credential_pattern='A temporary password is provided for this session:\s*(\S+)' ;;
*) die "$(translate "Unsupported credential pattern:") $credential_label" ;;
esac
fi
oci_log "Capturing the credential of ${credential_label} from the boot console"
msg_progress "$(translate "Waiting for the temporary password...")"
credential_value=$(capture_console_credential "$RUNTIME_CONSOLE_LOG" "$credential_pattern" "$credential_timeout")
if [[ -n $credential_value ]]; then
CREDENTIALS=$(jq -c --arg label "$credential_label" --arg password "$credential_value" \
'map(if .label == $label and .retrieval.method? == "container-console-pattern" then .password = $password else . end)' \
<<<"$CREDENTIALS")
PASSWORD_STATE=retrieved
else
CREDENTIALS=$(jq -c --arg label "$credential_label" \
'map(if .label == $label and .retrieval.method? == "container-console-pattern" then
. + {retrieval_error: "The credential could not be retrieved from the boot console"} else . end)' \
<<<"$CREDENTIALS")
oci_log "The credential of ${credential_label} could not be read from the console"
PASSWORD_STATE=missing
fi
done < <(jq -r '.[] | select(.retrieval.method? == "container-console-pattern") | @base64' <<<"$CREDENTIALS")
fi
FILE_CREDENTIALS=$(jq '[.[] | select(.retrieval.method? == "container-file")] | length' <<<"$CREDENTIALS")
if (( FILE_CREDENTIALS > 0 )); then
while IFS= read -r encoded; do
[[ -n $encoded ]] || continue
credential=$(printf '%s' "$encoded" | base64 -d)
credential_label=$(jq -r '.label' <<<"$credential")
credential_path=$(jq -r '.retrieval.path' <<<"$credential")
credential_pattern=$(jq -r '.retrieval.pattern // empty' <<<"$credential")
credential_timeout=$(jq -r '.retrieval.timeout_seconds // 180' <<<"$credential")
[[ $credential_path == /* && $credential_path != *[[:space:]]* ]] \
|| die "$(translate "Invalid credential file path:") $credential_path"
oci_log "Reading the credential of ${credential_label} from ${credential_path}"
if credential_value=$(capture_container_file_credential \
"$VMID" "$credential_path" "$credential_pattern" "$credential_timeout"); then
CREDENTIALS=$(jq -c --arg label "$credential_label" --arg password "$credential_value" \
'map(if .label == $label and .retrieval.method? == "container-file" then .password = $password else . end)' \
<<<"$CREDENTIALS")
PASSWORD_STATE=retrieved
else
CREDENTIALS=$(jq -c --arg label "$credential_label" \
'map(if .label == $label and .retrieval.method? == "container-file" then
. + {retrieval_error: "The credential could not be read from the container"} else . end)' \
<<<"$CREDENTIALS")
oci_log "The credential of ${credential_label} could not be read"
PASSWORD_STATE=missing
fi
done < <(jq -r '.[] | select(.retrieval.method? == "container-file") | @base64' <<<"$CREDENTIALS")
fi
detect_container_ipv4 "$(translate "Container started")"
case "$PASSWORD_STATE" in
retrieved) msg_ok "$(translate "Temporary password retrieved")" ;;
missing) msg_warn "$(translate "The temporary password could not be retrieved.")" ;;
esac
else
IP=""
msg_ok "$(translate "Container configured (not started):") CT $VMID"
fi
if [[ -z $IP && $IPV4 != dhcp ]]; then
IP=${IPV4%%/*}
fi
POST_START_CONFIGURATION_COUNT=$(jq '.post_start_configurations? // [] | length' "$DEPLOYMENT_FILE")
if (( POST_START_CONFIGURATION_COUNT > 0 )); then
(( START_AFTER == 1 )) \
|| die "$(translate "The selected configuration needs to start the LXC during the installation")"
while IFS= read -r CONFIGURATION_ENCODED; do
[[ -n $CONFIGURATION_ENCODED ]] || continue
apply_post_start_configuration "$(printf '%s' "$CONFIGURATION_ENCODED" | base64 -d)"
done < <(jq -r '.post_start_configurations[]? | @base64' "$DEPLOYMENT_FILE")
msg_info "$(translate "Waiting for the network address...")"
detect_container_ipv4
if [[ -z $IP && $IPV4 != dhcp ]]; then
IP=${IPV4%%/*}
fi
fi
if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
HC_SCHEME=$(jq -er '.scheme' <<<"$STARTUP_HEALTHCHECK")
HC_PORT=$(jq -er '.port' <<<"$STARTUP_HEALTHCHECK")
HC_PATH=$(jq -er '.path' <<<"$STARTUP_HEALTHCHECK")
HC_TIMEOUT=$(jq -er '.timeout_seconds' <<<"$STARTUP_HEALTHCHECK")
HC_REQUEST_TIMEOUT=$(jq -er '.request_timeout_seconds' <<<"$STARTUP_HEALTHCHECK")
HC_STABILITY=$(jq -r '.stability_seconds // 0' <<<"$STARTUP_HEALTHCHECK")
HC_VERIFY_TLS=$(jq -r '.verify_tls' <<<"$STARTUP_HEALTHCHECK")
[[ $HC_SCHEME == http || $HC_SCHEME == https ]] || die "$(translate "Invalid healthcheck scheme")"
[[ $HC_PORT =~ ^[0-9]+$ && $HC_PORT -ge 1 && $HC_PORT -le 65535 ]] \
|| die "$(translate "Invalid healthcheck port")"
[[ $HC_PATH == /* && $HC_PATH != *[[:space:]]* ]] || die "$(translate "Invalid healthcheck path")"
[[ $HC_TIMEOUT =~ ^[0-9]+$ && $HC_TIMEOUT -gt 0 ]] || die "$(translate "Invalid healthcheck timeout")"
[[ $HC_REQUEST_TIMEOUT =~ ^[0-9]+$ && $HC_REQUEST_TIMEOUT -gt 0 ]] \
|| die "$(translate "Invalid healthcheck request timeout")"
[[ $HC_STABILITY =~ ^[0-9]+$ ]] || die "$(translate "Invalid healthcheck stability period")"
(( HC_STABILITY < HC_TIMEOUT )) \
|| die "$(translate "The stability period must be shorter than the healthcheck timeout")"
[[ -n $IP ]] || die "$(translate "The healthcheck cannot run without an IP address")"
HC_URL="${HC_SCHEME}://${IP}:${HC_PORT}${HC_PATH}"
HC_ANY_STATUS=$(jq -r '.accept_any_status // false' <<<"$STARTUP_HEALTHCHECK")
if [[ $HC_ANY_STATUS == true ]]; then
CURL_ARGS=(-sS --noproxy '*' -o /dev/null -w '%{http_code}' --max-time "$HC_REQUEST_TIMEOUT")
else
CURL_ARGS=(-fsS --noproxy '*' -o /dev/null --max-time "$HC_REQUEST_TIMEOUT")
fi
[[ $HC_VERIFY_TLS == true ]] || CURL_ARGS+=(-k)
HC_OK=0
HC_ELAPSED=0
HC_STABLE_ELAPSED=0
HC_STABLE_SINCE=0
oci_log "Waiting for the service: $HC_URL"
msg_info "$(translate "Waiting for the application to respond...")"
while (( HC_ELAPSED < HC_TIMEOUT )); do
if ! container_is_running; then
oci_log "The container stopped during its first start. Last console messages:"
[[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}"
die "$(translate "The container stopped before the application responded:") CT $VMID"
fi
if healthcheck_probe; then
if (( HC_STABILITY == 0 )); then
HC_OK=1
break
fi
HC_NOW=$(date +%s)
if (( HC_STABLE_SINCE == 0 )); then
HC_STABLE_SINCE=$HC_NOW
fi
HC_STABLE_ELAPSED=$((HC_NOW - HC_STABLE_SINCE))
if (( HC_STABLE_ELAPSED >= HC_STABILITY )); then
HC_OK=1
break
fi
else
HC_STABLE_ELAPSED=0
HC_STABLE_SINCE=0
fi
sleep 2
HC_ELAPSED=$((HC_ELAPSED + 2))
if (( HC_STABLE_ELAPSED > 0 )); then
msg_progress "$(translate "Application responding; checking its stability...") ${HC_STABLE_ELAPSED}/${HC_STABILITY} s"
else
msg_progress "$(translate "Waiting for the application to respond...") ${HC_ELAPSED}/${HC_TIMEOUT} s"
fi
done
if [[ $HC_OK != 1 ]]; then
oci_log "The service did not pass the healthcheck. Last console messages:"
[[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}"
die "$(translate "The application did not respond in time:") $HC_URL"
fi
msg_ok "$(translate "Application responding:") $HC_URL"
fi
# Applications without a web address: the container must keep running.
if [[ $START_AFTER == 1 && $HAS_RUNNING_CHECK == 1 ]]; then
RC_STABILITY=$(jq -r '.stability_seconds // 20' <<<"$STARTUP_HEALTHCHECK")
[[ $RC_STABILITY =~ ^[0-9]+$ && $RC_STABILITY -le 600 ]] || die "$(translate "Invalid healthcheck stability period")"
msg_info "$(translate "Checking that the container keeps running...")"
RC_START=$(date +%s)
while (( $(date +%s) - RC_START < RC_STABILITY )); do
if ! container_is_running; then
oci_log "The container stopped after starting. Last console messages:"
[[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}"
die "$(translate "The container stopped after starting:") CT $VMID"
fi
sleep 2
done
msg_ok "$(translate "Container running steadily")"
fi
HAOS_URLS=""
if [[ $START_AFTER == 1 && $HAOS_HEALTHCHECK != 0 ]]; then
PRESERVE_FAILED_CT=1
[[ -n $IP ]] || die "$(translate "Home Assistant OS cannot be checked without an IP address")"
# The checker rewrites this line with its own progress on stderr.
msg_progress "$(translate "Waiting for Home Assistant OS...")"
HAOS_URLS=$(python3 "${SCRIPT_DIR}/haos_healthcheck.py" --vmid "$VMID" --ip "$IP" --timeout "$HAOS_HEALTHCHECK") \
|| die "$(translate "Home Assistant OS did not pass the Supervisor, Core and Observer checks")"
msg_ok "$(translate "Home Assistant OS ready: Supervisor, Core and Observer running")"
fi
cleanup_runtime_console_log
UPDATED_DESCRIPTION=$(python3 "${SCRIPT_DIR}/oci_description.py" --template "$TEMPLATE_FILE" \
--digest "$DIGEST" --instance "$INSTANCE_ID" --ip "$IP") \
|| die "Could not prepare the OCI notes"
oci_quiet pct set "$VMID" --description "$UPDATED_DESCRIPTION" \
|| die "Could not write the OCI notes in Proxmox"
URLS=$(jq -c --arg ip "$IP" '
if $ip == "" then []
elif (.first_run.endpoints? // []) | length > 0 then
[.first_run.endpoints[] | {
label: .label,
url: (.scheme + "://" + $ip + ":" + (.port | tostring) + .path)
}]
elif .catalog_ui.launch.port != null then
[{
label: "Web UI",
url: (.catalog_ui.launch.scheme + "://" + $ip + ":" + (.catalog_ui.launch.port | tostring) + .catalog_ui.launch.path)
}]
else [] end
' "$TEMPLATE_FILE")
if [[ -n $HAOS_URLS ]]; then
URLS=$HAOS_URLS
elif [[ $HAOS_HEALTHCHECK != 0 ]]; then
URLS='[]'
msg_info2 "$(translate "Home Assistant OS was not started: its addresses will be known once Core is running.")"
fi
apply_host_monitor_firewall
INSTALL_COMPLETE=1
if [[ $(jq -r '.stack_managed // false' "$DEPLOYMENT_FILE") == true ]]; then
msg_ok "$(translate "Container prepared for the stack:") CT $VMID ($HOSTNAME)"
elif [[ $START_AFTER == 1 ]]; then
check_native_device_permissions
fi
if [[ -z ${PROXMENUX_OCI_TRANSACTION:-} ]] && ! oci_quiet python3 "${SCRIPT_DIR}/oci_instances.py" complete "$VMID" \
--archive "$ARCHIVE_PATH" --digest "$DIGEST"; then
msg_warn "$(translate "Container installed, but without a verifiable record for future updates.")"
fi
# The template's notes, then those of this installation's choices, such as the
# detector configuration for a device the user attached.
COMPLETION_NOTES=$(jq -cs '(.[0].proxmox.installer_profile.completion_notes // [])
+ (.[1].completion_notes // [])' "$TEMPLATE_FILE" "$DEPLOYMENT_FILE")
RESULT=$(jq -cn \
--arg app_id "$APP_ID" \
--arg image "$IMAGE_REF" \
--arg digest "$DIGEST" \
--arg architecture "$ARCH" \
--arg image_version "$IMAGE_VERSION" \
--arg image_created "$CREATED" \
--arg ip "$IP" \
--argjson urls "$URLS" \
--argjson credentials "$CREDENTIALS" \
--argjson completion_notes "$COMPLETION_NOTES" \
--argjson vmid "$VMID" \
--arg log "${OCI_LOG:-}" \
'{app_id:$app_id,vmid:$vmid,image:$image,digest:$digest,architecture:$architecture,image_version:$image_version,image_created:$image_created,ip:$ip,urls:$urls,credentials:$credentials,completion_notes:$completion_notes,log:$log}')
printf 'PROXMENUX_RESULT=%s\n' "$(printf '%s' "$RESULT" | base64 | tr -d '\n')"