Essa é uma revisão anterior do documento!
PBS Prune Individual
Visão geral
O PBS Prune Individual é um assistente em Bash para configurar retenção de backups por VM/CT no Proxmox Backup Server.
O PBS trabalha com prune por datastore/namespace. Este assistente permite selecionar grupos específicos, definir o keep-last, realizar uma simulação antes da execução e instalar o processo para execução recorrente.
O que o PBS Prune faz
- Coleta e valida os dados de conexão com o PBS.
- Pode criar automaticamente o token de API e aplicar a permissão necessária quando executado no próprio PBS.
- Lista os grupos de backup disponíveis.
- Permite configurar retenção individual para VM/CT.
- Executa dry-run antes do prune real.
- Instala o executor, configuração, cron e logrotate.
- Mantém logs das execuções.
- Utiliza lock para evitar duas execuções simultâneas.
Fluxo de funcionamento
Pré-requisitos
|
v
Conexão com PBS
|
v
VMs / CTs + keep-last
|
v
Dry-run
|
v
Agendamento
|
v
Revisão
|
v
Instalação
|
v
Prune automático
Requisitos
O script realiza uma pré-checagem antes da configuração.
São verificados:
- execução como root;
- proxmox-backup-client;
- openssl;
- timeout;
- flock;
- cron.
Como usar
Executar o assistente
./pbs-prune-setup.sh
O assistente conduz a configuração em etapas:
- conexão com o Proxmox Backup Server;
- seleção das VMs/CTs;
- definição do keep-last;
- dry-run;
- agendamento;
- revisão;
- instalação.
Ver ajuda
./pbs-prune-setup.sh --help
Desinstalar
./pbs-prune-setup.sh --uninstall
A desinstalação remove o executor, configuração, agendamento e logrotate locais.
O token existente no PBS não é removido automaticamente.
Configuração de retenção
O arquivo:
/etc/pbs-prune/groups.conf
possui uma linha para cada grupo configurado.
Exemplo:
vm/100 7 ct/200 14
Neste exemplo:
vm/100
mantém os últimos 7 backups.
ct/200
mantém os últimos 14 backups.
Também é possível informar somente o ID durante o assistente:
100
O script identifica se o grupo é VM ou CT. Caso existam os dois com o mesmo ID, o usuário escolhe qual deseja configurar.
Dry-run
Antes da execução real, o assistente pode executar uma simulação.
O objetivo é permitir a conferência da retenção antes de remover snapshots.
Depois da instalação:
pbs-prune --dry-run
Execução
Para executar o prune configurado:
pbs-prune
Para visualizar os grupos configurados:
pbs-prune --list
Para acompanhar o log:
tail -f /var/log/pbs-prune.log
Agendamento
O assistente permite:
- diário;
- semanal;
- a cada N horas;
- expressão cron personalizada;
- execução somente manual.
A recomendação incorporada ao assistente é executar o prune:
- depois da janela de backup;
- antes do Garbage Collection do datastore.
Arquivos instalados
| Arquivo | Função |
|---|---|
/usr/local/sbin/pbs-prune | Executor |
/etc/pbs-prune/pbs-prune.conf | Configuração de conexão e agendamento |
/etc/pbs-prune/groups.conf | Grupos e retenção |
/etc/pbs-prune/secret | Secret do token |
/etc/cron.d/pbs-prune | Agendamento |
/etc/logrotate.d/pbs-prune | Rotação do log |
/var/log/pbs-prune.log | Log de execução |
Estrutura lógica
pbs-prune-setup.sh
|
+-- coleta e validação
|
+-- conexão PBS
|
+-- configuração dos grupos
|
+-- dry-run
|
+-- agendamento
|
+-- instalação
|
+-- pbs-prune
+-- pbs-prune.conf
+-- groups.conf
+-- secret
+-- cron
+-- logrotate
+-- log
Segurança
O secret do token é armazenado em arquivo separado do código.
O arquivo é criado com permissão restrita.
O executor também utiliza:
flock
para impedir execuções concorrentes;
timeout
para limitar a execução do cliente PBS;
- log das execuções;
- fingerprint SHA-256 do certificado do PBS.
Nunca publique tokens, secrets ou credenciais reais no código-fonte.
Garbage Collection
O prune define quais snapshots deixam de ser mantidos.
O espaço do datastore é efetivamente liberado quando o Garbage Collection do datastore for executado.
Licença
O script informa em seu cabeçalho:
Licença : MIT
Script completo
O conteúdo abaixo é o script completo
pbs-prune-setup.sh
.
#!/usr/bin/env bash # ============================================================================== # pbs-prune-setup — Assistente de prune individual por VM/CT no Proxmox Backup Server # ------------------------------------------------------------------------------ # Autor : Caio Monteiro # Site : www.projetoempresalivre.com.br # Versão : 1.0.0 (25/09/2026) # Licença : MIT # # O PBS só faz prune por datastore/namespace. Este assistente: # 1. coleta e valida os dados de conexão (host, datastore, token, fingerprint) # 2. cria o token de API e a permissão automaticamente (quando roda no próprio PBS) # 3. lista os grupos existentes e deixa você escolher VM/CT + quantos manter (keep-last) # 4. simula (dry-run) antes de aplicar # 5. instala o executor, a config, o agendamento (cron) e o logrotate # # Uso: # pbs-prune-setup.sh assistente interativo # pbs-prune-setup.sh --uninstall remove tudo que foi instalado # pbs-prune-setup.sh --help # # Arquivos instalados: # /usr/local/sbin/pbs-prune executor (roda pelo cron ou manualmente) # /etc/pbs-prune/pbs-prune.conf conexão e agendamento # /etc/pbs-prune/groups.conf lista "grupo keep-last" (ex.: vm/100 7) # /etc/pbs-prune/secret secret do token (600) # /etc/cron.d/pbs-prune agendamento # /etc/logrotate.d/pbs-prune rotação do log # /var/log/pbs-prune.log log de execução # ============================================================================== set -uo pipefail readonly VERSION="1.0.0" readonly APP="pbs-prune" readonly PREFIX="${PBS_PRUNE_PREFIX:-}" # só para testes; em produção fica vazio readonly CONF_DIR="${PREFIX}/etc/${APP}" readonly CONF_FILE="${CONF_DIR}/${APP}.conf" readonly GROUPS_FILE="${CONF_DIR}/groups.conf" readonly SECRET_FILE="${CONF_DIR}/secret" readonly RUNNER="${PREFIX}/usr/local/sbin/${APP}" readonly CRON_FILE="${PREFIX}/etc/cron.d/${APP}" readonly LOGROTATE_FILE="${PREFIX}/etc/logrotate.d/${APP}" readonly LOG_FILE="${PREFIX}/var/log/${APP}.log" readonly LOCK_FILE="${PREFIX}/run/${APP}.lock" # ------------------------------------------------------------------ estado PBS_HOST="" PBS_PORT="8007" DATASTORE="" NAMESPACE="" AUTH_ID="root@pam!prune" SECRET="" FINGERPRINT="" CRON_EXPR="" CRON_DESC="" G_NAMES=() G_KEEP=() AVAILABLE=() IS_LOCAL_PBS=0 # ------------------------------------------------------------------ interface if [[ -t 1 ]]; then C_0=$'\e[0m' C_B=$'\e[1m' C_DIM=$'\e[2m' C_R=$'\e[31m' C_G=$'\e[32m' C_Y=$'\e[33m' C_C=$'\e[36m' else C_0="" C_B="" C_DIM="" C_R="" C_G="" C_Y="" C_C="" fi hr() { printf '%s%s%s\n' "$C_DIM" "────────────────────────────────────────────────────────────" "$C_0"; } info() { printf ' %s➜%s %s\n' "$C_C" "$C_0" "$*"; } ok() { printf ' %s✔%s %s\n' "$C_G" "$C_0" "$*"; } warn() { printf ' %s⚠%s %s\n' "$C_Y" "$C_0" "$*" >&2; } fail() { printf ' %s✖%s %s\n' "$C_R" "$C_0" "$*" >&2; } die() { fail "$*"; exit 1; } tip() { printf ' %s%s%s\n' "$C_DIM" "$*" "$C_0"; } step() { printf '\n%s%s▌ %s%s\n' "$C_B" "$C_C" "$1" "$C_0"; hr; } banner() { printf '%s%s' "$C_B" "$C_C" cat <<'EOF' ╔════════════════════════════════════════════════════════╗ ║ PBS Prune Individual — Assistente de configuração ║ ║ Retenção por VM/CT no Proxmox Backup Server ║ ╚════════════════════════════════════════════════════════╝ EOF printf '%s %sv%s · Projeto Empresa Livre%s\n' "$C_0" "$C_DIM" "$VERSION" "$C_0" } on_interrupt() { stty echo 2>/dev/null; printf '\n'; warn "Cancelado pelo usuário. Nenhuma alteração pendente foi aplicada."; exit 130; } trap on_interrupt INT # ask VAR "pergunta" "padrão" [validador] ask() { local __var=$1 __prompt=$2 __def=${3-} __val=${4:-v_nonempty} __ans while :; do read -rp " ${__prompt}${__def:+ ${C_DIM}[${__def}]${C_0}}: " __ans || die "Entrada encerrada." __ans=${__ans:-$__def} if "$__val" "$__ans"; then printf -v "$__var" '%s' "$__ans"; return 0; fi done } ask_secret() { local __var=$1 __prompt=$2 __ans while :; do read -rsp " ${__prompt}: " __ans || die "Entrada encerrada."; printf '\n' [[ -n $__ans ]] && { printf -v "$__var" '%s' "$__ans"; return 0; } warn "Campo obrigatório." done } # confirm "pergunta" [S|N] -> 0 = sim confirm() { local def=${2:-N} ans hint [[ $def == S ]] && hint="S/n" || hint="s/N" read -rp " $1 ${C_DIM}[${hint}]${C_0}: " ans || die "Entrada encerrada." ans=${ans:-$def} [[ ${ans,,} =~ ^(s|sim|y|yes)$ ]] } # choose VAR "título" "opção 1" "opção 2" ... -> VAR recebe o número escolhido choose() { local __var=$1 __title=$2; shift 2 local __i=1 __opt __ans printf ' %s%s%s\n' "$C_B" "$__title" "$C_0" for __opt in "$@"; do printf ' %s%2d)%s %s\n' "$C_C" "$__i" "$C_0" "$__opt"; __i=$((__i+1)); done while :; do read -rp " Opção: " __ans || die "Entrada encerrada." if [[ $__ans =~ ^[0-9]+$ ]] && (( __ans >= 1 && __ans <= $# )); then printf -v "$__var" '%s' "$__ans"; return 0; fi warn "Escolha um número entre 1 e $#." done } # ------------------------------------------------------------------ validadores v_nonempty() { [[ -n $1 ]] || { warn "Campo obrigatório."; return 1; }; } v_any() { return 0; } v_host() { [[ $1 =~ ^[A-Za-z0-9._-]+$ ]] || { warn "Host/IP inválido (use nome DNS ou IPv4)."; return 1; }; } v_port() { [[ $1 =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 )) || { warn "Porta inválida (1-65535)."; return 1; }; } v_name() { [[ $1 =~ ^[A-Za-z0-9._-]+$ ]] || { warn "Use apenas letras, números, ponto, hífen e sublinhado."; return 1; }; } v_ns() { [[ -z $1 || $1 =~ ^[A-Za-z0-9._/-]+$ ]] || { warn "Namespace inválido (ex.: producao ou clientes/acme)."; return 1; }; } v_posint() { [[ $1 =~ ^[1-9][0-9]*$ ]] || { warn "Informe um inteiro maior ou igual a 1."; return 1; }; } v_time() { [[ $1 =~ ^([01]?[0-9]|2[0-3]):[0-5][0-9]$ ]] || { warn "Horário no formato HH:MM (ex.: 03:30)."; return 1; }; } v_hours() { [[ $1 =~ ^([1-9]|1[0-9]|2[0-3])$ ]] || { warn "Informe de 1 a 23 horas."; return 1; }; } v_authid() { local re='^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+![A-Za-z0-9._-]+$' [[ $1 =~ $re ]] || { warn "Formato: usuario@realm!token (ex.: root@pam!prune)"; return 1; } } v_fp() { local re='^([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$' [[ $1 =~ $re ]] || { warn "Fingerprint SHA-256 inválido (32 pares hex separados por ':')."; return 1; } } v_cron() { local -a f; read -ra f <<<"$1" (( ${#f[@]} == 5 )) && [[ $1 =~ ^[0-9A-Za-z*/,\ -]+$ ]] \ || { warn "Expressão cron precisa de 5 campos (min hora dia mês dia-semana)."; return 1; } } # ------------------------------------------------------------------ helpers PBS repo() { printf '%s@%s:%s:%s' "$AUTH_ID" "$PBS_HOST" "$PBS_PORT" "$DATASTORE"; } ns_args() { [[ -n $NAMESPACE ]] && printf -- '--ns\n%s\n' "$NAMESPACE"; } pbc() { local -a ns; mapfile -t ns < <(ns_args) PBS_REPOSITORY="$(repo)" PBS_PASSWORD="$SECRET" PBS_FINGERPRINT="$FINGERPRINT" \ timeout 60 proxmox-backup-client "$@" "${ns[@]}" </dev/null } fetch_fingerprint() { timeout 8 openssl s_client -connect "${PBS_HOST}:${PBS_PORT}" -servername "$PBS_HOST" </dev/null 2>/dev/null \ | openssl x509 -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 } is_local_host() { [[ $1 == 127.0.0.1 || $1 == localhost || $1 == "$(hostname)" || $1 == "$(hostname -f 2>/dev/null)" ]] && return 0 hostname -I 2>/dev/null | tr ' ' '\n' | grep -qxF "$1" } # ------------------------------------------------------------------ pré-checagem preflight() { step "Verificando pré-requisitos" [[ $EUID -eq 0 ]] || die "Execute como root." local c missing=0 for c in proxmox-backup-client openssl timeout flock; do if command -v "$c" >/dev/null 2>&1; then ok "$c"; else fail "$c não encontrado"; missing=1; fi done (( missing )) && die "Instale os pacotes faltando (proxmox-backup-client vem no PVE/PBS; em Debian puro use o repositório do PBS)." [[ -d ${PREFIX}/etc/cron.d ]] || die "Diretório /etc/cron.d não existe — o cron está instalado?" ok "cron" command -v proxmox-backup-manager >/dev/null 2>&1 && { IS_LOCAL_PBS=1; ok "Rodando no próprio PBS — criação automática de token disponível"; } return 0 } # ------------------------------------------------------------------ 1. conexão step_connection() { step "1/5 · Conexão com o Proxmox Backup Server" local def_host=""; (( IS_LOCAL_PBS )) && def_host="127.0.0.1" ask PBS_HOST "IP ou hostname do PBS" "${PBS_HOST:-$def_host}" v_host ask PBS_PORT "Porta da API" "$PBS_PORT" v_port if (( IS_LOCAL_PBS )) && is_local_host "$PBS_HOST"; then info "Datastores disponíveis:" proxmox-backup-manager datastore list 2>/dev/null | sed 's/^/ /' fi ask DATASTORE "Nome do datastore" "$DATASTORE" v_name ask NAMESPACE "Namespace (Enter = raiz)" "$NAMESPACE" v_ns tip "Recomendado: token dedicado, ex.: root@pam!prune" ask AUTH_ID "Auth-ID do token (usuario@realm!token)" "$AUTH_ID" v_authid setup_token setup_fingerprint test_connection || step_connection } setup_token() { local user=${AUTH_ID%%!*} tok=${AUTH_ID#*!} opt out acl_path if (( IS_LOCAL_PBS )) && is_local_host "$PBS_HOST" && confirm "Criar/ajustar o token e a permissão automaticamente?" S; then if proxmox-backup-manager user list-tokens "$user" --output-format json 2>/dev/null | grep -qF "\"${AUTH_ID}\""; then warn "O token ${AUTH_ID} já existe." choose opt "O que deseja fazer?" "Usar o token existente (vou digitar o secret)" "Regenerar o token (o secret atual deixa de funcionar)" if (( opt == 2 )); then proxmox-backup-manager user delete-token "$user" "$tok" >/dev/null 2>&1 || die "Falha ao remover o token antigo." else ask_secret SECRET "Secret do token" fi fi if [[ -z $SECRET ]]; then out=$(proxmox-backup-manager user generate-token "$user" "$tok" --output-format json 2>&1) \ || die "Falha ao gerar o token: $out" SECRET=$(sed -n 's/.*"value"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$out") [[ -n $SECRET ]] || die "Não consegui ler o secret gerado: $out" ok "Token ${AUTH_ID} criado" fi acl_path="/datastore/${DATASTORE}${NAMESPACE:+/$NAMESPACE}" if proxmox-backup-manager acl update "$acl_path" DatastoreAdmin --auth-id "$AUTH_ID" >/dev/null 2>&1; then ok "Permissão DatastoreAdmin em ${acl_path}" else warn "Não consegui aplicar a ACL. Aplique manualmente:" tip "proxmox-backup-manager acl update ${acl_path} DatastoreAdmin --auth-id '${AUTH_ID}'" fi else if [[ -n $SECRET ]] && confirm "Manter o secret já configurado?" S; then return 0; fi tip "O token precisa da role DatastoreAdmin em /datastore/${DATASTORE} (prune de grupos de outros donos)." ask_secret SECRET "Secret do token" fi } setup_fingerprint() { local fp info "Obtendo o fingerprint do certificado de ${PBS_HOST}:${PBS_PORT}..." fp=$(fetch_fingerprint || true) if [[ -n $fp ]]; then printf ' %s%s%s\n' "$C_B" "$fp" "$C_0" tip "Confira em: PBS → Painel → Mostrar Fingerprint (ou: proxmox-backup-manager cert info)" if confirm "O fingerprint confere?" S; then FINGERPRINT=$fp; return 0; fi else warn "Não foi possível ler o certificado automaticamente." fi ask FINGERPRINT "Cole o fingerprint SHA-256" "$FINGERPRINT" v_fp } test_connection() { local out info "Testando conexão em $(repo)${NAMESPACE:+ (ns: $NAMESPACE)}..." if out=$(pbc list --output-format text 2>&1); then mapfile -t AVAILABLE < <(grep -oE '\b(vm|ct|host)/[A-Za-z0-9._-]+' <<<"$out" | sort -u -V) ok "Conectado — ${#AVAILABLE[@]} grupo(s) de backup encontrados" return 0 fi fail "Falha na conexão:" sed 's/^/ /' <<<"$out" | tail -n 5 >&2 confirm "Revisar os dados de conexão?" S && return 1 confirm "Continuar mesmo assim?" N || die "Abortado." AVAILABLE=() return 0 } # ------------------------------------------------------------------ 2. grupos print_groups() { local i if (( ${#G_NAMES[@]} == 0 )); then tip "(nenhum grupo configurado)"; return; fi printf ' %s%-4s %-18s %s%s\n' "$C_B" "#" "GRUPO" "MANTER (keep-last)" "$C_0" for i in "${!G_NAMES[@]}"; do printf ' %-4s %-18s %s\n' "$((i+1))" "${G_NAMES[$i]}" "${G_KEEP[$i]}" done } group_index() { local i; for i in "${!G_NAMES[@]}"; do [[ ${G_NAMES[$i]} == "$1" ]] && { echo "$i"; return 0; }; done; return 1; } in_available() { printf '%s\n' "${AVAILABLE[@]}" | grep -qxF "$1"; } resolve_group() { # entrada: "100", "vm/100", "ct/200" -> saída: grupo local input=$1 opt if [[ $input =~ ^(vm|ct|host)/[A-Za-z0-9._-]+$ ]]; then echo "$input"; return 0; fi [[ $input =~ ^[0-9]+$ ]] || { warn "Informe o ID (ex.: 100) ou o grupo (ex.: ct/200)."; return 1; } local has_vm=0 has_ct=0 in_available "vm/$input" && has_vm=1 in_available "ct/$input" && has_ct=1 if (( has_vm && has_ct )); then choose opt "Existem vm/$input e ct/$input. Qual?" "vm/$input (QEMU)" "ct/$input (LXC)" >&2 (( opt == 1 )) && echo "vm/$input" || echo "ct/$input" elif (( has_vm )); then echo "vm/$input" elif (( has_ct )); then echo "ct/$input" else warn "ID $input não encontrado no datastore${NAMESPACE:+ / ns $NAMESPACE}." choose opt "Adicionar mesmo assim como:" "vm/$input (QEMU)" "ct/$input (LXC)" "Cancelar" >&2 case $opt in 1) echo "vm/$input";; 2) echo "ct/$input";; *) return 1;; esac fi } step_groups() { step "2/5 · VMs/CTs e retenção" if (( ${#AVAILABLE[@]} )); then info "Grupos encontrados no datastore:" if command -v column >/dev/null 2>&1; then printf '%s\n' "${AVAILABLE[@]}" | column -c 70 | sed 's/^/ /' else printf ' %s\n' "${AVAILABLE[@]}" fi fi tip "Digite o ID (100) ou o grupo (ct/200). Enter vazio finaliza." local input group keep idx opt n while :; do printf '\n' read -rp " ID da VM/CT: " input || die "Entrada encerrada." input=${input// /} if [[ -z $input ]]; then if (( ${#G_NAMES[@]} == 0 )); then warn "Adicione pelo menos um grupo."; continue; fi printf '\n'; print_groups; printf '\n' choose opt "Lista concluída?" "Sim, continuar" "Adicionar mais" "Remover um item" "Limpar a lista" case $opt in 1) break ;; 2) continue ;; 3) ask n "Número do item a remover" "" v_posint if (( n <= ${#G_NAMES[@]} )); then ok "Removido ${G_NAMES[$((n-1))]}" unset 'G_NAMES[n-1]' 'G_KEEP[n-1]'; G_NAMES=("${G_NAMES[@]}"); G_KEEP=("${G_KEEP[@]}") else warn "Item inexistente."; fi ;; 4) G_NAMES=(); G_KEEP=(); ok "Lista limpa." ;; esac continue fi group=$(resolve_group "$input") || continue if idx=$(group_index "$group"); then warn "$group já está na lista (keep-last ${G_KEEP[$idx]})." confirm "Alterar a retenção?" S || continue ask keep "Quantos backups manter de $group" "${G_KEEP[$idx]}" v_posint G_KEEP[$idx]=$keep else ask keep "Quantos backups manter de $group" "7" v_posint G_NAMES+=("$group"); G_KEEP+=("$keep") fi ok "$group → manter os últimos $keep" done } # ------------------------------------------------------------------ 3. simulação step_dryrun() { step "3/5 · Simulação (dry-run)" confirm "Simular agora o que seria mantido/removido?" S || { tip "Simulação pulada."; return 0; } local i out kept removed printf ' %s%-18s %-10s %-9s %s%s\n' "$C_B" "GRUPO" "KEEP-LAST" "MANTÉM" "REMOVE" "$C_0" for i in "${!G_NAMES[@]}"; do if out=$(pbc prune "${G_NAMES[$i]}" --keep-last "${G_KEEP[$i]}" --dry-run --output-format json 2>&1); then kept=$(grep -oE '"keep":[[:space:]]*true' <<<"$out" | wc -l) removed=$(grep -oE '"keep":[[:space:]]*false' <<<"$out" | wc -l) printf ' %-18s %-10s %-9s %s%s%s\n' "${G_NAMES[$i]}" "${G_KEEP[$i]}" "$kept" \ "$( (( removed > 0 )) && echo "$C_Y" )" "$removed" "$C_0" else printf ' %-18s %-10s %s%s%s\n' "${G_NAMES[$i]}" "${G_KEEP[$i]}" "$C_R" "erro: $(tail -n1 <<<"$out")" "$C_0" fi done printf '\n' confirm "Resultado OK? (N volta para editar a lista)" S || { step_groups; step_dryrun; } } # ------------------------------------------------------------------ 4. agendamento step_schedule() { step "4/5 · Agendamento" tip "Agende DEPOIS da janela de backup e ANTES do Garbage Collection do datastore." local opt t h m d dname local -a days=("Domingo" "Segunda" "Terça" "Quarta" "Quinta" "Sexta" "Sábado") choose opt "Frequência:" "Diário" "Semanal" "A cada N horas" "Expressão cron personalizada" "Não agendar (só executar manualmente)" case $opt in 1) ask t "Horário (HH:MM)" "03:30" v_time; h=$((10#${t%%:*})); m=$((10#${t##*:})) CRON_EXPR="$m $h * * *"; CRON_DESC="Diário às $(printf '%02d:%02d' "$h" "$m")" ;; 2) choose d "Dia da semana:" "${days[@]}"; d=$((d-1)); dname=${days[$d]} ask t "Horário (HH:MM)" "03:30" v_time; h=$((10#${t%%:*})); m=$((10#${t##*:})) CRON_EXPR="$m $h * * $d"; CRON_DESC="Toda ${dname} às $(printf '%02d:%02d' "$h" "$m")" ;; 3) ask h "Intervalo em horas" "6" v_hours CRON_EXPR="15 */$h * * *"; CRON_DESC="A cada $h hora(s), no minuto 15" ;; 4) ask CRON_EXPR "Expressão (min hora dia mês dia-semana)" "${CRON_EXPR:-30 3 * * *}" v_cron CRON_DESC="Personalizado" ;; 5) CRON_EXPR=""; CRON_DESC="Sem agendamento" ;; esac ok "${CRON_DESC}${CRON_EXPR:+ (${CRON_EXPR})}" } # ------------------------------------------------------------------ 5. instalação write_runner() { mkdir -p "$(dirname "$RUNNER")" cat >"$RUNNER" <<'RUNNER_EOF' #!/usr/bin/env bash # pbs-prune — executor de prune individual por VM/CT (gerado pelo pbs-prune-setup) # Uso: pbs-prune [--dry-run] [--list] set -uo pipefail CONF="${PBS_PRUNE_CONF:-@CONF_FILE@}" [[ -r $CONF ]] || { echo "Configuração não encontrada: $CONF" >&2; exit 2; } # shellcheck source=/dev/null source "$CONF" DRY=() case "${1:-}" in -n|--dry-run) DRY=(--dry-run) ;; -l|--list) grep -vE '^\s*(#|$)' "$GROUPS_FILE"; exit 0 ;; -h|--help) echo "Uso: $(basename "$0") [--dry-run] [--list]"; exit 0 ;; "") ;; *) echo "Opção inválida: $1" >&2; exit 2 ;; esac exec 9>"$LOCK_FILE" flock -n 9 || { echo "Já existe uma execução em andamento." >&2; exit 3; } log() { local line; line="$(date '+%F %T') $*" printf '%s\n' "$line" >>"$LOG_FILE" [[ -t 1 ]] && printf '%s\n' "$line" logger -t pbs-prune -- "$*" 2>/dev/null return 0 } export PBS_REPOSITORY="${AUTH_ID}@${PBS_HOST}:${PBS_PORT}:${DATASTORE}" export PBS_FINGERPRINT="$FINGERPRINT" PBS_PASSWORD="$(<"$SECRET_FILE")" || { log "ERRO: não foi possível ler $SECRET_FILE"; exit 2; } export PBS_PASSWORD NS=(); [[ -n ${NAMESPACE:-} ]] && NS=(--ns "$NAMESPACE") total=0 errors=0 removed_total=0 start=$SECONDS log "===== início${DRY:+ (dry-run)} · ${PBS_REPOSITORY}${NAMESPACE:+ · ns=$NAMESPACE} =====" while read -r group keep _; do [[ -z ${group:-} || $group == \#* ]] && continue total=$((total + 1)) if out=$(timeout 600 proxmox-backup-client prune "$group" "${NS[@]}" --keep-last "$keep" \ "${DRY[@]}" --output-format json </dev/null 2>&1); then removed=$(grep -oE '"keep":[[:space:]]*false' <<<"$out" | wc -l) removed_total=$((removed_total + removed)) log "OK ${group} keep-last=${keep} removidos=${removed}" else errors=$((errors + 1)) log "ERRO ${group} keep-last=${keep}" while IFS= read -r l; do log " $l"; done <<<"$out" fi done <"$GROUPS_FILE" log "===== fim · grupos=${total} erros=${errors} snapshots removidos=${removed_total} · $((SECONDS - start))s =====" (( errors == 0 )) RUNNER_EOF sed -i "s|@CONF_FILE@|${CONF_FILE}|" "$RUNNER" chmod 750 "$RUNNER" } write_config() { install -d -m 700 "$CONF_DIR" umask 077 { echo "# Gerado por pbs-prune-setup v${VERSION} em $(date '+%F %T'). Rode o setup novamente para alterar." printf 'PBS_HOST=%q\nPBS_PORT=%q\nDATASTORE=%q\nNAMESPACE=%q\nAUTH_ID=%q\nFINGERPRINT=%q\n' \ "$PBS_HOST" "$PBS_PORT" "$DATASTORE" "$NAMESPACE" "$AUTH_ID" "$FINGERPRINT" printf 'CRON_EXPR=%q\nCRON_DESC=%q\n' "$CRON_EXPR" "$CRON_DESC" printf 'SECRET_FILE=%q\nGROUPS_FILE=%q\nLOG_FILE=%q\nLOCK_FILE=%q\n' \ "$SECRET_FILE" "$GROUPS_FILE" "$LOG_FILE" "$LOCK_FILE" } >"$CONF_FILE" printf '%s' "$SECRET" >"$SECRET_FILE" chmod 600 "$CONF_FILE" "$SECRET_FILE" umask 022 } write_groups() { local i install -d -m 700 "$CONF_DIR" { echo "# grupo keep-last (vm/ID = QEMU · ct/ID = LXC)" echo "# Pode editar à mão; uma linha por grupo." for i in "${!G_NAMES[@]}"; do printf '%-12s %s\n' "${G_NAMES[$i]}" "${G_KEEP[$i]}"; done } >"$GROUPS_FILE" chmod 600 "$GROUPS_FILE" } write_cron() { if [[ -z $CRON_EXPR ]]; then rm -f "$CRON_FILE"; return 0; fi mkdir -p "$(dirname "$CRON_FILE")" "$(dirname "$LOG_FILE")" "$(dirname "$LOCK_FILE")" cat >"$CRON_FILE" <<EOF # pbs-prune — ${CRON_DESC} (gerado por pbs-prune-setup) SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ${CRON_EXPR} root ${RUNNER} >/dev/null 2>&1 EOF chmod 644 "$CRON_FILE" } write_logrotate() { mkdir -p "$(dirname "$LOGROTATE_FILE")" cat >"$LOGROTATE_FILE" <<EOF ${LOG_FILE} { monthly rotate 12 compress missingok notifempty } EOF } step_review_install() { step "5/5 · Revisão e instalação" printf ' %-14s %s\n' "Repositório:" "$(repo)" "Namespace:" "${NAMESPACE:-(raiz)}" \ "Fingerprint:" "${FINGERPRINT:0:23}…" "Agendamento:" "${CRON_DESC}${CRON_EXPR:+ (${CRON_EXPR})}" printf '\n'; print_groups; printf '\n' confirm "Instalar com essas configurações?" S || die "Instalação cancelada. Nada foi alterado." write_config; ok "Configuração ${CONF_FILE}" write_groups; ok "Lista de grupos ${GROUPS_FILE}" ok "Secret ${SECRET_FILE} (600)" write_runner; ok "Executor ${RUNNER}" write_cron; [[ -n $CRON_EXPR ]] && ok "Agendamento ${CRON_FILE}" || tip "Sem agendamento instalado." write_logrotate; ok "Logrotate ${LOGROTATE_FILE}" } finish() { printf '\n' if confirm "Executar o prune agora (valendo)?" N; then "$RUNNER" && ok "Execução concluída." || warn "Execução terminou com erros — veja o log." fi step "Pronto" printf ' %-34s %s\n' \ "pbs-prune --dry-run" "simular" \ "pbs-prune" "executar agora" \ "pbs-prune --list" "ver grupos configurados" \ "tail -f ${LOG_FILE}" "acompanhar o log" \ "$(basename "$0")" "editar lista / agendamento / desinstalar" tip "Lembrete: o espaço só é liberado quando o Garbage Collection do datastore rodar." printf '\n' } # ------------------------------------------------------------------ carregar / desinstalar load_existing() { # shellcheck source=/dev/null source "$CONF_FILE" [[ -r $SECRET_FILE ]] && SECRET=$(<"$SECRET_FILE") G_NAMES=() G_KEEP=() local g k while read -r g k _; do [[ -z ${g:-} || $g == \#* ]] && continue G_NAMES+=("$g"); G_KEEP+=("$k") done <"$GROUPS_FILE" } uninstall() { step "Desinstalação" [[ $EUID -eq 0 ]] || die "Execute como root." confirm "Remover executor, configuração, agendamento e logrotate?" N || die "Cancelado." rm -f "$RUNNER" "$CRON_FILE" "$LOGROTATE_FILE" && ok "Executor, cron e logrotate removidos" rm -rf "$CONF_DIR" && ok "Configuração e secret removidos" tip "O log ${LOG_FILE} foi mantido. O token no PBS não foi removido:" tip "proxmox-backup-manager user delete-token <usuario@realm> <token>" } existing_menu() { local opt load_existing step "Instalação existente encontrada" printf ' %-14s %s\n' "Repositório:" "$(repo)" "Agendamento:" "${CRON_DESC:-—}${CRON_EXPR:+ (${CRON_EXPR})}" printf '\n'; print_groups; printf '\n' choose opt "O que deseja fazer?" \ "Editar a lista de VMs/CTs" "Alterar o agendamento" "Reconfigurar tudo" \ "Simular agora (dry-run)" "Desinstalar" "Sair" case $opt in 1) test_connection || true; step_groups; step_dryrun; write_groups; ok "Lista atualizada: ${GROUPS_FILE}" ;; 2) step_schedule; write_config; write_cron; ok "Agendamento atualizado" ;; 3) full_flow; return ;; 4) "$RUNNER" --dry-run ;; 5) uninstall; exit 0 ;; 6) exit 0 ;; esac finish } full_flow() { step_connection step_groups step_dryrun step_schedule step_review_install finish } # ------------------------------------------------------------------ main case "${1:-}" in -h|--help) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; -v|--version) echo "pbs-prune-setup $VERSION"; exit 0 ;; --uninstall) banner; uninstall; exit 0 ;; "") ;; *) die "Opção inválida: $1 (use --help)" ;; esac banner preflight if [[ -f $CONF_FILE && -f $GROUPS_FILE ]]; then existing_menu; else full_flow; fi
Referência
Download
O arquivo do script pode ser disponibilizado separadamente na própria página da Wiki usando a função de mídia do DokuWiki.
Para uma página de documentação, recomenda-se manter:
- esta página para documentação;
- o arquivo
pbs-prune-setup.sh
como download;
- o código completo acima como referência.
Comandos rápidos
# Assistente ./pbs-prune-setup.sh # Ajuda ./pbs-prune-setup.sh --help # Dry-run pbs-prune --dry-run # Executar pbs-prune # Listar grupos pbs-prune --list # Log tail -f /var/log/pbs-prune.log # Desinstalar ./pbs-prune-setup.sh --uninstall
