mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-30 10:36:41 +00:00
Merge pull request #394 from Vaso73/fix/oci-persistence-input-safety
fix(oci): preserve persistent data and caller-owned plans
This commit is contained in:
@@ -1,4 +1,38 @@
|
|||||||
{
|
{
|
||||||
|
"container_contract": {
|
||||||
|
"volumes": [
|
||||||
|
{
|
||||||
|
"id": "volume-0",
|
||||||
|
"container_path": "/pocketbase/pb_data",
|
||||||
|
"compose_source_example": "/DATA/AppData/$AppID/pb_data",
|
||||||
|
"read_only": false,
|
||||||
|
"required": true,
|
||||||
|
"installation_choice": [
|
||||||
|
"managed-volume",
|
||||||
|
"host-bind"
|
||||||
|
],
|
||||||
|
"default": "managed-volume",
|
||||||
|
"managed_volume": {
|
||||||
|
"backup": true,
|
||||||
|
"default_size_gb": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"proxmox": {
|
||||||
|
"installer_profile": {
|
||||||
|
"volume_seeds": [
|
||||||
|
{
|
||||||
|
"container_path": "/pocketbase/pb_data",
|
||||||
|
"source_path": "/pocketbase/pb_data",
|
||||||
|
"mount_types": [
|
||||||
|
"managed-volume",
|
||||||
|
"host-bind"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"first_run": {
|
"first_run": {
|
||||||
"credentials": [
|
"credentials": [
|
||||||
{
|
{
|
||||||
|
|||||||
+145
-4
@@ -318,6 +318,11 @@ SECCOMP_PROFILE_FILE=""
|
|||||||
CT_CREATED=0
|
CT_CREATED=0
|
||||||
INSTALL_COMPLETE=0
|
INSTALL_COMPLETE=0
|
||||||
PRESERVE_FAILED_CT=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() {
|
cleanup_runtime_console_log() {
|
||||||
# The console log is the container's own log from here on, and its line in
|
# The console log is the container's own log from here on, and its line in
|
||||||
@@ -327,6 +332,14 @@ cleanup_runtime_console_log() {
|
|||||||
RUNTIME_CONSOLE_LOG=""
|
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,
|
# 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.
|
# even when its first page is a redirect or asks for a login.
|
||||||
healthcheck_probe() {
|
healthcheck_probe() {
|
||||||
@@ -342,6 +355,8 @@ healthcheck_probe() {
|
|||||||
cleanup_failed_install() {
|
cleanup_failed_install() {
|
||||||
local status=$?
|
local status=$?
|
||||||
stop_spinner
|
stop_spinner
|
||||||
|
cleanup_volume_seed_staging || true
|
||||||
|
cleanup_canonical_inputs || true
|
||||||
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
|
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
|
||||||
# The transaction owns recovery. Destroying this CT could destroy reused data.
|
# The transaction owns recovery. Destroying this CT could destroy reused data.
|
||||||
cleanup_runtime_console_log || true
|
cleanup_runtime_console_log || true
|
||||||
@@ -398,6 +413,121 @@ ensure_rootfs_directory() {
|
|||||||
done
|
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() {
|
prepare_file_mount_target() {
|
||||||
local requested_target=$1
|
local requested_target=$1
|
||||||
local rootfs="/var/lib/lxc/${VMID}/rootfs"
|
local rootfs="/var/lib/lxc/${VMID}/rootfs"
|
||||||
@@ -902,7 +1032,7 @@ apply_runtime_groups() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_installer_profile() {
|
apply_installer_profile() {
|
||||||
local generated_count preparation_count tls_count mounted=0 failed=0
|
local generated_count preparation_count tls_count seeded_volumes mounted=0 failed=0
|
||||||
local rootfs="/var/lib/lxc/${VMID}/rootfs"
|
local rootfs="/var/lib/lxc/${VMID}/rootfs"
|
||||||
local encoded item path mode owner destination target remove_lost_found owner_strategy
|
local encoded item path mode owner destination target remove_lost_found owner_strategy
|
||||||
local only_when_mount_type selected_mount_type
|
local only_when_mount_type selected_mount_type
|
||||||
@@ -914,11 +1044,16 @@ apply_installer_profile() {
|
|||||||
generated_count=$(jq '.proxmox.installer_profile.generated_files? // [] | length' "$TEMPLATE_FILE")
|
generated_count=$(jq '.proxmox.installer_profile.generated_files? // [] | length' "$TEMPLATE_FILE")
|
||||||
preparation_count=$(jq '.proxmox.installer_profile.volume_preparations? // [] | 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")
|
tls_count=$(jq 'if .proxmox.installer_profile.self_signed_tls? then 1 else 0 end' "$TEMPLATE_FILE")
|
||||||
if (( generated_count > 0 || preparation_count > 0 || tls_count > 0 )); then
|
seeded_volumes=${#VOLUME_SEED_TARGETS[@]}
|
||||||
|
if (( generated_count > 0 || preparation_count > 0 || tls_count > 0 || seeded_volumes > 0 )); then
|
||||||
mount_ct_rootfs
|
mount_ct_rootfs
|
||||||
mounted=1
|
mounted=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if (( seeded_volumes > 0 )); then
|
||||||
|
apply_volume_seeds || failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
while IFS= read -r encoded; do
|
while IFS= read -r encoded; do
|
||||||
[[ -n $encoded ]] || continue
|
[[ -n $encoded ]] || continue
|
||||||
item=$(printf '%s' "$encoded" | base64 -d)
|
item=$(printf '%s' "$encoded" | base64 -d)
|
||||||
@@ -1230,8 +1365,13 @@ INSTANCE_ID=$(python3 "${SCRIPT_DIR}/oci_instances.py" prepare "$VMID" \
|
|||||||
--template "$TEMPLATE_FILE" --deployment "$DEPLOYMENT_FILE")
|
--template "$TEMPLATE_FILE" --deployment "$DEPLOYMENT_FILE")
|
||||||
INSTANCE_CONTRACT="$INSTANCE_ROOT/$VMID/oci-compose.json"
|
INSTANCE_CONTRACT="$INSTANCE_ROOT/$VMID/oci-compose.json"
|
||||||
# The persisted contract is the source for the actual installation inputs.
|
# The persisted contract is the source for the actual installation inputs.
|
||||||
jq '.template' "$INSTANCE_CONTRACT" >"$TEMPLATE_FILE"
|
# Do not rewrite caller-owned files, notably a regular user's file in sticky /tmp.
|
||||||
jq '.deployment' "$INSTANCE_CONTRACT" >"$DEPLOYMENT_FILE"
|
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
|
fi
|
||||||
|
|
||||||
skopeo_transport_reference() {
|
skopeo_transport_reference() {
|
||||||
@@ -1491,6 +1631,7 @@ if [[ $UNPRIVILEGED_FLAG == 0 ]]; then
|
|||||||
|| die "$(translate "Could not enable the privileged profile before the first start")"
|
|| die "$(translate "Could not enable the privileged profile before the first start")"
|
||||||
msg_ok "$(translate "Container converted to privileged")"
|
msg_ok "$(translate "Container converted to privileged")"
|
||||||
fi
|
fi
|
||||||
|
capture_volume_seeds
|
||||||
MOUNT_INDEX=0
|
MOUNT_INDEX=0
|
||||||
CONTAINER_PUID=$(jq -r '[.environment[]? | select(.name == "PUID" or .name == "USER_ID" or .name == "UID") | .value] | last // "0"' "$DEPLOYMENT_FILE")
|
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")
|
CONTAINER_PGID=$(jq -r '[.environment[]? | select(.name == "PGID" or .name == "GROUP_ID" or .name == "GID") | .value] | last // "0"' "$DEPLOYMENT_FILE")
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""The installer must not need to rewrite files supplied by its caller."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
INSTALLER = ROOT / "remote" / "install_oci.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalInstallerInputTests(unittest.TestCase):
|
||||||
|
def test_persisted_contract_uses_private_copies_not_caller_files(self):
|
||||||
|
installer = INSTALLER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('mktemp -d "/var/tmp/proxmenux-oci-inputs-${VMID}.XXXXXX"', installer)
|
||||||
|
self.assertIn('TEMPLATE_FILE="${CANONICAL_INPUT_DIR}/template.json"', installer)
|
||||||
|
self.assertIn('DEPLOYMENT_FILE="${CANONICAL_INPUT_DIR}/deployment.json"', installer)
|
||||||
|
self.assertIn('cleanup_canonical_inputs || true', installer)
|
||||||
|
self.assertNotIn('jq \'.template\' "$INSTANCE_CONTRACT" >"$TEMPLATE_FILE"', installer)
|
||||||
|
self.assertNotIn('jq \'.deployment\' "$INSTANCE_CONTRACT" >"$DEPLOYMENT_FILE"', installer)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Regression coverage for PocketBase's native OCI data mount."""
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
INSTALLER = ROOT / "remote" / "install_oci.sh"
|
||||||
|
|
||||||
|
from proxmenux_oci.catalog import Catalog
|
||||||
|
|
||||||
|
|
||||||
|
class PocketBasePersistenceTests(unittest.TestCase):
|
||||||
|
def test_data_volume_uses_the_image_working_directory(self):
|
||||||
|
template = Catalog(ROOT).compose("pocketbase")
|
||||||
|
volumes = template["container_contract"]["volumes"]
|
||||||
|
|
||||||
|
self.assertEqual(len(volumes), 1)
|
||||||
|
self.assertEqual(volumes[0]["container_path"], "/pocketbase/pb_data")
|
||||||
|
self.assertTrue(volumes[0]["required"])
|
||||||
|
self.assertTrue(volumes[0]["managed_volume"]["backup"])
|
||||||
|
|
||||||
|
def test_first_install_seeds_only_the_new_managed_data_volume(self):
|
||||||
|
template = Catalog(ROOT).compose("pocketbase")
|
||||||
|
seeds = template["proxmox"]["installer_profile"]["volume_seeds"]
|
||||||
|
|
||||||
|
self.assertEqual(seeds, [{
|
||||||
|
"container_path": "/pocketbase/pb_data",
|
||||||
|
"source_path": "/pocketbase/pb_data",
|
||||||
|
"mount_types": ["managed-volume", "host-bind"],
|
||||||
|
}])
|
||||||
|
|
||||||
|
def test_seed_mechanism_is_opt_in_and_never_overwrites_existing_data(self):
|
||||||
|
installer = INSTALLER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('and all(.[]; . == "managed-volume" or . == "host-bind")', installer)
|
||||||
|
self.assertIn('[[ $selected_mount_type == managed-volume ]] && is_reused_managed_mount "$target";', installer)
|
||||||
|
self.assertIn('Refusing to seed a persistent volume that already contains data:', installer)
|
||||||
|
self.assertIn('Keeping existing host data without seeding it:', installer)
|
||||||
|
self.assertIn('mktemp -d "/var/tmp/proxmenux-oci-seed-${VMID}.XXXXXX"', installer)
|
||||||
|
self.assertIn('cleanup_volume_seed_staging || true', installer)
|
||||||
|
self.assertLess(installer.index('capture_volume_seeds\nMOUNT_INDEX=0'),
|
||||||
|
installer.index('while IFS=$\'\\t\' read -r TYPE TARGET'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user