do/commands/vault
2025-09-19 18:14:10 -04:00

1005 lines
No EOL
36 KiB
Bash
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ----------------------------------------------------
# Performs a full site backup to a Restic repository on B2.
# Credentials can be injected via environment variables or piped via stdin.
# ----------------------------------------------------
function vault_create() {
echo "🚀 Starting secure snapshot to Restic B2 repository..."
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_wp_cli; then return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then
echo "❌ Error: This does not appear to be a WordPress installation." >&2
return 1
fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
# --- Rclone Cache Size Check ---
if [ -n "$EMAIL_NOTIFY" ]; then
local rclone_cache_dir="$HOME/.cache/rclone"
if [ -d "$rclone_cache_dir" ]; then
local cache_size_bytes
cache_size_bytes=$(du -sb "$rclone_cache_dir" | awk '{print $1}')
local size_limit_bytes=10737418240 # 10 GB
if (( cache_size_bytes > size_limit_bytes )); then
local site_url
site_url=$("$WP_CLI_CMD" option get home)
local cache_size_gb
cache_size_gb=$(awk -v bytes="$cache_size_bytes" 'BEGIN { printf "%.2f", bytes / 1024 / 1024 / 1024 }')
local email_subject="Rclone Cache Warning for ${site_url}"
local email_message="Warning: The rclone cache folder at ${rclone_cache_dir} is larger than 10GB. Current size: ${cache_size_gb}GB. This might cause issues with 'vault create' operations."
echo " - ⚠️ Rclone cache is large (${cache_size_gb}GB). Sending email notification to $EMAIL_NOTIFY..."
"$WP_CLI_CMD" eval "wp_mail( '$EMAIL_NOTIFY', '$email_subject', '$email_message', ['Content-Type: text/html; charset=UTF-8'] );"
fi
fi
fi
# --- Check/Initialize Restic Repository ---
echo " - Checking for repository at ${RESTIC_REPOSITORY}..."
if ! "$RESTIC_CMD" stats > /dev/null 2>&1; then
echo " - Repository not found or is invalid. Attempting to initialize..."
if ! "$RESTIC_CMD" init; then
echo "❌ Error: Failed to initialize Restic repository." >&2
return 1
fi
echo " - ✅ Repository initialized successfully."
else
echo " - ✅ Repository found."
fi
# --- Create DB Dump in Private Directory ---
local private_dir
if ! private_dir=$(_get_private_dir); then
return 1
fi
local sql_file_path="${private_dir}/database-backup.sql"
echo " - Generating database dump at: ${sql_file_path}"
if ! "$WP_CLI_CMD" db export "$sql_file_path" --add-drop-table --single-transaction --quick --max_allowed_packet=512M > /dev/null; then
echo "❌ Error: Database export failed." >&2
return 1
fi
# --- Move DB dump to root for snapshot ---
local wp_root_dir
wp_root_dir=$(realpath ".")
local temporary_sql_path_in_root="${wp_root_dir}/database-backup.sql"
echo " - Temporarily moving database dump to web root for snapshotting."
if ! mv "$sql_file_path" "$temporary_sql_path_in_root"; then
echo "❌ Error: Could not move database dump to web root." >&2
return 1
fi
# --- Run Restic Backup ---
local original_dir
original_dir=$(pwd)
echo " - Changing to WordPress root ($wp_root_dir) for clean snapshot paths..."
cd "$wp_root_dir" || {
echo "❌ Error: Could not change to WordPress root directory." >&2
echo " - Attempting to move database dump back to private directory..."
mv "$temporary_sql_path_in_root" "$sql_file_path"
return 1
}
local human_readable_size
human_readable_size=$(du -sh . | awk '{print $1}')
local tag_args=()
if [[ -n "$human_readable_size" ]]; then
tag_args+=(--tag "size:${human_readable_size}")
fi
echo " - Backing up current directory (.), which now includes the SQL dump..."
if ! "$RESTIC_CMD" backup "." \
"${tag_args[@]}" \
--exclude '**.DS_Store' \
--exclude '*timthumb.txt' \
--exclude 'debug.log' \
--exclude 'error_log' \
--exclude 'phperror_log' \
--exclude 'wp-content/updraft' \
--exclude 'wp-content/cache' \
--exclude 'wp-content/et-cache' \
--exclude 'wp-content/.wps-slots' \
--exclude 'wp-content/wflogs' \
--exclude 'wp-content/uploads/sessions' \
--exclude 'wp-snapshots'; then
echo "❌ Error: Restic backup command failed." >&2
cd "$original_dir"
echo " - Moving database dump back to private directory..."
mv "$temporary_sql_path_in_root" "$sql_file_path"
return 1
fi
# --- Cleanup: Move DB dump back to private directory ---
cd "$original_dir"
echo " - Moving database dump back to private directory..."
mv "$temporary_sql_path_in_root" "$sql_file_path"
# Unset variables for security
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
echo "✅ Vault snapshot complete!"
}
# ----------------------------------------------------
# (Helper) Reads credentials and sets up the restic environment.
# ----------------------------------------------------
function _setup_vault_env() {
# --- Read Credentials ---
local b2_bucket b2_path b2_key_id b2_app_key restic_password
# Prioritize stdin: If data is being piped in, read from it.
if ! [ -t 0 ]; then
read -r stdin_b2_bucket
read -r stdin_b2_path
read -r stdin_b2_key_id
read -r stdin_b2_app_key
read -r stdin_restic_password
b2_bucket=${stdin_b2_bucket}
b2_path=${stdin_b2_path}
b2_key_id=${stdin_b2_key_id}
b2_app_key=${stdin_b2_app_key}
restic_password=${stdin_restic_password}
fi
# If stdin empty or incomplete, then attempt to load from environment variables.
if [ -z "$b2_bucket" ] || [ -z "$b2_path" ] || [ -z "$b2_key_id" ] || [ -z "$b2_app_key" ] || [ -z "$restic_password" ]; then
b2_bucket="$B2_BUCKET"
b2_path="$B2_PATH"
b2_key_id="$B2_ACCOUNT_ID"
b2_app_key="$B2_ACCOUNT_KEY"
restic_password="$RESTIC_PASSWORD"
fi
if [ -z "$b2_bucket" ] || [ -z "$b2_path" ] || [ -z "$b2_key_id" ] || [ -z "$b2_app_key" ] || [ -z "$restic_password" ]; then
echo "❌ Error: One or more required credentials were not provided or were empty." >&2
return 1
fi
export B2_ACCOUNT_ID="$b2_key_id"
export B2_ACCOUNT_KEY="$b2_app_key"
export RESTIC_PASSWORD="$restic_password"
export RESTIC_REPOSITORY="b2:${b2_bucket}:${b2_path}"
return 0
}
# ----------------------------------------------------
# (Helper) Caches the file list for a snapshot.
# ----------------------------------------------------
function _cache_snapshot_files() {
local snapshot_id="$1"
local cache_file="$2"
# Show a spinner while caching the entire file list.
if ! "$GUM_CMD" spin --spinner dot --title "Caching file list for snapshot ${snapshot_id}..." -- \
"$RESTIC_CMD" ls --json --long --recursive "${snapshot_id}" > "$cache_file";
then
echo "❌ Error: Could not cache the file list for snapshot ${snapshot_id}."
>&2
rm -f "$cache_file" # Clean up partial cache file
return 1
fi
return 0
}
# ----------------------------------------------------
# (Helper) Provides an interactive menu for a selected file.
# ----------------------------------------------------
function _file_action_menu() {
local snapshot_id="$1"
local file_path="$2"
local choice
choice=$("$GUM_CMD" choose "View Content" "Download File" "Restore File" "Back")
case "$choice" in
"View Content")
echo "📄 Viewing content of '$file_path'... (Press 'q' to quit)"
local temp_file
temp_file=$(mktemp)
if [ -z "$temp_file" ];
then
echo "❌ Error: Could not create a temporary file."
>&2
sleep 2
return
fi
# Dump the file content to the temporary file
if ! "$RESTIC_CMD" dump "${snapshot_id}" "${file_path}" > "$temp_file"; then
echo "❌ Error: Could not dump file content from repository."
>&2
rm -f "$temp_file"
sleep 2
return
fi
# View the temporary file with less, ensuring it's interactive
less -RN "$temp_file" </dev/tty
# Clean up the temporary file
rm -f "$temp_file"
;;
"Download File")
local filename
filename=$(basename "$file_path")
echo "⬇️ Downloading '$filename' to current directory..."
if "$GUM_CMD" confirm "Download '${filename}' to '$(pwd)'?";
then
if "$RESTIC_CMD" dump "${snapshot_id}" "${file_path}" > "$filename";
then
local size
size=$(ls -lh "$filename" | awk '{print $5}')
echo "✅ File downloaded: $filename ($size)"
else
echo "❌ Error: Failed to download file."
rm -f "$filename"
fi
else
echo "Download cancelled."
fi
"$GUM_CMD" input --placeholder="Press Enter to continue..." > /dev/null
;;
"Restore File")
echo "🔄 Restoring '$file_path' to current directory..."
if "$GUM_CMD" confirm "Restore '${file_path}' to '$(pwd)'?";
then
if "$RESTIC_CMD" restore "${snapshot_id}" --include "${file_path}" --target ".";
then
echo "✅ File restored successfully."
else
echo "❌ Error: Failed to restore file."
fi
else
echo "Restore cancelled."
fi
# Add a pause so the user can see the result before returning to the browser.
"$GUM_CMD" input --placeholder="Press Enter to continue..." > /dev/null
;;
"Back")
return
;;
*)
esac
}
# ----------------------------------------------------
# (Helper) Handles the download/restore of a full folder.
# ----------------------------------------------------
function _download_folder_action() {
local snapshot_id="$1"
local folder_path_in_repo="$2"
echo "📦 Preparing to download folder: '${folder_path_in_repo}'"
echo "This action will restore the selected folder and its full directory structure from the snapshot's root into your current working directory."
echo "For example, restoring '/wp-content/plugins/' will create the path './wp-content/plugins/' here."
if "$GUM_CMD" confirm "Proceed with restoring '${folder_path_in_repo}' to '$(pwd)'?"; then
echo " - Restoring files..."
if "$RESTIC_CMD" restore "${snapshot_id}" --include "${folder_path_in_repo}" --target "."; then
echo "✅ Folder and its contents restored successfully."
else
echo "❌ Error: Failed to restore folder."
fi
else
echo "Download cancelled."
fi
"$GUM_CMD" input --placeholder="Press Enter to continue..." > /dev/null
}
# ----------------------------------------------------
# (Helper) Provides an interactive file browser for a snapshot.
# ----------------------------------------------------
function _browse_snapshot() {
local snapshot_id="$1"
local cache_file="$2"
local current_path="/"
while true;
do
clear
echo "🗂 Browse Snapshot: ${snapshot_id} | Path: ${current_path}"
# PHP script to parse the cached JSON and format it for the current directory
local php_parser_code='
<?php
$cache_file = $argv[1] ?? "";
$current_path = $argv[2] ?? "/";
if (!file_exists($cache_file)) {
fwrite(STDERR, "Cache file not found.\n");
exit(1);
}
$file_content = file_get_contents($cache_file);
$lines = explode("\n", trim($file_content));
$items_in_current_dir = [];
$dirs_in_current_dir = [];
foreach ($lines as $line) {
if (empty($line)) continue;
$item = json_decode($line, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($item["path"])) continue;
$item_path = $item["path"];
if (strpos($item_path, $current_path) !== 0) continue;
$relative_path = substr($item_path, strlen($current_path));
if (empty($relative_path)) continue;
if (strpos($relative_path, "/") === false) {
if ($item["type"] === "dir") {
$dirs_in_current_dir[$relative_path] = true;
} else {
$items_in_current_dir[$relative_path] = $item;
}
} else {
$dir_name = explode("/", $relative_path)[0];
$dirs_in_current_dir[$dir_name] = true;
}
}
ksort($dirs_in_current_dir);
ksort($items_in_current_dir);
if ($current_path !== "/") {
echo "⤴️ ../ (Up one level)|up|..\n";
}
foreach (array_keys($dirs_in_current_dir) as $dir) {
echo "📁 " . $dir . "/|dir|" . $dir . "\n";
}
foreach ($items_in_current_dir as $name => $item) {
$size_bytes = $item["size"] ?? 0;
$size_formatted = "0 B";
if ($size_bytes >= 1048576) { $size_formatted = round($size_bytes / 1048576, 2) . " MB"; }
elseif ($size_bytes >= 1024) { $size_formatted = round($size_bytes / 1024, 2) . " KB"; }
elseif ($size_bytes > 0) { $size_formatted = $size_bytes . " B"; }
echo "📄 " . $name . " (" . $size_formatted . ")|file|" . $name . "\n";
}
echo "\n";
echo "💾 Download this directory ({$current_path})|download_dir|.\n";
'
# Execute PHP script to get a formatted list from the cache
local temp_script_file;
temp_script_file=$(mktemp)
echo "$php_parser_code" > "$temp_script_file"
local formatted_list;
formatted_list=$(php -f "$temp_script_file" "$cache_file" "$current_path")
rm "$temp_script_file"
# --- Display and selection logic ---
local display_items=()
local data_items=()
while IFS='|'
read -r display_part type_part name_part; do
if [ -z "$display_part" ];
then continue; fi
display_items+=("$display_part")
data_items+=("${type_part}|${name_part}")
done <<< "$formatted_list"
local selected_display
# Use a for loop to pipe items to gum filter, avoiding argument list limits.
selected_display=$(
for item in "${display_items[@]}"; do
echo "$item"
done | "$GUM_CMD" filter --height=20 --prompt="👇 Select a snapshot to browse" --indicator="→" --placeholder=""
)
if [ -z "$selected_display" ];
then
return # Exit the browser
fi
local selected_index=-1
for i in "${!display_items[@]}";
do
if [[ "${display_items[$i]}" == "$selected_display" ]];
then
selected_index=$i
break
fi
done
if [ "$selected_index" -eq -1 ];
then continue; fi
local selected_data="${data_items[selected_index]}"
local item_type;
item_type=$(echo "$selected_data" | cut -d'|' -f1)
local item_name;
item_name=$(echo "$selected_data" | cut -d'|' -f2)
case "$item_type" in
"dir")
current_path="${current_path}${item_name}/"
;;
"file")
_file_action_menu "${snapshot_id}" "${current_path}${item_name}"
;;
"up")
if [[ "$current_path" != "/" ]]; then
# Get parent directory
parent_path=$(dirname "${current_path%/}")
# If the parent is the root, the new path is simply "/".
# Otherwise, it's the parent path with a trailing slash.
if [[ "$parent_path" == "/" ]]; then
current_path="/"
else
current_path="${parent_path}/"
fi
fi
;;
"download_dir")
_download_folder_action "${snapshot_id}" "${current_path}"
;;
esac
done
}
# ----------------------------------------------------
# Lists all snapshots in the Restic repository.
# ----------------------------------------------------
function vault_snapshots() {
local output_mode="$1"
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
if ! command -v php &>/dev/null; then echo "❌ Error: The 'php' command is required for this operation." >&2; return 1; fi
if ! command -v mktemp &>/dev/null; then echo "❌ Error: The 'mktemp' command is required for this operation." >&2; return 1; fi
if ! command -v wc &>/dev/null; then echo "❌ Error: The 'wc' command is required for this operation." >&2; return 1; fi
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
local snapshots_json
snapshots_json=$("$GUM_CMD" spin --spinner dot --title "Fetching snapshots from repository..." -- \
"$RESTIC_CMD" snapshots --json
)
if [[ ! "$snapshots_json" =~ ^\[ ]]; then
echo "Error: Failed to fetch snapshots. Restic output:" >&2
echo "$snapshots_json" >&2
exit 1
fi
local total_count
read -r -d '' php_script << 'EOF'
$json_string = file_get_contents("php://stdin");
$snapshots = json_decode($json_string, true);
if (json_last_error() === JSON_ERROR_NONE) {
echo count($snapshots);
} else {
echo 0;
}
EOF
total_count=$("$GUM_CMD" spin --spinner dot --title "Counting total snapshots..." -- \
bash -c 'php -r "$1"' _ "$php_script" <<< "$snapshots_json"
)
echo "🔎 Fetching ${total_count} snapshots..."
if [[ "$snapshots_json" == "[]" ]]; then
echo " No snapshots found in the repository."
return 0
fi
local php_parser_code='
<?php
if (defined("STDIN")) {
$json_data = file_get_contents("php://stdin");
$snapshots = json_decode($json_data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
fwrite(STDERR, "PHP Error: Failed to decode JSON - " . json_last_error_msg() . "\n");
exit(1);
}
if (empty($snapshots) || !is_array($snapshots)) {
exit(0);
}
usort($snapshots, function($a, $b) { return strtotime($b["time"]) - strtotime($a["time"]); });
foreach ($snapshots as $snap) {
$size_formatted = "N/A";
if (isset($snap["summary"]["total_bytes_processed"])) {
$size_bytes = (float)$snap["summary"]["total_bytes_processed"];
if ($size_bytes >= 1073741824) {
$size_formatted = round($size_bytes / 1073741824, 2) . " GB";
} elseif ($size_bytes >= 1048576) {
$size_formatted = round($size_bytes / 1048576, 2) . " MB";
} elseif ($size_bytes >= 1024) {
$size_formatted = round($size_bytes / 1024, 2) . " KB";
} elseif ($size_bytes > 0) {
$size_formatted = $size_bytes . " B";
}
}
echo $snap["short_id"] . "|" .
(new DateTime($snap["time"]))->format("Y-m-d H:i:s") . "|" .
$size_formatted . "\n";
}
}
'
local php_script_file
php_script_file=$(mktemp)
if [ -z "$php_script_file" ]; then
echo "❌ Error: Could not create a temporary file for the PHP script." >&2
return 1
fi
echo "$php_parser_code" > "$php_script_file"
local snapshot_list
snapshot_list=$(printf "%s" "$snapshots_json" | php -f "$php_script_file" 2>/dev/null)
rm -f "$php_script_file"
if [ -z "$snapshot_list" ]; then
echo "❌ Error parsing snapshot list." >&2
return 1
fi
local display_items=()
local data_items=()
while IFS='|' read -r id time size; do
if [ -z "$id" ]; then
continue
fi
display_items+=("$(printf "%-9s | %-20s | %-10s" "$id" "$time" "$size")")
data_items+=("$id")
done <<< "$snapshot_list"
if [[ "$output_mode" == "true" ]]; then
printf "%s\n" "${display_items[@]}"
return 0
fi
local selected_display
selected_display=$(printf "%s\n" "${display_items[@]}" | "$GUM_CMD" filter --height=20 --prompt="👇 Select a snapshot to browse" --indicator="→" --placeholder="")
if [ -z "$selected_display" ]; then echo "No snapshot selected."; return 0; fi
local selected_id
selected_id=$(echo "$selected_display" | awk '{print $1}')
local cache_file;
cache_file=$(mktemp)
if ! _cache_snapshot_files "$selected_id" "$cache_file"; then
[ -f "$cache_file" ] && rm -f "$cache_file"
return 1
fi
_browse_snapshot "$selected_id" "$cache_file"
rm -f "$cache_file"
}
# ----------------------------------------------------
# Mounts the Restic repository to a local directory.
# ----------------------------------------------------
function vault_mount() {
echo "🚀 Preparing to mount Restic repository..."
if ! setup_restic;
then return 1; fi
if ! _setup_vault_env; then return 1;
fi
local mount_point="/tmp/restic_mount_$(date +%s)"
mkdir -p "$mount_point"
echo " - Mount point created at: $mount_point"
echo " - To unmount, run: umount \"$mount_point\""
echo " - Press Ctrl+C to stop the foreground mount process."
"$RESTIC_CMD" mount "$mount_point"
}
# ----------------------------------------------------
# Displays statistics about the Restic repository.
# ----------------------------------------------------
function vault_info() {
echo "🔎 Gathering repository information..."
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
if ! setup_rclone; then return 1; fi
if ! command -v php &>/dev/null; then echo "❌ Error: The 'php' command is required for this operation." >&2; return 1; fi
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
# --- Get repo size and file count using rclone ---
local temp_repo_string="${RESTIC_REPOSITORY#b2:}"
local b2_bucket="${temp_repo_string%%:*}"
local b2_path="${temp_repo_string#*:}"
local rclone_remote_string=":b2,account='${B2_ACCOUNT_ID}',key='${B2_ACCOUNT_KEY}':${b2_bucket}/${b2_path}"
local size_json
size_json=$("$GUM_CMD" spin --spinner dot --title "Calculating repository size with rclone..." -- \
"$RCLONE_CMD" size --json "$rclone_remote_string"
)
local size_data=""
if [[ "$size_json" == *"bytes"* ]]; then
local size_parser_code='
$json_str = file_get_contents("php://stdin");
$data = json_decode($json_str, true);
if (json_last_error() === JSON_ERROR_NONE) {
$bytes = $data["bytes"] ?? 0;
$count = $data["count"] ?? 0;
$size_formatted = "0 B";
if ($bytes >= 1073741824) { $size_formatted = round($bytes / 1073741824, 2) . " GiB"; }
elseif ($bytes >= 1048576) { $size_formatted = round($bytes / 1048576, 2) . " MiB"; }
elseif ($bytes >= 1024) { $size_formatted = round($bytes / 1024, 2) . " KiB"; }
elseif ($bytes > 0) { $size_formatted = $bytes . " B"; }
echo "Total Size," . $size_formatted . "\n";
echo "Total Files," . $count . "\n";
}
'
size_data=$(echo "$size_json" | php -r "$size_parser_code")
fi
# --- Get Snapshot Info from Restic ---
local snapshots_json
snapshots_json=$("$GUM_CMD" spin --spinner dot --title "Fetching snapshot list..." -- \
"$RESTIC_CMD" snapshots --json)
if [ $? -ne 0 ]; then
echo "❌ Error fetching snapshots from restic." >&2
echo "Restic output: $snapshots_json" >&2
return 1
fi
# Verify JSON and default to empty array if invalid
if ! printf "%s" "$snapshots_json" | "$GUM_CMD" format >/dev/null 2>&1; then
snapshots_json="[]"
fi
# --- MODIFIED: Pipe Restic JSON directly to PHP parser ---
local php_parser_code_info='
$json_data = file_get_contents("php://stdin");
$snapshots = json_decode($json_data, true);
// Exit if JSON is invalid or empty after decoding
if (json_last_error() !== JSON_ERROR_NONE || !is_array($snapshots)) { exit(0); }
$snapshot_count = count($snapshots);
$oldest_date = "N/A";
$newest_date = "N/A";
if ($snapshot_count > 0) {
$timestamps = array_map(function($s) {
return isset($s["time"]) ? strtotime($s["time"]) : 0;
}, $snapshots);
$timestamps = array_filter($timestamps);
if(count($timestamps) > 0) {
$oldest_ts = min($timestamps);
$newest_ts = max($timestamps);
$oldest_date = date("Y-m-d H:i:s T", $oldest_ts);
$newest_date = date("Y-m-d H:i:s T", $newest_ts);
}
}
echo "Snapshot Count," . $snapshot_count . "\n";
echo "Oldest Snapshot," . $oldest_date . "\n";
echo "Newest Snapshot," . $newest_date . "\n";
'
local info_data
info_data=$(printf "%s" "$snapshots_json" | php -r "$php_parser_code_info")
echo "--- Repository Information ---"
(
echo "Statistic,Value"
echo "B2 Bucket,${b2_bucket}"
echo "B2 Path,${b2_path}"
if [ -n "$size_data" ]; then echo "$size_data"; fi
if [ -n "$info_data" ]; then echo "$info_data"; fi
) | "$GUM_CMD" table --print --separator "," --widths=20,40
}
# ----------------------------------------------------
# Prunes the Restic repository to remove unneeded data.
# ----------------------------------------------------
function vault_prune() {
echo "🚀 Preparing to prune the Restic repository..."
echo "This command removes old data that is no longer needed."
echo "It can be a long-running process and will lock the repository."
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
# --- User Confirmation ---
echo "Repository: ${RESTIC_REPOSITORY}"
if ! "$GUM_CMD" confirm "Are you sure you want to prune this repository?"; then
echo "Prune operation cancelled."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
# --- Run Restic Prune with lock detection ---
echo " - Starting prune operation. This may take a while..."
local prune_output
# Capture all output (stdout and stderr) to check for the lock message
prune_output=$("$RESTIC_CMD" prune 2>&1)
local prune_exit_code=$?
# Check if the prune command failed
if [ $prune_exit_code -ne 0 ]; then
# If it failed, check if it was due to a lock
if echo "$prune_output" | grep -q "unable to create lock"; then
echo "⚠️ The repository is locked. A previous operation may have failed or is still running."
echo "$prune_output" # Show the user the detailed lock info from restic
if "$GUM_CMD" confirm "Do you want to attempt to remove the stale lock and retry?"; then
echo " - Attempting to unlock repository..."
if ! "$RESTIC_CMD" unlock; then
echo "❌ Error: Failed to unlock the repository. Please check it manually." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
echo " - Unlock successful. Retrying prune operation..."
if ! "$RESTIC_CMD" prune; then
echo "❌ Error: Restic prune command failed even after unlocking." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
else
echo "Prune operation cancelled due to locked repository."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
else
# The failure was for a reason other than a lock
echo "❌ Error: Restic prune command failed." >&2
echo "$prune_output" >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
fi
# --- Cleanup ---
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
echo "✅ Vault prune complete!"
}
# ----------------------------------------------------
# Deletes a specific snapshot from the Restic repository.
# ----------------------------------------------------
function vault_delete() {
local snapshot_id="$1"
if [ -z "$snapshot_id" ]; then
echo "❌ Error: You must provide a snapshot ID to delete." >&2
show_command_help "vault" >&2
return 1
fi
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
echo "You are about to permanently delete snapshot: ${snapshot_id}"
echo "This action cannot be undone."
if ! "$GUM_CMD" confirm "Are you sure you want to delete this snapshot?"; then
echo "Delete operation cancelled."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
echo " - Deleting snapshot ${snapshot_id}..."
local forget_output
# Capture stdout and stderr to check for errors
forget_output=$("$RESTIC_CMD" forget "$snapshot_id" 2>&1)
local forget_exit_code=$?
# Check if the command failed
if [ $forget_exit_code -ne 0 ]; then
# If it failed, check if it was due to a lock
if echo "$forget_output" | grep -q "unable to create lock"; then
echo "⚠️ The repository is locked. A previous operation may have failed or is still running."
echo "$forget_output"
if "$GUM_CMD" confirm "Do you want to attempt to remove the stale lock and retry?"; then
echo " - Attempting to unlock repository..."
if ! "$RESTIC_CMD" unlock; then
echo "❌ Error: Failed to unlock the repository. Please check it manually." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
echo " - Unlock successful. Retrying delete operation..."
if ! "$RESTIC_CMD" forget "$snapshot_id"; then
echo "❌ Error: Restic forget command failed even after unlocking." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
else
echo "Delete operation cancelled due to locked repository."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
else
# The failure was for a reason other than a lock
echo "❌ Error: Failed to delete snapshot ${snapshot_id}." >&2
echo "$forget_output" >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
else
# Print the success output from the first attempt
echo "$forget_output"
fi
# --- Cleanup ---
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
echo "✅ Snapshot ${snapshot_id} has been forgotten."
echo "💡 Note: This only removes the snapshot reference. To free up storage space by removing the underlying data, run '_do vault prune'."
}
function vault_snapshot_info() {
local snapshot_id="$1"
if [ -z "$snapshot_id" ];
then
echo "❌ Error: You must provide a snapshot ID."
show_command_help "vault" >&2
return 1
fi
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
if ! command -v php &>/dev/null; then
echo "❌ Error: The 'php' command is required for this operation."
>&2
return 1
fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1
fi
# --- Fetch Snapshot Data ---
echo "🔎 Fetching information for snapshot ${snapshot_id}..."
local snapshots_json
snapshots_json=$("$GUM_CMD" spin --spinner dot --title "Fetching repository data..." -- \
"$RESTIC_CMD" snapshots --json)
if [ $? -ne 0 ]; then
echo "❌ Error: Failed to fetch snapshot list from the repository."
>&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
# --- PHP Parser ---
# This script searches the JSON list for the specific snapshot ID and falls back to 'restic stats' if needed.
local php_parser_code='
$target_id = $argv[1] ?? "";
$restic_cmd = $argv[2] ?? "restic";
if (empty($target_id)) { exit(1);
}
$json_data = file_get_contents("php://stdin");
$snapshots = json_decode($json_data, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($snapshots)) {
exit(1);
}
$found_snap = null;
foreach ($snapshots as $snap) {
if ((isset($snap["id"]) && strpos($snap["id"], $target_id) === 0) || (isset($snap["short_id"]) && $snap["short_id"] === $target_id)) {
$found_snap = $snap;
break;
}
}
if ($found_snap === null) {
fwrite(STDERR, "Snapshot with ID starting with \"$target_id\" not found.\n");
exit(1);
}
$snap = $found_snap;
function format_bytes($bytes) {
$bytes = (float)$bytes;
if ($bytes >= 1073741824) { return round($bytes / 1073741824, 2) . " GB"; }
elseif ($bytes >= 1048576) { return round($bytes / 1048576, 2) . " MB"; }
elseif ($bytes >= 1024) { return round($bytes / 1024, 2) . " KB"; }
elseif ($bytes > 0) { return $bytes . " B"; }
else { return "0 B"; }
}
$output = [];
$output["ID"] = $snap["short_id"] ?? "N/A";
$output["Time"] = isset($snap["time"]) ? (new DateTime($snap["time"]))->format("Y-m-d H:i:s T") : "N/A";
$output["Parent"] = $snap["parent"] ?? "None";
$output["Paths"] = isset($snap["paths"]) && is_array($snap["paths"]) ? implode("\n", $snap["paths"]) : "N/A";
$size_formatted = "N/A";
$data_added_formatted = "N/A";
if (isset($snap["summary"]["total_bytes_processed"])) {
$size_formatted = format_bytes($snap["summary"]["total_bytes_processed"]);
if (isset($snap["summary"]["data_added"])) {
$data_added_formatted = format_bytes($snap["summary"]["data_added"]);
}
} else {
fwrite(STDERR, "Snapshot summary not found. Calculating size with '\''restic stats'\''.\n");
$full_snapshot_id = $snap["id"];
$stats_json = shell_exec(escapeshellarg($restic_cmd) . " stats --json " . escapeshellarg($full_snapshot_id));
if ($stats_json) {
$stats_data = json_decode($stats_json, true);
if (json_last_error() === JSON_ERROR_NONE && isset($stats_data["total_size"])) {
$size_formatted = format_bytes($stats_data["total_size"]);
}
}
}
$output["Size (Full)"] = $size_formatted;
$output["Data Added (Unique)"] = $data_added_formatted;
foreach($output as $key => $value) {
// Using addslashes and quoting to handle multi-line paths and other special characters
echo $key . "," . "\"" . addslashes($value) . "\"\n";
}
'
# --- Process and Display ---
local info_data
info_data=$(echo "$snapshots_json" | php -r "$php_parser_code" "$snapshot_id" "$RESTIC_CMD")
# If the PHP script returns no data, the snapshot was not found in the JSON.
if [ -z "$info_data" ]; then
echo "❌ Error: Could not find data for snapshot '${snapshot_id}' in the repository list." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
# Unset variables for security
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
# Display the final table
echo "--- Snapshot Information ---"
(
echo "Property,Value"
# The PHP script now correctly escapes output for the table
echo "$info_data"
) |
"$GUM_CMD" table --print --separator "," --widths=25,0
}