diff --git a/scripts/test-vector-backup-restore-safety.sh b/scripts/test-vector-backup-restore-safety.sh index 9dcb0a56..7c0d947b 100755 --- a/scripts/test-vector-backup-restore-safety.sh +++ b/scripts/test-vector-backup-restore-safety.sh @@ -57,6 +57,9 @@ run_restore() { exit 17 fi tar -C "$extract" -xf "$backup_dir/$input_name" + if [ -n "${RESTORE_CAPTURE_FILE:-}" ]; then + cp "$backup_dir/$input_name" "$RESTORE_CAPTURE_FILE" + fi cp -R "$extract/payload"/. "$volume_dir"/ rm -rf "$snapshot" "$extract" } @@ -187,6 +190,33 @@ with tarfile.open(archive, "w") as tf: PY } +make_duplicate_manifest_archive() { + archive_path=$1 + python - "$archive_path" <<'PY' +import io, tarfile, sys +archive = sys.argv[1] +with tarfile.open(archive, "w") as tf: + manifest = b"""format=thothii-qdrant-backup-v1 +project_name=thoth-task8 +project_name=thoth-task8 +volume_name=thoth-task8_qdrant-data +volume_role=qdrant-data +helper_image=qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c +created_utc=2026-08-08T17:35:36Z +""" + info = tarfile.TarInfo("manifest.env") + info.size = len(manifest) + tf.addfile(info, io.BytesIO(manifest)) + directory = tarfile.TarInfo("payload") + directory.type = tarfile.DIRTYPE + tf.addfile(directory) + payload = b"dup" + info = tarfile.TarInfo("payload/dup.txt") + info.size = len(payload) + tf.addfile(info, io.BytesIO(payload)) +PY +} + backup_output="$tmp/qdrant-backup.tar" docker_log="$tmp/docker-backup.log" PATH="$fakebin:$PATH" DOCKER_LOG="$docker_log" PROJECT_NAME="$project" VOLUME_ROOT="$volume_root" \ @@ -272,6 +302,27 @@ if grep -q "compose --project-name $project stop qdrant" "$symlink_log"; then exit 1 fi +duplicate_archive="$tmp/duplicate.tar" +make_duplicate_manifest_archive "$duplicate_archive" +duplicate_log="$tmp/duplicate.log" +: >"$duplicate_log" +if PATH="$fakebin:$PATH" DOCKER_LOG="$duplicate_log" PROJECT_NAME="$project" VOLUME_ROOT="$volume_root" \ +HELPER_IMAGE="qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c" \ + ./scripts/vector-restore.sh --project-name "$project" --input "$duplicate_archive" --confirm-project "$project" \ + >"$tmp/duplicate.out" 2>"$tmp/duplicate.err"; then + echo "restore accepted duplicate manifest keys" >&2 + exit 1 +fi +grep -Eq 'duplicate|manifest' "$tmp/duplicate.err" +if grep -q "compose --project-name $project stop qdrant" "$duplicate_log"; then + echo "restore stopped qdrant before duplicate-manifest rejection" >&2 + exit 1 +fi +if grep -q '^run ' "$duplicate_log"; then + echo "restore ran helper before duplicate-manifest rejection" >&2 + exit 1 +fi + restore_source="$tmp/restore-source" mkdir -p "$restore_source/payload/collections/demo" cat >"$restore_source/manifest.env" <"$volume_dir/collections/demo/state.json" restore_log="$tmp/restore-ok.log" +restore_capture="$tmp/restore-captured.tar" PATH="$fakebin:$PATH" DOCKER_LOG="$restore_log" PROJECT_NAME="$project" VOLUME_ROOT="$volume_root" \ +RESTORE_CAPTURE_FILE="$restore_capture" \ HELPER_IMAGE="qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c" \ - ./scripts/vector-restore.sh --project-name "$project" --input "$restore_input" --confirm-project "$project" >/dev/null + ./scripts/vector-restore.sh --project-name "$project" --input "$restore_input" --confirm-project "$project" >/dev/null & +restore_pid=$! +sleep 1 +printf '%s' swapped >"$restore_source/payload/collections/demo/state.json" +tar -C "$restore_source" -cf "$restore_input" manifest.env payload +wait "$restore_pid" test "$(cat "$volume_dir/collections/demo/state.json")" = restored +tar -xOf "$restore_capture" payload/collections/demo/state.json | grep -qx 'restored' grep -q "compose --project-name $project stop qdrant" "$restore_log" grep -q "compose --project-name $project start qdrant" "$restore_log" -grep -q "run --rm --mount type=volume,src=$volume_name,dst=/qdrant-data --mount type=bind,src=$tmp,dst=/restore-backup,readonly" "$restore_log" +grep -Eq "run --rm --mount type=volume,src=$volume_name,dst=/qdrant-data --mount type=bind,src=.*/qdrant-archive\.[^,]*,dst=/restore-backup,readonly" "$restore_log" +if grep -q "src=$tmp,dst=/restore-backup,readonly" "$restore_log"; then + echo "restore mounted the original archive directory instead of a private copy" >&2 + exit 1 +fi if grep -q "volume inspect --format {{ .Mountpoint }}" "$restore_log"; then echo "restore consulted Docker mountpoints during normal restore" >&2 exit 1 @@ -312,6 +375,10 @@ if grep -q "prune" "$restore_log"; then echo "restore attempted global docker cleanup" >&2 exit 1 fi +if find "$tmp" -maxdepth 1 -type d -name 'qdrant-archive.*' | grep -q .; then + echo "restore left its private archive-copy directory behind" >&2 + exit 1 +fi printf '%s' rollback-source >"$volume_dir/collections/demo/state.json" rollback_log="$tmp/restore-rollback.log" diff --git a/scripts/vector-restore.sh b/scripts/vector-restore.sh index cc26b6d6..6aae4f6a 100755 --- a/scripts/vector-restore.sh +++ b/scripts/vector-restore.sh @@ -29,9 +29,29 @@ done } [ -r "$input" ] || { echo "backup input is not readable" >&2; exit 2; } -input_dir=$(dirname "$input") -input_name=$(basename "$input") expected_volume_name="${project_name}_${volume_role}" +private_archive_dir= +private_archive_path= +cleanup() { + status=$? + if [ -n "${private_archive_dir:-}" ] && [ -d "${private_archive_dir:-}" ]; then + rm -rf "$private_archive_dir" + fi + if [ "${restart_qdrant:-0}" -eq 1 ]; then + docker compose --project-name "$project_name" start qdrant >/dev/null + fi + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +private_archive_dir=$(mktemp -d "${TMPDIR:-/tmp}/qdrant-archive.XXXXXX") +private_archive_path="$private_archive_dir/archive.tar" +umask 077 +cp "$input" "$private_archive_path" +chmod 0600 "$private_archive_path" + +input_dir=$private_archive_dir +input_name=$(basename "$private_archive_path") resolve_volume() { names=$(docker volume ls \ @@ -60,7 +80,7 @@ validate_volume_metadata() { } validate_archive_paths() { - paths=$(tar -tf "$input") || { + paths=$(tar -tf "$private_archive_path") || { echo "archive listing failed" >&2 exit 2 } @@ -102,7 +122,7 @@ EOF } validate_archive_types() { - tar -tvf "$input" | while IFS= read -r entry; do + tar -tvf "$private_archive_path" | while IFS= read -r entry; do [ -n "$entry" ] || continue type=$(printf '%.1s' "$entry") case "$type" in @@ -124,7 +144,7 @@ validate_archive_types() { } validate_archive_manifest() { - manifest=$(tar -xOf "$input" manifest.env 2>/dev/null) || { + manifest=$(tar -xOf "$private_archive_path" manifest.env 2>/dev/null) || { echo "archive manifest could not be read" >&2 exit 2 } @@ -134,25 +154,59 @@ validate_archive_manifest() { manifest_role= manifest_helper= created_utc= - seen= + format_count=0 + project_count=0 + volume_count=0 + role_count=0 + helper_count=0 + created_count=0 while IFS='=' read -r key value; do [ -n "$key" ] || continue case "$key" in - format) format=$value ;; - project_name) manifest_project=$value ;; - volume_name) manifest_volume=$value ;; - volume_role) manifest_role=$value ;; - helper_image) manifest_helper=$value ;; - created_utc) created_utc=$value ;; + format) + format_count=$((format_count + 1)) + [ "$format_count" -eq 1 ] || { echo "archive manifest contains duplicate keys" >&2; exit 2; } + format=$value + ;; + project_name) + project_count=$((project_count + 1)) + [ "$project_count" -eq 1 ] || { echo "archive manifest contains duplicate keys" >&2; exit 2; } + manifest_project=$value + ;; + volume_name) + volume_count=$((volume_count + 1)) + [ "$volume_count" -eq 1 ] || { echo "archive manifest contains duplicate keys" >&2; exit 2; } + manifest_volume=$value + ;; + volume_role) + role_count=$((role_count + 1)) + [ "$role_count" -eq 1 ] || { echo "archive manifest contains duplicate keys" >&2; exit 2; } + manifest_role=$value + ;; + helper_image) + helper_count=$((helper_count + 1)) + [ "$helper_count" -eq 1 ] || { echo "archive manifest contains duplicate keys" >&2; exit 2; } + manifest_helper=$value + ;; + created_utc) + created_count=$((created_count + 1)) + [ "$created_count" -eq 1 ] || { echo "archive manifest contains duplicate keys" >&2; exit 2; } + created_utc=$value + ;; *) echo "archive manifest contains unexpected fields" >&2 exit 2 ;; esac - seen="$seen $key" done <&2; exit 2; } + [ "$project_count" -eq 1 ] || { echo "archive manifest is incomplete" >&2; exit 2; } + [ "$volume_count" -eq 1 ] || { echo "archive manifest is incomplete" >&2; exit 2; } + [ "$role_count" -eq 1 ] || { echo "archive manifest is incomplete" >&2; exit 2; } + [ "$helper_count" -eq 1 ] || { echo "archive manifest is incomplete" >&2; exit 2; } + [ "$created_count" -eq 1 ] || { echo "archive manifest is incomplete" >&2; exit 2; } [ "$format" = "$archive_format" ] || { echo "archive format is unsupported" >&2; exit 2; } [ "$manifest_project" = "$project_name" ] || { echo "archive project does not match restore target" >&2; exit 2; } [ "$manifest_volume" = "$expected_volume_name" ] || { echo "archive volume does not match restore target" >&2; exit 2; } @@ -170,14 +224,6 @@ validate_volume_metadata "$volume_name" running_container=$(docker compose --project-name "$project_name" ps --status running -q qdrant) restart_qdrant=0 -cleanup() { - status=$? - if [ "$restart_qdrant" -eq 1 ]; then - docker compose --project-name "$project_name" start qdrant >/dev/null - fi - exit "$status" -} -trap cleanup EXIT HUP INT TERM if [ -n "$running_container" ]; then docker compose --project-name "$project_name" stop qdrant >/dev/null