#!/bin/sh set -eu helper_image='qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c' archive_format='thothii-qdrant-backup-v1' volume_role='qdrant-data' usage() { echo "usage: $0 --project-name NAME --input FILE --confirm-project NAME" >&2 exit 2 } project_name= input= confirm_project= while [ "$#" -gt 0 ]; do case "$1" in --project-name) project_name=${2-}; shift 2 ;; --input) input=${2-}; shift 2 ;; --confirm-project) confirm_project=${2-}; shift 2 ;; *) usage ;; esac done [ -n "$project_name" ] && [ -n "$input" ] && [ -n "$confirm_project" ] || usage [ "$confirm_project" = "$project_name" ] || { echo "restore confirmation must match --project-name exactly" >&2 exit 2 } [ -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}" resolve_volume() { names=$(docker volume ls \ --filter "label=com.docker.compose.project=$project_name" \ --filter "label=com.docker.compose.volume=$volume_role" \ --format '{{.Name}}') count=$(printf '%s\n' "$names" | sed '/^$/d' | wc -l | tr -d ' ') [ "$count" -eq 1 ] || { echo "expected exactly one qdrant-data volume for compose project $project_name" >&2 exit 2 } resolved=$(printf '%s\n' "$names" | sed -n '/./{p;q;}') [ "$resolved" = "$expected_volume_name" ] || { echo "unexpected qdrant-data volume name: $resolved" >&2 exit 2 } printf '%s\n' "$resolved" } validate_volume_metadata() { metadata=$(docker volume inspect --format '{{ .Name }} {{ index .Labels "com.docker.compose.project" }} {{ index .Labels "com.docker.compose.volume" }}' "$1") set -- $metadata [ "${1-}" = "$expected_volume_name" ] || { echo "volume metadata name mismatch" >&2; exit 2; } [ "${2-}" = "$project_name" ] || { echo "volume metadata project label mismatch" >&2; exit 2; } [ "${3-}" = "$volume_role" ] || { echo "volume metadata role label mismatch" >&2; exit 2; } } validate_archive_paths() { paths=$(tar -tf "$input") || { echo "archive listing failed" >&2 exit 2 } saw_manifest=0 saw_payload=0 while IFS= read -r path; do [ -n "$path" ] || continue case "$path" in /*) echo "archive contains absolute paths" >&2 exit 2 ;; esac case "/$path/" in */../*|*/..//*) echo "archive contains parent traversal" >&2 exit 2 ;; esac case "$path" in manifest.env) saw_manifest=$((saw_manifest + 1)) ;; payload|payload/*) case "$path" in payload/*) saw_payload=1 ;; esac ;; *) echo "archive contains unexpected top-level layout" >&2 exit 2 ;; esac done <&2; exit 2; } [ "$saw_payload" -eq 1 ] || { echo "archive payload is missing" >&2; exit 2; } } validate_archive_types() { tar -tvf "$input" | while IFS= read -r entry; do [ -n "$entry" ] || continue type=$(printf '%.1s' "$entry") case "$type" in -|d) ;; l|h) echo "archive contains symlink or hardlink entries" >&2 exit 2 ;; b|c|p|s) echo "archive contains device or special-file entries" >&2 exit 2 ;; *) echo "archive contains unsupported entry types" >&2 exit 2 ;; esac done } validate_archive_manifest() { manifest=$(tar -xOf "$input" manifest.env 2>/dev/null) || { echo "archive manifest could not be read" >&2 exit 2 } format= manifest_project= manifest_volume= manifest_role= manifest_helper= created_utc= seen= 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 ;; *) echo "archive manifest contains unexpected fields" >&2 exit 2 ;; esac seen="$seen $key" done <&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; } [ "$manifest_role" = "$volume_role" ] || { echo "archive volume role is invalid" >&2; exit 2; } [ "$manifest_helper" = "$helper_image" ] || { echo "archive helper image is invalid" >&2; exit 2; } [ -n "$created_utc" ] || { echo "archive manifest is incomplete" >&2; exit 2; } } validate_archive_paths validate_archive_types validate_archive_manifest volume_name=$(resolve_volume) 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 restart_qdrant=1 fi docker run --rm \ --mount "type=volume,src=$volume_name,dst=/qdrant-data" \ --mount "type=bind,src=$input_dir,dst=/restore-backup,readonly" \ "$helper_image" \ /bin/sh -eu -c ' input_name=$1 rollback=$(mktemp -d /tmp/qdrant-rollback.XXXXXX) extracted=$(mktemp -d /tmp/qdrant-extract.XXXXXX) restore_original() { find /qdrant-data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + cp -R "$rollback"/. /qdrant-data/ } cleanup() { status=$? if [ "$status" -ne 0 ] && [ -d "$rollback" ]; then restore_original fi rm -rf "$rollback" "$extracted" exit "$status" } trap cleanup EXIT HUP INT TERM cp -R /qdrant-data/. "$rollback"/ tar -C "$extracted" -xf "/restore-backup/$input_name" find /qdrant-data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + cp -R "$extracted/payload"/. /qdrant-data/ ' sh "$input_name" echo "Qdrant restore completed into $volume_name for compose project $project_name"