fix: harden qdrant restore safety
This commit is contained in:
+155
-36
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user