215 lines
6.3 KiB
Bash
Executable File
215 lines
6.3 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; }
|
|
|
|
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 <<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)
|
|
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"
|