From 6e7be4c029ba434eaae57740b933d12c97722f8a Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:33:46 +0200 Subject: [PATCH] fix(oci): preserve PocketBase initial data on first install --- oci/catalog/overlays/pocketbase.json | 34 ++++++ oci/remote/install_oci.sh | 130 ++++++++++++++++++++++- oci/tests/test_pocketbase_persistence.py | 48 +++++++++ 3 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 oci/tests/test_pocketbase_persistence.py diff --git a/oci/catalog/overlays/pocketbase.json b/oci/catalog/overlays/pocketbase.json index c11479ef..7c1be1ba 100644 --- a/oci/catalog/overlays/pocketbase.json +++ b/oci/catalog/overlays/pocketbase.json @@ -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": { "credentials": [ { diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 74699784..e1b8651d 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -246,6 +246,10 @@ 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=() cleanup_runtime_console_log() { # The console log is the container's own log from here on, and its line in @@ -270,6 +274,7 @@ healthcheck_probe() { cleanup_failed_install() { local status=$? stop_spinner + cleanup_volume_seed_staging || true if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then # The transaction owns recovery. Destroying this CT could destroy reused data. cleanup_runtime_console_log || true @@ -326,6 +331,121 @@ ensure_rootfs_directory() { 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" @@ -830,7 +950,7 @@ apply_runtime_groups() { } 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 encoded item path mode owner destination target remove_lost_found owner_strategy local only_when_mount_type selected_mount_type @@ -842,11 +962,16 @@ apply_installer_profile() { 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") - 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 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) @@ -1418,6 +1543,7 @@ if [[ $UNPRIVILEGED_FLAG == 0 ]]; then || 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") diff --git a/oci/tests/test_pocketbase_persistence.py b/oci/tests/test_pocketbase_persistence.py new file mode 100644 index 00000000..667823de --- /dev/null +++ b/oci/tests/test_pocketbase_persistence.py @@ -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()