Files
ThothII/scripts/vector-restore.sh

272 lines
8.6 KiB
Bash
Executable File

#!/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; }
expected_volume_name="${project_name}_${volume_role}"
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
. "$script_dir/lib/vector-operation-lock.sh"
vector_operation_lock_init
private_archive_dir=
private_archive_path=
restart_qdrant=0
cleanup() {
status=$?
trap - EXIT HUP INT TERM
if [ -n "${private_archive_dir:-}" ] && [ -d "${private_archive_dir:-}" ]; then
rm -rf "$private_archive_dir"
fi
if [ "${restart_qdrant:-0}" -eq 1 ]; then
if ! docker compose --project-name "$project_name" start qdrant >/dev/null; then
echo "Unable to restart Qdrant after restore" >&2
[ "$status" -ne 0 ] || status=1
fi
fi
if ! release_vector_operation_lock; then
[ "$status" -ne 0 ] || status=1
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 \
--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 "$private_archive_path") || {
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 <<EOF
$paths
EOF
[ "$saw_manifest" -eq 1 ] || { echo "archive manifest is missing or duplicated" >&2; exit 2; }
[ "$saw_payload" -eq 1 ] || { echo "archive payload is missing" >&2; exit 2; }
}
validate_archive_types() {
tar -tvf "$private_archive_path" | 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 "$private_archive_path" 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=
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_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
done <<EOF
$manifest
EOF
[ "$format_count" -eq 1 ] || { echo "archive manifest is incomplete" >&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; }
[ "$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
acquire_vector_operation_lock
volume_name=$(resolve_volume)
validate_volume_metadata "$volume_name"
running_container=$(docker compose --project-name "$project_name" ps --status running -q qdrant)
if [ -n "$running_container" ]; then
restart_qdrant=1
docker compose --project-name "$project_name" stop qdrant >/dev/null
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"