fix: harden qdrant restore safety

This commit is contained in:
2026-08-08 19:37:09 +02:00
parent a6dbe1d023
commit afaab5931f
3 changed files with 461 additions and 83 deletions
+254 -33
View File
@@ -9,9 +9,10 @@ mkdir "$fakebin"
project="thoth-task8"
volume_name="${project}_qdrant-data"
mountpoint="$tmp/docker-volumes/$volume_name/_data"
mkdir -p "$mountpoint/collections/demo"
printf '%s' before-backup >"$mountpoint/collections/demo/state.json"
volume_root="$tmp/volumes"
volume_dir="$volume_root/$volume_name"
mkdir -p "$volume_dir/collections/demo"
printf '%s' before-backup >"$volume_dir/collections/demo/state.json"
cat >"$fakebin/docker" <<'SH'
#!/bin/sh
@@ -19,8 +20,49 @@ set -eu
log_file=${DOCKER_LOG:?}
printf '%s\n' "$*" >>"$log_file"
volume_dir_for() {
printf '%s/%s\n' "${VOLUME_ROOT:?}" "$1"
}
run_backup() {
volume_name=$1
backup_dir=$2
output_name=$3
staging=$(mktemp -d "${TMPDIR:-/tmp}/fake-qdrant-backup.XXXXXX")
mkdir -p "$staging/payload"
cat >"$staging/manifest.env" <<EOF
format=thothii-qdrant-backup-v1
project_name=${PROJECT_NAME:?}
volume_name=$volume_name
volume_role=qdrant-data
helper_image=${HELPER_IMAGE:?}
EOF
cp -R "$(volume_dir_for "$volume_name")"/. "$staging/payload"/
tar -C "$staging" -cf "$backup_dir/$output_name" manifest.env payload
rm -rf "$staging"
}
run_restore() {
volume_name=$1
backup_dir=$2
input_name=$3
volume_dir=$(volume_dir_for "$volume_name")
snapshot=$(mktemp -d "${TMPDIR:-/tmp}/fake-qdrant-rollback.XXXXXX")
extract=$(mktemp -d "${TMPDIR:-/tmp}/fake-qdrant-extract.XXXXXX")
cp -R "$volume_dir"/. "$snapshot"/
find "$volume_dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
if [ "${RUN_FAIL_AFTER_CLEANUP:-0}" = 1 ]; then
cp -R "$snapshot"/. "$volume_dir"/
rm -rf "$snapshot" "$extract"
exit 17
fi
tar -C "$extract" -xf "$backup_dir/$input_name"
cp -R "$extract/payload"/. "$volume_dir"/
rm -rf "$snapshot" "$extract"
}
if [ "$1" = volume ] && [ "$2" = ls ]; then
if [ "${VOLUME_LS_OUTPUT:-}" = multiple ]; then
if [ "${VOLUME_LS_OUTPUT:-single}" = multiple ]; then
printf '%s\n%s\n' "${PROJECT_NAME}_qdrant-data" "${PROJECT_NAME}_qdrant-data-copy"
exit 0
fi
@@ -29,7 +71,10 @@ if [ "$1" = volume ] && [ "$2" = ls ]; then
fi
if [ "$1" = volume ] && [ "$2" = inspect ]; then
printf '%s\n' "${MOUNTPOINT:?}"
printf '%s %s %s\n' \
"${PROJECT_NAME}_qdrant-data" \
"${PROJECT_NAME}" \
"qdrant-data"
exit 0
fi
@@ -41,68 +86,244 @@ if [ "$1" = compose ] && [ "$2" = --project-name ]; then
fi
exit 0
;;
stop)
exit 0
;;
start)
stop|start)
exit 0
;;
esac
fi
if [ "$1" = run ]; then
shift
volume_mount=
backup_mount=
backup_readonly=0
while [ "$#" -gt 0 ]; do
case "$1" in
--mount)
case "$2" in
type=volume,src=*,dst=/qdrant-data*)
volume_mount=$2
;;
type=bind,src=*,dst=/backup*)
backup_mount=$2
;;
type=bind,src=*,dst=/restore-backup,readonly)
backup_mount=$2
backup_readonly=1
;;
esac
shift 2
;;
--rm)
shift
;;
*)
break
;;
esac
done
image=$1
shift
last_arg=
for arg in "$@"; do last_arg=$arg; done
volume_name=$(printf '%s' "$volume_mount" | sed -n 's/.*src=\([^,]*\),dst=.*/\1/p')
backup_src=$(printf '%s' "$backup_mount" | sed -n 's/.*src=\([^,]*\),dst=.*/\1/p')
[ "$image" = "${HELPER_IMAGE:?}" ] || exit 19
[ -n "$volume_name" ] || exit 20
[ -n "$backup_src" ] || exit 21
case "$backup_mount" in
*dst=/backup)
run_backup "$volume_name" "$backup_src" "$last_arg"
;;
*dst=/restore-backup,readonly)
[ "$backup_readonly" -eq 1 ] || exit 22
run_restore "$volume_name" "$backup_src" "$last_arg"
;;
*)
exit 23
;;
esac
exit 0
fi
exit 0
SH
chmod 0755 "$fakebin/docker"
make_foreign_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=wrong-format\nproject_name=foreign\nvolume_name=foreign_qdrant-data\nvolume_role=qdrant-data\nhelper_image=foreign\n"
info = tarfile.TarInfo("manifest.env")
info.size = len(manifest)
tf.addfile(info, io.BytesIO(manifest))
data = b"x"
info = tarfile.TarInfo("../escape")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
PY
}
make_symlink_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\nproject_name=thoth-task8\nvolume_name=thoth-task8_qdrant-data\nvolume_role=qdrant-data\nhelper_image=qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c\n"
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)
link = tarfile.TarInfo("payload/collections-link")
link.type = tarfile.SYMTYPE
link.linkname = "/etc/passwd"
tf.addfile(link)
PY
}
backup_output="$tmp/qdrant-backup.tar"
docker_log="$tmp/docker.log"
PATH="$fakebin:$PATH" DOCKER_LOG="$docker_log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
docker_log="$tmp/docker-backup.log"
PATH="$fakebin:$PATH" DOCKER_LOG="$docker_log" PROJECT_NAME="$project" VOLUME_ROOT="$volume_root" \
HELPER_IMAGE="qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c" \
./scripts/vector-backup.sh --project-name "$project" --output "$backup_output" >/dev/null
test -s "$backup_output"
tar -tf "$backup_output" | grep -q '^./collections/demo/state.json$'
grep -q "volume ls --filter label=com.docker.compose.project=$project --filter label=com.docker.compose.volume=qdrant-data" "$docker_log"
grep -q "compose --project-name $project ps --status running -q qdrant" "$docker_log"
tar -tf "$backup_output" | grep -qx 'manifest.env'
tar -tf "$backup_output" | grep -qx 'payload/collections/demo/state.json'
tar -xOf "$backup_output" manifest.env | grep -qx 'format=thothii-qdrant-backup-v1'
tar -xOf "$backup_output" manifest.env | grep -qx "project_name=$project"
tar -xOf "$backup_output" manifest.env | grep -qx "volume_name=$volume_name"
grep -q "run --rm --mount type=volume,src=$volume_name,dst=/qdrant-data,readonly --mount type=bind,src=$tmp,dst=/backup" "$docker_log"
grep -q "compose --project-name $project stop qdrant" "$docker_log"
grep -q "compose --project-name $project start qdrant" "$docker_log"
if grep -q "volume inspect --format {{ .Mountpoint }}" "$docker_log"; then
echo "backup consulted Docker mountpoints" >&2
exit 1
fi
if grep -q "prune" "$docker_log"; then
echo "backup attempted global docker cleanup" >&2
exit 1
fi
existing="$tmp/existing.tar"
printf '%s' sentinel >"$existing"
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/existing.log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
./scripts/vector-backup.sh --project-name "$project" --output "$existing" >"$tmp/existing.out" 2>"$tmp/existing.err"; then
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/existing.log" PROJECT_NAME="$project" VOLUME_ROOT="$volume_root" \
HELPER_IMAGE="qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c" \
./scripts/vector-backup.sh --project-name "$project" --output "$existing" >"$tmp/existing.out" 2>"$tmp/existing.err"; then
echo "backup overwrote an existing archive" >&2
exit 1
fi
test "$(cat "$existing")" = sentinel
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/ambiguous.log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" VOLUME_LS_OUTPUT=multiple \
./scripts/vector-backup.sh --project-name "$project" --output "$tmp/ambiguous.tar" >"$tmp/ambiguous.out" 2>"$tmp/ambiguous.err"; then
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/ambiguous.log" PROJECT_NAME="$project" VOLUME_ROOT="$volume_root" \
VOLUME_LS_OUTPUT=multiple HELPER_IMAGE="qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c" \
./scripts/vector-backup.sh --project-name "$project" --output "$tmp/ambiguous.tar" >"$tmp/ambiguous.out" 2>"$tmp/ambiguous.err"; then
echo "backup accepted an ambiguous qdrant-data target" >&2
exit 1
fi
grep -q 'exactly one qdrant-data volume' "$tmp/ambiguous.err"
restore_input="$tmp/restore.tar"
restore_source="$tmp/restore-source"
mkdir -p "$restore_source/collections/demo"
printf '%s' restored >"$restore_source/collections/demo/state.json"
tar -C "$restore_source" -cf "$restore_input" .
foreign_archive="$tmp/foreign.tar"
make_foreign_archive "$foreign_archive"
printf '%s' before-malicious >"$volume_dir/collections/demo/state.json"
foreign_log="$tmp/foreign.log"
: >"$foreign_log"
if PATH="$fakebin:$PATH" DOCKER_LOG="$foreign_log" PROJECT_NAME="$project" VOLUME_ROOT="$volume_root" \
MOUNTPOINT="/" HELPER_IMAGE="qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c" \
./scripts/vector-restore.sh --project-name "$project" --input "$foreign_archive" --confirm-project "$project" \
>"$tmp/foreign.out" 2>"$tmp/foreign.err"; then
echo "restore accepted a malformed or foreign archive" >&2
exit 1
fi
grep -Eq 'manifest|archive|foreign|unexpected|traversal' "$tmp/foreign.err"
test "$(cat "$volume_dir/collections/demo/state.json")" = before-malicious
if grep -q "compose --project-name $project stop qdrant" "$foreign_log"; then
echo "restore stopped qdrant before archive validation" >&2
exit 1
fi
if grep -q '^run ' "$foreign_log"; then
echo "restore ran helper container before archive validation" >&2
exit 1
fi
if grep -q "volume inspect --format {{ .Mountpoint }}" "$foreign_log"; then
echo "restore consulted Docker mountpoints during malformed archive rejection" >&2
exit 1
fi
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/restore-refuse.log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
./scripts/vector-restore.sh --project-name "$project" --input "$restore_input" --confirm-project wrong-project \
>"$tmp/restore-refuse.out" 2>"$tmp/restore-refuse.err"; then
symlink_archive="$tmp/symlink.tar"
make_symlink_archive "$symlink_archive"
symlink_log="$tmp/symlink.log"
: >"$symlink_log"
if PATH="$fakebin:$PATH" DOCKER_LOG="$symlink_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 "$symlink_archive" --confirm-project "$project" \
>"$tmp/symlink.out" 2>"$tmp/symlink.err"; then
echo "restore accepted a symlink-bearing archive" >&2
exit 1
fi
grep -Eq 'symlink|hardlink|device|fifo|socket|archive' "$tmp/symlink.err"
if grep -q "compose --project-name $project stop qdrant" "$symlink_log"; then
echo "restore stopped qdrant before symlink archive rejection" >&2
exit 1
fi
restore_source="$tmp/restore-source"
mkdir -p "$restore_source/payload/collections/demo"
cat >"$restore_source/manifest.env" <<EOF
format=thothii-qdrant-backup-v1
project_name=$project
volume_name=$volume_name
volume_role=qdrant-data
helper_image=qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c
created_utc=2026-08-08T17:35:36Z
EOF
printf '%s' restored >"$restore_source/payload/collections/demo/state.json"
restore_input="$tmp/restore.tar"
tar -C "$restore_source" -cf "$restore_input" manifest.env payload
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/confirm.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 "$restore_input" --confirm-project wrong-project \
>"$tmp/confirm.out" 2>"$tmp/confirm.err"; then
echo "restore skipped explicit project confirmation" >&2
exit 1
fi
grep -q 'confirmation must match --project-name exactly' "$tmp/restore-refuse.err"
test "$(cat "$mountpoint/collections/demo/state.json")" = before-backup
grep -q 'confirmation must match --project-name exactly' "$tmp/confirm.err"
printf '%s' modified-live >"$mountpoint/collections/demo/state.json"
printf '%s' modified-live >"$volume_dir/collections/demo/state.json"
restore_log="$tmp/restore-ok.log"
PATH="$fakebin:$PATH" DOCKER_LOG="$restore_log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
PATH="$fakebin:$PATH" DOCKER_LOG="$restore_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 "$restore_input" --confirm-project "$project" >/dev/null
test "$(cat "$mountpoint/collections/demo/state.json")" = restored
test "$(cat "$volume_dir/collections/demo/state.json")" = restored
grep -q "compose --project-name $project stop qdrant" "$restore_log"
grep -q "compose --project-name $project start qdrant" "$restore_log"
grep -q "volume inspect --format {{ .Mountpoint }} $volume_name" "$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"
if grep -q "volume inspect --format {{ .Mountpoint }}" "$restore_log"; then
echo "restore consulted Docker mountpoints during normal restore" >&2
exit 1
fi
if grep -q "prune" "$restore_log"; then
echo "restore attempted global docker cleanup" >&2
exit 1
fi
echo "qdrant backup/restore target resolution, refusal, and service-state contracts passed."
printf '%s' rollback-source >"$volume_dir/collections/demo/state.json"
rollback_log="$tmp/restore-rollback.log"
if PATH="$fakebin:$PATH" DOCKER_LOG="$rollback_log" PROJECT_NAME="$project" VOLUME_ROOT="$volume_root" \
RUN_FAIL_AFTER_CLEANUP=1 HELPER_IMAGE="qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c" \
./scripts/vector-restore.sh --project-name "$project" --input "$restore_input" --confirm-project "$project" \
>"$tmp/rollback.out" 2>"$tmp/rollback.err"; then
echo "restore ignored helper failure after cleanup" >&2
exit 1
fi
test "$(cat "$volume_dir/collections/demo/state.json")" = rollback-source
grep -q "compose --project-name $project stop qdrant" "$rollback_log"
grep -q "compose --project-name $project start qdrant" "$rollback_log"
echo "qdrant backup/restore archive validation, scoped helper execution, and rollback safety passed."
+52 -14
View File
@@ -1,6 +1,10 @@
#!/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 --output FILE" >&2
exit 2
@@ -23,37 +27,43 @@ output_dir=$(dirname "$output")
output_name=$(basename "$output")
[ -d "$output_dir" ] || { echo "backup destination directory does not exist" >&2; exit 2; }
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=qdrant-data" \
--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
}
printf '%s\n' "$names" | sed -n '/./{p;q;}'
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"
}
resolve_mountpoint() {
mountpoint=$(docker volume inspect --format '{{ .Mountpoint }}' "$1")
[ -n "$mountpoint" ] || { echo "docker did not return a qdrant-data mountpoint" >&2; exit 2; }
case "$mountpoint" in
/*) ;;
*) echo "qdrant-data mountpoint is not absolute: $mountpoint" >&2; exit 2 ;;
esac
[ -d "$mountpoint" ] || { echo "qdrant-data mountpoint is not a directory: $mountpoint" >&2; exit 2; }
printf '%s\n' "$mountpoint"
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; }
}
volume_name=$(resolve_volume)
mountpoint=$(resolve_mountpoint "$volume_name")
validate_volume_metadata "$volume_name"
running_container=$(docker compose --project-name "$project_name" ps --status running -q qdrant)
restart_qdrant=0
temporary_output=
cleanup() {
status=$?
if [ "${temporary_output:-}" ] && [ -e "${temporary_output:-}" ]; then
if [ -n "${temporary_output:-}" ] && [ -e "${temporary_output:-}" ]; then
rm -f "$temporary_output"
fi
if [ "$restart_qdrant" -eq 1 ]; then
@@ -70,7 +80,35 @@ fi
umask 077
temporary_output=$(mktemp "$output_dir/.${output_name}.tmp.XXXXXX")
tar -C "$mountpoint" -cf "$temporary_output" .
created_utc=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
docker run --rm \
--mount "type=volume,src=$volume_name,dst=/qdrant-data,readonly" \
--mount "type=bind,src=$output_dir,dst=/backup" \
"$helper_image" \
/bin/sh -eu -c '
archive_format=$1
project_name=$2
volume_name=$3
volume_role=$4
helper_image=$5
created_utc=$6
output_name=$7
staging=$(mktemp -d /tmp/qdrant-backup.XXXXXX)
trap "rm -rf \"$staging\"" EXIT HUP INT TERM
mkdir -p "$staging/payload"
cat >"$staging/manifest.env" <<EOF
format=$archive_format
project_name=$project_name
volume_name=$volume_name
volume_role=$volume_role
helper_image=$helper_image
created_utc=$created_utc
EOF
cp -R /qdrant-data/. "$staging/payload"/
tar -C "$staging" -cf "/backup/$output_name" manifest.env payload
' sh "$archive_format" "$project_name" "$volume_name" "$volume_role" "$helper_image" "$created_utc" "$(basename "$temporary_output")"
if ! ln "$temporary_output" "$output"; then
echo "refusing to replace backup destination created concurrently: $output" >&2
exit 2
+155 -36
View File
@@ -1,6 +1,10 @@
#!/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
@@ -25,49 +29,149 @@ 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}"
resolve_volume() {
names=$(docker volume ls \
--filter "label=com.docker.compose.project=$project_name" \
--filter "label=com.docker.compose.volume=qdrant-data" \
--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
}
printf '%s\n' "$names" | sed -n '/./{p;q;}'
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"
}
resolve_mountpoint() {
mountpoint=$(docker volume inspect --format '{{ .Mountpoint }}' "$1")
[ -n "$mountpoint" ] || { echo "docker did not return a qdrant-data mountpoint" >&2; exit 2; }
case "$mountpoint" in
/*) ;;
*) echo "qdrant-data mountpoint is not absolute: $mountpoint" >&2; exit 2 ;;
esac
[ -d "$mountpoint" ] || { echo "qdrant-data mountpoint is not a directory: $mountpoint" >&2; exit 2; }
printf '%s\n' "$mountpoint"
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 <<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 "$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 <<EOF
$manifest
EOF
[ "$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
volume_name=$(resolve_volume)
mountpoint=$(resolve_mountpoint "$volume_name")
validate_volume_metadata "$volume_name"
running_container=$(docker compose --project-name "$project_name" ps --status running -q qdrant)
restart_qdrant=0
staging_dir=
extract_dir=
cleanup() {
status=$?
if [ "$status" -ne 0 ] && [ -n "${staging_dir:-}" ] && [ -d "${staging_dir:-}" ]; then
find "$mountpoint" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
find "$staging_dir" -mindepth 1 -maxdepth 1 -exec mv {} "$mountpoint"/ \;
fi
if [ -n "${staging_dir:-}" ] && [ -d "${staging_dir:-}" ]; then
rm -rf "$staging_dir"
fi
if [ -n "${extract_dir:-}" ] && [ -d "${extract_dir:-}" ]; then
rm -rf "$extract_dir"
fi
if [ "$restart_qdrant" -eq 1 ]; then
docker compose --project-name "$project_name" start qdrant >/dev/null
fi
@@ -80,16 +184,31 @@ if [ -n "$running_container" ]; then
restart_qdrant=1
fi
parent_dir=$(dirname "$mountpoint")
staging_dir=$(mktemp -d "$parent_dir/.qdrant-restore-staging.XXXXXX")
extract_dir=$(mktemp -d "${TMPDIR:-/tmp}/qdrant-restore.XXXXXX")
tar -C "$extract_dir" -xf "$input"
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"
find "$mountpoint" -mindepth 1 -maxdepth 1 -exec mv {} "$staging_dir"/ \;
find "$extract_dir" -mindepth 1 -maxdepth 1 -exec mv {} "$mountpoint"/ \;
rm -rf "$staging_dir"
staging_dir=
rm -rf "$extract_dir"
extract_dir=
echo "Qdrant restore completed into $volume_name for compose project $project_name"