Add exclude paths for restore and skip-suspended/schedule enhancements

- Add --exclude flag to restore account/files commands to skip specific
  paths during homedir restoration (rsync --exclude / rclone --exclude)
- Add exclude paths UI in WHM restore form (step 2 tag input + modal,
  step 3 summary, step 4 command building)
- Add rclone_from_remote_filtered() for passing extra args to rclone copy
- Add _build_exclude_args() helper in restore.sh
- Add exclude pattern to Runner.pm allowlist
- Add skip-suspended flag and schedule configuration enhancements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-04 19:10:33 +02:00
parent 0eb480489e
commit bea3ff05cb
6 changed files with 236 additions and 16 deletions

View File

@@ -163,6 +163,43 @@ rclone_from_remote() {
return 1
}
# Like rclone_from_remote but passes extra args (e.g. --exclude) to rclone copy.
# Usage: rclone_from_remote_filtered <remote_subpath> <local_dir> [extra_args...]
rclone_from_remote_filtered() {
local remote_subpath="$1"
local local_dir="$2"
shift 2
local -a extra_args=("$@")
local attempt=0
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
local remote_src; remote_src=$(_rclone_remote_path "$remote_subpath")
mkdir -p "$local_dir" || {
log_error "Failed to create local dir: $local_dir"
return 1
}
while (( attempt < max_retries )); do
((attempt++)) || true
log_debug "rclone copy (filtered) attempt $attempt/$max_retries: $remote_src -> $local_dir"
if _rclone_cmd copy "$remote_src" "$local_dir" "${extra_args[@]}"; then
log_debug "rclone download succeeded on attempt $attempt"
return 0
fi
log_warn "rclone download failed, attempt $attempt/$max_retries"
if (( attempt < max_retries )); then
local backoff=$(( attempt * 10 ))
log_info "Retrying in ${backoff}s..."
sleep "$backoff"
fi
done
log_error "rclone download failed after $max_retries attempts"
return 1
}
# ── Snapshot Management ───────────────────────────────────────
rclone_list_dirs() {

View File

@@ -19,6 +19,21 @@ _rsync_download() {
fi
}
# Helper: build --exclude args from comma-separated exclude string.
# Outputs one arg per line (--exclude then pattern).
_build_exclude_args() {
local exclude="$1"
local -a args=()
if [[ -n "$exclude" ]]; then
IFS=',' read -ra patterns <<< "$exclude"
for p in "${patterns[@]}"; do
p="${p## }"; p="${p%% }"
[[ -n "$p" ]] && args+=(--exclude "$p")
done
fi
printf '%s\n' "${args[@]}"
}
# Helper: detect pkgacct base (old vs new format) for rclone or SSH
_detect_pkgacct_base() {
local snap_path="$1"
@@ -42,9 +57,16 @@ _detect_pkgacct_base() {
restore_full_account() {
local user="$1"
local timestamp="${2:-}"
local force="${3:-false}"
local strategy="${3:-}"
local exclude="${4:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
# Validate strategy
if [[ -n "$strategy" ]] && [[ "$strategy" != "merge" ]] && [[ "$strategy" != "terminate" ]]; then
log_error "Invalid strategy: $strategy (must be 'merge' or 'terminate')"
return 1
fi
# Resolve timestamp
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
@@ -53,11 +75,21 @@ restore_full_account() {
log_info "Restoring full account: $user from snapshot $ts"
# Check if account already exists
if account_exists "$user" && [[ "$force" != "true" ]]; then
log_error "Account $user already exists. Use --force to overwrite."
if account_exists "$user" && [[ -z "$strategy" ]]; then
log_error "Account $user already exists. Use --strategy=merge or --strategy=terminate to proceed."
return 1
fi
# Terminate strategy: remove existing account first
if [[ "$strategy" == "terminate" ]] && account_exists "$user"; then
log_info "Terminating existing account $user (--strategy=terminate)..."
if ! /scripts/removeacct --keepdns "$user"; then
log_error "Failed to terminate account $user"
return 1
fi
log_info "Account $user terminated, proceeding with clean restore"
fi
local restore_dir="$temp_dir/restore-$user"
mkdir -p "$restore_dir" || {
log_error "Failed to create restore temp directory"
@@ -119,7 +151,7 @@ restore_full_account() {
# Run restorepkg
log_info "Running restorepkg for $user..."
if [[ "$force" == "true" ]] && account_exists "$user"; then
if [[ "$strategy" == "merge" ]] && account_exists "$user"; then
if ! /scripts/restorepkg --force "$restore_dir/$user"; then
log_error "restorepkg failed for $user"
rm -rf "$restore_dir"
@@ -133,17 +165,33 @@ restore_full_account() {
fi
fi
# Build exclude args for homedir phase
local -a exclude_args=()
if [[ -n "$exclude" ]]; then
while IFS= read -r arg; do
exclude_args+=("$arg")
done < <(_build_exclude_args "$exclude")
fi
# Restore homedir
local homedir; homedir=$(get_account_homedir "$user")
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
if rclone_exists "${snap_subpath}/homedir/"; then
log_info "Restoring homedir for $user..."
if [[ ${#exclude_args[@]} -gt 0 ]]; then
if ! rclone_from_remote_filtered "${snap_subpath}/homedir" "$homedir" "${exclude_args[@]}"; then
log_error "Failed to restore homedir for $user"
rm -rf "$restore_dir"
return 1
fi
else
if ! rclone_from_remote "${snap_subpath}/homedir" "$homedir"; then
log_error "Failed to restore homedir for $user"
rm -rf "$restore_dir"
return 1
fi
fi
log_info "Fixing ownership for $homedir..."
chown -R "$user":"$user" "$homedir"
fi
@@ -151,6 +199,7 @@ restore_full_account() {
log_info "Restoring homedir for $user..."
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
"${exclude_args[@]}" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${snap_path}/homedir/" \
"$homedir/"; then
@@ -173,6 +222,7 @@ restore_files() {
local user="$1"
local subpath="${2:-}"
local timestamp="${3:-}"
local exclude="${4:-}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
@@ -186,15 +236,30 @@ restore_files() {
log_info "Restoring files for $user: ${subpath:-entire homedir} from snapshot $ts"
# Build exclude args
local -a exclude_args=()
if [[ -n "$exclude" ]]; then
while IFS= read -r arg; do
exclude_args+=("$arg")
done < <(_build_exclude_args "$exclude")
fi
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local remote_sub="${snap_subpath}/homedir"
[[ -n "$subpath" ]] && remote_sub="${snap_subpath}/homedir/${subpath}"
if [[ ${#exclude_args[@]} -gt 0 ]]; then
if ! rclone_from_remote_filtered "$remote_sub" "$local_dest" "${exclude_args[@]}"; then
log_error "Failed to restore files for $user"
return 1
fi
else
if ! rclone_from_remote "$remote_sub" "$local_dest"; then
log_error "Failed to restore files for $user"
return 1
fi
fi
else
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
@@ -204,6 +269,7 @@ restore_files() {
[[ -n "$subpath" ]] && remote_source="${snap_path}/homedir/${subpath}"
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
"${exclude_args[@]}" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${remote_source}" \
"$local_dest"; then
@@ -1348,7 +1414,7 @@ restore_server() {
((total++)) || true
log_info "--- Restoring account $user ($total) ---"
if restore_full_account "$user" "$timestamp" "true"; then
if restore_full_account "$user" "$timestamp" "merge"; then
((succeeded++)) || true
else
((failed++)) || true

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +1,2 @@
<!-- Tailwind/DaisyUI class safelist for gniza WHM plugin -->
<div class="alert alert-error alert-info alert-success alert-warning badge badge-error badge-sm badge-success badge-warning bg-base-100 bg-base-200 bg-neutral bg-primary/10 border border-base-300 border-base-content/5 breadcrumbs btn btn-error btn-ghost btn-info btn-primary btn-secondary btn-sm btn-xs card card-body card-title checkbox checkbox-sm collapse collapse-arrow collapse-content collapse-title cursor-pointer flex flex-1 flex-col flex-wrap font-bold font-medium font-mono font-semibold gap-1 gap-2 gap-3 hidden inline input input-bordered input-sm items-center items-start mx-auto join join-item link list-disc loading loading-spinner loading-xs max-h-48 max-w-2xl max-w-xs mb-1 mb-2.5 mb-3 mb-4 mb-5 mb-6 ml-2 modal modal-action modal-backdrop modal-box mt-2 mt-3 mt-4 mt-5 my-2 my-4 overflow-x-auto overflow-y-auto p-3 p-4 pt-1 pt-2 pl-5 px-4 py-1 py-3 py-4 radio radio-sm rounded-box rounded-lg select select-bordered select-sm shadow-sm steps tab tab-content table hover tabs tabs-box tabs-lg tab-active text-center text-error text-lg textarea textarea-bordered textarea-sm text-base-content/60 text-neutral-content text-sm text-xl text-xs toggle toggle-sm toggle-success w-11/12 w-44 w-full whitespace-pre-wrap font-sans text-[1.6rem] text-warning badge-info badge-neutral btn-active leading-relaxed inline-flex items-stretch w-fit bg-[#fafafa] px-5 max-h-[360px] m-0 no-underline bg-white p-2.5 animate-pulse badge-outline"></div>
<div class="alert alert-error alert-info alert-success alert-warning badge badge-error badge-sm badge-success badge-warning bg-base-100 bg-base-200 bg-neutral bg-primary/10 border border-base-300 border-base-content/5 breadcrumbs btn btn-error btn-ghost btn-info btn-primary btn-secondary btn-sm btn-xs card card-body card-title checkbox checkbox-sm collapse collapse-arrow collapse-content collapse-title cursor-pointer flex flex-1 flex-col flex-wrap font-bold font-medium font-mono font-semibold gap-1 gap-2 gap-3 hidden inline input input-bordered input-sm items-center items-start mx-auto join join-item link list-disc loading loading-spinner loading-xs max-h-48 max-w-2xl max-w-xs mb-1 mb-2.5 mb-3 mb-4 mb-5 mb-6 ml-2 modal modal-action modal-backdrop modal-box mt-2 mt-3 mt-4 mt-5 my-2 my-4 overflow-x-auto overflow-y-auto p-3 p-4 pt-1 pt-2 pl-5 px-4 py-1 py-3 py-4 radio radio-sm rounded-box rounded-lg select select-bordered select-sm shadow-sm steps tab tab-content table hover tabs tabs-box tabs-lg tab-active text-center text-error text-lg textarea textarea-bordered textarea-sm text-base-content/60 text-neutral-content text-sm text-xl text-xs toggle toggle-sm toggle-success w-11/12 w-44 w-full whitespace-pre-wrap font-sans text-[1.6rem] text-warning badge-info badge-neutral btn-active leading-relaxed inline-flex items-stretch w-fit bg-[#fafafa] px-5 max-h-[360px] m-0 no-underline bg-white p-2.5 animate-pulse badge-outline btn-warning btn-circle mt-1 h-40"></div>

View File

@@ -47,6 +47,8 @@ my %OPT_PATTERNS = (
timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/,
path => qr/^[a-zA-Z0-9_.\/@ -]+$/,
account => qr/^[a-z][a-z0-9_-]*$/,
strategy => qr/^(merge|terminate)$/,
exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/,
);
# _validate($cmd, $subcmd, \@args, \%opts)

View File

@@ -247,6 +247,44 @@ sub handle_step2 {
print qq{ </div>\n};
print qq{</div>\n};
# Restore strategy (visible only for Full Account mode)
print qq{<div id="strategy-panel" class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm">Restore Strategy</label>\n};
print qq{ <div class="join inline-flex items-stretch">\n};
print qq{ <input type="radio" name="strategy" class="join-item btn btn-sm m-0" aria-label="Overwrite (merge)" value="merge" checked>\n};
print qq{ <input type="radio" name="strategy" class="join-item btn btn-sm m-0" aria-label="Terminate & re-create" value="terminate">\n};
print qq{ </div>\n};
print qq{</div>\n};
# Exclude paths (visible in both Full Account and Selective modes)
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3 mt-3">\n};
print qq{<div class="card-body py-3 px-4">\n};
print qq{ <h3 class="card-title text-sm">Directories and Files to Exclude</h3>\n};
print qq{ <div class="flex items-center gap-2">\n};
print qq{ <input type="text" class="input input-bordered input-sm flex-1 max-w-xs" id="exclude-input" placeholder="e.g. public_html/cache">\n};
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaAddExclude()">Add Path</button>\n};
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaOpenExcludeModal()">Insert Multiple</button>\n};
print qq{ </div>\n};
print qq{ <p class="text-xs text-base-content/60">Exclude files and directories from restoration</p>\n};
print qq{ <div id="exclude-tags" class="flex flex-wrap gap-1 mt-1"></div>\n};
print qq{ <input type="hidden" id="exclude_paths" name="exclude_paths" value="">\n};
print qq{</div>\n};
print qq{</div>\n};
# Exclude modal
print qq{<dialog id="exclude-modal" class="modal">\n};
print qq{<div class="modal-box">\n};
print qq{ <h3 class="text-lg font-bold">Directories and Files to Exclude</h3>\n};
print qq{ <textarea id="exclude-textarea" class="textarea textarea-bordered w-full h-40 mt-3" placeholder="One path per line"></textarea>\n};
print qq{ <p class="text-xs text-base-content/60 mt-1">* Separated by new line</p>\n};
print qq{ <div class="modal-action">\n};
print qq{ <button type="button" class="btn btn-sm" onclick="document.getElementById('exclude-modal').close()">Cancel</button>\n};
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaExcludeModalOk()">OK</button>\n};
print qq{ </div>\n};
print qq{</div>\n};
print qq{<div class="modal-backdrop" onclick="this.closest('dialog').close()"><button type="button">close</button></div>\n};
print qq{</dialog>\n};
# Hidden field that always carries account type when Full is selected
print qq{<input type="hidden" id="type_account_hidden" name="type_account" value="1">\n};
@@ -414,6 +452,7 @@ function gnizaModeChanged() {
var mode = document.querySelector('input[name="restore_mode"]:checked').value;
var selective = mode === 'selective';
document.getElementById('selective-panel').hidden = !selective;
document.getElementById('strategy-panel').hidden = selective;
document.getElementById('type_account_hidden').disabled = selective;
if (selective) {
gnizaTypesChanged();
@@ -601,6 +640,61 @@ function gnizaPopulatePreview(containerId, options, type) {
}
}
function gnizaAddExclude() {
var input = document.getElementById('exclude-input');
var val = input.value.trim();
if (!val) return;
gnizaAddExcludeTag(val);
input.value = '';
gnizaUpdateExcludeField();
}
function gnizaAddExcludeTag(text) {
var container = document.getElementById('exclude-tags');
// Skip duplicates
var existing = container.querySelectorAll('.badge span');
for (var i = 0; i < existing.length; i++) {
if (existing[i].textContent === text) return;
}
var badge = document.createElement('span');
badge.className = 'badge badge-warning gap-1';
var span = document.createElement('span');
span.textContent = text;
badge.appendChild(span);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-xs btn-ghost btn-circle';
btn.innerHTML = '\\u2715';
btn.onclick = function() { badge.remove(); gnizaUpdateExcludeField(); };
badge.appendChild(btn);
container.appendChild(badge);
}
function gnizaUpdateExcludeField() {
var tags = document.getElementById('exclude-tags').querySelectorAll('.badge span');
var vals = [];
for (var i = 0; i < tags.length; i++) {
vals.push(tags[i].textContent);
}
document.getElementById('exclude_paths').value = vals.join(',');
}
function gnizaOpenExcludeModal() {
document.getElementById('exclude-textarea').value = '';
document.getElementById('exclude-modal').showModal();
}
function gnizaExcludeModalOk() {
var text = document.getElementById('exclude-textarea').value;
var lines = text.split('\\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (line) gnizaAddExcludeTag(line);
}
gnizaUpdateExcludeField();
document.getElementById('exclude-modal').close();
}
gnizaModeChanged();
function gnizaOpenFileBrowser() {
@@ -766,6 +860,8 @@ sub handle_step3 {
my $emails = $form->{'emails'} // '';
my $domain_names = $form->{'domain_names'} // '';
my $ssl_names = $form->{'ssl_names'} // '';
my $strategy = $form->{'strategy'} // '';
my $exclude_paths = $form->{'exclude_paths'} // '';
# Collect selected types from type_* checkboxes
my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl);
@@ -801,6 +897,12 @@ sub handle_step3 {
print qq{<tr><td class="font-medium">Snapshot</td><td>$esc_timestamp</td></tr>\n};
print qq{<tr><td class="font-medium">Restore Types</td><td>$types_display</td></tr>\n};
if (grep { $_ eq 'account' } @selected_types) {
my %strategy_labels = (merge => 'Overwrite (merge)', terminate => 'Terminate & re-create');
my $strategy_display = GnizaWHM::UI::esc($strategy_labels{$strategy} // $strategy);
print qq{<tr><td class="font-medium">Strategy</td><td>$strategy_display</td></tr>\n};
}
# Show sub-field details for applicable types
if (grep { $_ eq 'files' } @selected_types) {
my $path_display = $path ne '' ? GnizaWHM::UI::esc($path) : 'All files';
@@ -827,6 +929,12 @@ sub handle_step3 {
print qq{<tr><td class="font-medium">SSL</td><td>$ssl_display</td></tr>\n};
}
if ($exclude_paths ne '') {
my $exclude_display = GnizaWHM::UI::esc($exclude_paths);
$exclude_display =~ s/,/, /g;
print qq{<tr><td class="font-medium">Exclude</td><td>$exclude_display</td></tr>\n};
}
print qq{</table></div>\n};
print qq{</div>\n</div>\n};
@@ -844,6 +952,8 @@ sub handle_step3 {
print qq{<input type="hidden" name="emails" value="} . GnizaWHM::UI::esc($emails) . qq{">\n};
print qq{<input type="hidden" name="domain_names" value="} . GnizaWHM::UI::esc($domain_names) . qq{">\n};
print qq{<input type="hidden" name="ssl_names" value="} . GnizaWHM::UI::esc($ssl_names) . qq{">\n};
print qq{<input type="hidden" name="strategy" value="} . GnizaWHM::UI::esc($strategy) . qq{">\n};
print qq{<input type="hidden" name="exclude_paths" value="} . GnizaWHM::UI::esc($exclude_paths) . qq{">\n};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<div class="flex items-center gap-2">\n};
@@ -875,6 +985,8 @@ sub handle_step4 {
my $emails = $form->{'emails'} // '';
my $domain_names = $form->{'domain_names'} // '';
my $ssl_names = $form->{'ssl_names'} // '';
my $strategy = $form->{'strategy'} // '';
my $exclude_paths = $form->{'exclude_paths'} // '';
# Collect selected types
my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl);
@@ -895,11 +1007,14 @@ sub handle_step4 {
for my $type (@selected_types) {
my %opts = (remote => $remote);
$opts{timestamp} = $timestamp if $timestamp ne '';
$opts{strategy} = $strategy if $strategy ne '' && $type eq 'account';
if ($SIMPLE_TYPES{$type}) {
$opts{exclude} = $exclude_paths if $exclude_paths ne '' && $type eq 'account';
push @commands, ['restore', $type, [$account], {%opts}];
} elsif ($type eq 'files') {
$opts{path} = $path if $path ne '';
$opts{exclude} = $exclude_paths if $exclude_paths ne '';
push @commands, ['restore', 'files', [$account], {%opts}];
} elsif ($type eq 'database') {
my @dbs;