| Ambos lados da revisão anteriorRevisão anteriorPróxima revisão | Revisão anterior |
| proxmox:prune-individual [2026/09/27 18:10] – admin | proxmox:prune-individual [2026/09/29 13:22] (atual) – admin |
|---|
| <!doctype html> | ====== PBS — Prune Individual por VM/CT ====== |
| <html lang="pt-BR"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>PBS Prune Individual · Estrutura, Uso e Script</title> | |
| <meta name="description" content="Documentação do PBS Prune Individual para Proxmox Backup Server: estrutura, funcionamento, uso e script completo em DokuWiki."> | |
| <style> | |
| :root{--bg:#07090d;--panel:#0d121a;--panel2:#111923;--line:#202b39;--text:#e9eef5;--muted:#9ba8b8;--accent:#f2810f;--accent2:#2fd8bd;--danger:#ff6b6b;--code:#080b10} | |
| *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:radial-gradient(circle at 15% 0%,#1a1209 0,transparent 28%),radial-gradient(circle at 90% 10%,#09201d 0,transparent 25%),var(--bg);color:var(--text);font:15px/1.65 Inter,system-ui,-apple-system,Segoe UI,sans-serif} | |
| a{color:inherit}.wrap{max-width:1120px;margin:auto;padding:0 22px} | |
| nav{position:sticky;top:0;z-index:10;background:rgba(7,9,13,.86);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)} | |
| .nav{height:62px;display:flex;align-items:center;justify-content:space-between;gap:18px}.brand{font-weight:800;letter-spacing:.02em}.brand b{color:var(--accent)}.links{display:flex;gap:18px;color:var(--muted);font-size:13px}.links a{text-decoration:none}.links a:hover{color:#fff} | |
| .hero{padding:78px 0 52px}.eyebrow{color:var(--accent);font-weight:800;text-transform:uppercase;letter-spacing:.14em;font-size:12px}h1{font-size:clamp(34px,6vw,64px);line-height:1.02;margin:12px 0 18px;max-width:850px}.lead{font-size:19px;color:var(--muted);max-width:780px} | |
| .badges{display:flex;gap:9px;flex-wrap:wrap;margin-top:25px}.badge{border:1px solid var(--line);background:rgba(255,255,255,.025);padding:7px 11px;border-radius:999px;color:#cbd5e1;font-size:12px}.badge.hot{border-color:#6d3d13;color:#ffb66d} | |
| section{padding:38px 0}h2{font-size:27px;margin:0 0 10px}h3{margin:0 0 8px}.sub{color:var(--muted);max-width:760px} | |
| .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:22px}.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:22px} | |
| .card{background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.018));border:1px solid var(--line);border-radius:14px;padding:20px}.card .num{font:700 12px ui-monospace,SFMono-Regular,monospace;color:var(--accent);margin-bottom:12px}.card p{color:var(--muted);margin:7px 0 0} | |
| .flow{display:flex;gap:0;align-items:stretch;margin-top:24px;overflow:auto;padding-bottom:4px}.step{min-width:155px;padding:16px;background:var(--panel);border:1px solid var(--line)}.step:first-child{border-radius:12px 0 0 12px}.step:last-child{border-radius:0 12px 12px 0}.arrow{display:flex;align-items:center;padding:0 8px;color:var(--accent);background:var(--panel)} | |
| pre{margin:18px 0 0;background:var(--code);border:1px solid var(--line);border-radius:12px;padding:18px;overflow:auto;color:#d7e0ea;font:12.5px/1.6 "JetBrains Mono",ui-monospace,SFMono-Regular,monospace;max-height:620px}.terminal{border-left:3px solid var(--accent)} | |
| table{width:100%;border-collapse:collapse;margin-top:18px;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}th,td{padding:12px 14px;text-align:left;border-bottom:1px solid var(--line)}th{color:#fff;font-size:12px;text-transform:uppercase;letter-spacing:.06em}td{color:var(--muted);font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px} | |
| .callout{margin-top:22px;padding:18px 20px;border:1px solid #6d3d13;background:rgba(242,129,15,.06);border-radius:12px}.callout strong{color:#ffb66d} | |
| .actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}.btn{display:inline-block;text-decoration:none;padding:11px 15px;border-radius:9px;border:1px solid var(--line);background:var(--panel2);font-weight:700;font-size:13px}.btn.primary{background:var(--accent);border-color:var(--accent);color:#090b0f}.btn:hover{transform:translateY(-1px)} | |
| .small{font-size:12px;color:var(--muted)}footer{border-top:1px solid var(--line);padding:32px 0 50px;color:var(--muted);font-size:12px} | |
| @media(max-width:800px){.links{display:none}.grid,.grid2{grid-template-columns:1fr}.hero{padding-top:52px}h1{font-size:42px}.flow{display:grid;gap:8px}.step,.step:first-child,.step:last-child{border-radius:10px}.arrow{display:none}} | |
| </style> | |
| </head> | |
| <body> | |
| <nav><div class="wrap nav"><div class="brand">PBS <b>PRUNE</b> INDIVIDUAL</div><div class="links"> | |
| <a href="#estrutura">Estrutura</a><a href="#uso">Como usar</a><a href="#arquivos">Arquivos</a><a href="#script">Script</a><a href="#wiki">DokuWiki</a> | |
| </div></div></nav> | |
| |
| <header class="wrap hero"> | Script para configurar políticas de retenção individuais por VM ou CT no Proxmox Backup Server. |
| <div class="eyebrow">Proxmox Backup Server · documentação técnica</div> | |
| <h1>Prune por VM/CT, sem transformar uma tarefa simples em uma operação manual.</h1> | |
| <p class="lead">O PBS Prune Individual é um assistente em Bash que organiza conexão, retenção, simulação, agendamento e execução de prune por grupo de backup.</p> | |
| <div class="badges"><span class="badge hot">v1.0.0 · 25/09/2026</span><span class="badge">Bash</span><span class="badge">PBS</span><span class="badge">MIT</span></div> | |
| </header> | |
| |
| <main class="wrap"> | Exemplo: |
| <section> | |
| <h2>O que ele resolve</h2><p class="sub">A ferramenta parte de uma necessidade operacional: definir retenção diferente para VMs/CTs específicos e transformar essa configuração em uma execução repetível.</p> | |
| <div class="grid"> | |
| <div class="card"><div class="num">01 · CONEXÃO</div><h3>Valida o ambiente</h3><p>Host, porta, datastore, namespace, token e fingerprint.</p></div> | |
| <div class="card"><div class="num">02 · RETENÇÃO</div><h3>Escolhe o grupo</h3><p>VM/ID ou CT/ID com seu próprio keep-last.</p></div> | |
| <div class="card"><div class="num">03 · SEGURANÇA</div><h3>Simula antes</h3><p>Dry-run permite revisar o que será mantido ou removido.</p></div> | |
| </div> | |
| </section> | |
| |
| <section id="estrutura"> | <code> |
| <h2>Estrutura do projeto</h2><p class="sub">O setup é um assistente. Depois da configuração, ele deixa um executor separado para as execuções recorrentes.</p> | vm/100 7 |
| <div class="flow"> | vm/101 15 |
| <div class="step"><b>Setup</b><br><span class="small">entrada e validação</span></div><div class="arrow">→</div> | ct/200 30 |
| <div class="step"><b>Config</b><br><span class="small">PBS + agendamento</span></div><div class="arrow">→</div> | </code> |
| <div class="step"><b>Groups</b><br><span class="small">VM/CT + keep-last</span></div><div class="arrow">→</div> | |
| <div class="step"><b>Runner</b><br><span class="small">execução real</span></div><div class="arrow">→</div> | |
| <div class="step"><b>Log</b><br><span class="small">histórico</span></div> | |
| </div> | |
| <div class="grid2"> | |
| <div class="card"><h3>Setup interativo</h3><p>Executa o fluxo de pré-requisitos, conexão, grupos, dry-run, agendamento e instalação.</p></div> | |
| <div class="card"><h3>Executor</h3><p>O <code>pbs-prune</code> lê a configuração e processa os grupos configurados, com lock, timeout e log.</p></div> | |
| </div> | |
| </section> | |
| |
| <section id="uso"> | Cada grupo pode possuir seu próprio ''keep-last''. |
| <h2>Como usar</h2> | |
| <p class="sub">O ponto de entrada é simples. O assistente conduz a configuração e pode ser executado novamente para alterar a instalação existente.</p> | |
| <pre class="terminal"># executar o assistente | |
| ./pbs-prune-setup.sh | |
| |
| # ajuda | ===== Pré-requisitos ===== |
| ./pbs-prune-setup.sh --help | |
| |
| # remover a instalação local | Antes de executar: |
| ./pbs-prune-setup.sh --uninstall</pre> | |
| <div class="callout"><strong>Importante:</strong> o espaço no datastore só é efetivamente liberado quando o Garbage Collection do datastore for executado.</div> | |
| </section> | |
| |
| <section id="arquivos"> | * Proxmox Backup Server acessível; |
| <h2>O que fica instalado</h2> | * ''proxmox-backup-client'' instalado; |
| <p class="sub">A instalação separa código, configuração, segredo, política de retenção, agendamento e logs.</p> | * acesso ''root'' ao servidor onde o script será instalado; |
| <table> | * um usuário existente no PBS; |
| <thead><tr><th>Arquivo</th><th>Função</th></tr></thead> | * API Token associado a esse usuário; |
| <tbody> | * permissão no datastore que será utilizado. |
| <tr><td>/usr/local/sbin/pbs-prune</td><td>Executor</td></tr> | |
| <tr><td>/etc/pbs-prune/pbs-prune.conf</td><td>Conexão e agendamento</td></tr> | |
| <tr><td>/etc/pbs-prune/groups.conf</td><td>Grupos + keep-last</td></tr> | |
| <tr><td>/etc/pbs-prune/secret</td><td>Secret do token</td></tr> | |
| <tr><td>/etc/cron.d/pbs-prune</td><td>Agendamento</td></tr> | |
| <tr><td>/etc/logrotate.d/pbs-prune</td><td>Rotação do log</td></tr> | |
| <tr><td>/var/log/pbs-prune.log</td><td>Log das execuções</td></tr> | |
| </tbody></table> | |
| </section> | |
| |
| <section> | O API Token sempre pertence a um usuário existente. |
| <h2>Exemplo de política</h2> | |
| <p class="sub">O arquivo de grupos permite definir retenção individual por grupo.</p> | |
| <pre>vm/100 7 | |
| ct/200 14</pre> | |
| <p class="small">Neste exemplo, a VM 100 mantém os últimos 7 backups e o CT 200 mantém os últimos 14.</p> | |
| </section> | |
| |
| <section> | Exemplos: |
| <h2>Comandos do executor</h2> | |
| <div class="grid"> | |
| <div class="card"><h3><code>pbs-prune --dry-run</code></h3><p>Simula a operação sem aplicar o prune.</p></div> | |
| <div class="card"><h3><code>pbs-prune</code></h3><p>Executa o prune configurado.</p></div> | |
| <div class="card"><h3><code>pbs-prune --list</code></h3><p>Mostra os grupos configurados.</p></div> | |
| </div> | |
| </section> | |
| |
| <section> | <code> |
| <h2>Cuidados de segurança</h2> | root@pam!prune |
| <div class="grid2"> | backup@pbs!prune |
| <div class="card"><h3>Secret separado</h3><p>O token fica em arquivo próprio com permissão 600. Não coloque credenciais reais no código.</p></div> | </code> |
| <div class="card"><h3>Execução controlada</h3><p>O runner usa <code>flock</code> para evitar concorrência e <code>timeout</code> para limitar a chamada ao cliente PBS.</p></div> | |
| </div> | |
| </section> | |
| |
| <section id="script"> | A permissão deve ser aplicada no datastore utilizado: |
| <h2>Script completo</h2> | |
| <p class="sub">Abaixo está a versão disponibilizada na documentação. O conteúdo é o arquivo Bash integral utilizado como base desta página.</p> | |
| <pre>#!/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 | <code bash> |
| | proxmox-backup-manager acl update /datastore/backup DatastoreAdmin --auth-id 'backup@pbs!prune' |
| | </code> |
| |
| readonly VERSION="1.0.0" | Substitua ''backup'' pelo nome do seu datastore e utilize o usuário/token criado no seu ambiente. |
| 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 | ===== Download ===== |
| 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 | Baixe o script diretamente: |
| 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"; } | [[https://cursoproxmox.com.br/prune/pbs-prune-setup.sh|pbs-prune-setup.sh]] |
| 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() { | ===== Instalação ===== |
| printf '%s%s' "$C_B" "$C_C" | |
| cat <<'EOF' | |
| |
| ╔════════════════════════════════════════════════════════╗ | No PBS: |
| ║ 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; } | <code bash> |
| trap on_interrupt INT | chmod +x /root/pbs-prune-setup.sh |
| | /root/pbs-prune-setup.sh |
| | </code> |
| |
| # ask VAR "pergunta" "padrão" [validador] | O assistente solicitará: |
| 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() { | * endereço do PBS; |
| local __var=$1 __prompt=$2 __ans | * datastore; |
| while :; do | * namespace, se utilizado; |
| read -rsp " ${__prompt}: " __ans || die "Entrada encerrada."; printf '\n' | * API Token; |
| [[ -n $__ans ]] && { printf -v "$__var" '%s' "$__ans"; return 0; } | * VMs/CTs; |
| warn "Campo obrigatório." | * quantidade de backups a manter; |
| done | * agendamento. |
| } | |
| |
| # confirm "pergunta" [S|N] -> 0 = sim | Antes da instalação é possível realizar um ''dry-run''. |
| 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 | ===== Estrutura criada ===== |
| 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 | Após a instalação: |
| 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 | <code> |
| repo() { printf '%s@%s:%s:%s' "$AUTH_ID" "$PBS_HOST" "$PBS_PORT" "$DATASTORE"; } | /usr/local/sbin/ |
| ns_args() { [[ -n $NAMESPACE ]] && printf -- '--ns\n%s\n' "$NAMESPACE"; } | └── pbs-prune |
| |
| pbc() { | /etc/pbs-prune/ |
| local -a ns; mapfile -t ns < <(ns_args) | ├── pbs-prune.conf |
| PBS_REPOSITORY="$(repo)" PBS_PASSWORD="$SECRET" PBS_FINGERPRINT="$FINGERPRINT" \ | ├── groups.conf |
| timeout 60 proxmox-backup-client "$@" "${ns[@]}" </dev/null | └── secret |
| } | |
| |
| fetch_fingerprint() { | /etc/cron.d/ |
| timeout 8 openssl s_client -connect "${PBS_HOST}:${PBS_PORT}" -servername "$PBS_HOST" </dev/null 2>/dev/null \ | └── pbs-prune |
| | openssl x509 -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 | |
| } | |
| |
| is_local_host() { | /etc/logrotate.d/ |
| [[ $1 == 127.0.0.1 || $1 == localhost || $1 == "$(hostname)" || $1 == "$(hostname -f 2>/dev/null)" ]] && return 0 | └── pbs-prune |
| hostname -I 2>/dev/null | tr ' ' '\n' | grep -qxF "$1" | |
| } | |
| |
| # ------------------------------------------------------------------ pré-checagem | /var/log/ |
| preflight() { | └── pbs-prune.log |
| step "Verificando pré-requisitos" | </code> |
| [[ $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 | Arquivos principais: |
| 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 | ^ Arquivo ^ Função ^ |
| info "Datastores disponíveis:" | | ''/usr/local/sbin/pbs-prune'' | Executor do prune | |
| proxmox-backup-manager datastore list 2>/dev/null | sed 's/^/ /' | | ''/etc/pbs-prune/pbs-prune.conf'' | Configuração | |
| fi | | ''/etc/pbs-prune/groups.conf'' | Retenção por VM/CT | |
| ask DATASTORE "Nome do datastore" "$DATASTORE" v_name | | ''/etc/pbs-prune/secret'' | Secret do API Token | |
| ask NAMESPACE "Namespace (Enter = raiz)" "$NAMESPACE" v_ns | | ''/etc/cron.d/pbs-prune'' | Agendamento | |
| | | ''/var/log/pbs-prune.log'' | Log das execuções | |
| |
| tip "Recomendado: token dedicado, ex.: root@pam!prune" | ===== Configuração das VMs/CTs ===== |
| ask AUTH_ID "Auth-ID do token (usuario@realm!token)" "$AUTH_ID" v_authid | |
| |
| setup_token | Arquivo: |
| setup_fingerprint | |
| test_connection || step_connection | |
| } | |
| |
| setup_token() { | <code> |
| local user=${AUTH_ID%%!*} tok=${AUTH_ID#*!} opt out acl_path | /etc/pbs-prune/groups.conf |
| | </code> |
| |
| if (( IS_LOCAL_PBS )) && is_local_host "$PBS_HOST" && confirm "Criar/ajustar o token e a permissão automaticamente?" S; then | Exemplo: |
| 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() { | <code> |
| local fp | vm/100 7 |
| info "Obtendo o fingerprint do certificado de ${PBS_HOST}:${PBS_PORT}..." | vm/101 15 |
| fp=$(fetch_fingerprint || true) | ct/200 30 |
| if [[ -n $fp ]]; then | </code> |
| 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() { | Formato: |
| 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 | <code> |
| print_groups() { | GRUPO KEEP-LAST |
| local i | </code> |
| 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; } | ===== Comandos ===== |
| in_available() { printf '%s\n' "${AVAILABLE[@]}" | grep -qxF "$1"; } | |
| |
| resolve_group() { # entrada: "100", "vm/100", "ct/200" -> saída: grupo | Simular: |
| 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() { | <code bash> |
| step "2/5 · VMs/CTs e retenção" | pbs-prune --dry-run |
| if (( ${#AVAILABLE[@]} )); then | </code> |
| 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 | Executar: |
| 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 | <code bash> |
| if idx=$(group_index "$group"); then | pbs-prune |
| warn "$group já está na lista (keep-last ${G_KEEP[$idx]})." | </code> |
| 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 | Listar grupos: |
| 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 | <code bash> |
| step_schedule() { | pbs-prune --list |
| step "4/5 · Agendamento" | </code> |
| 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 | Acompanhar log: |
| 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@}" | <code bash> |
| [[ -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</pre> | |
| </section> | |
| | |
| <section id="wiki"> | |
| <h2>Versão para DokuWiki</h2> | |
| <p class="sub">A página também pode ser publicada diretamente no DokuWiki. O arquivo abaixo contém a documentação e o script em bloco Bash.</p> | |
| <div class="actions"> | |
| <a class="btn primary" href="pbs-prune-individual.txt" download>↓ Baixar página DokuWiki</a> | |
| <a class="btn" href="https://wiki.projetoempresalivre.com.br/doku.php?id=proxmox:prune-individual" target="_blank" rel="noopener">Abrir na Wiki</a> | |
| </div> | |
| <pre>====== 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 projeto cria uma camada de automação para selecionar grupos específicos, definir o **keep-last**, simular a operação e instalar a execução recorrente. | |
| | |
| ===== O que ele 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 da operação real. | |
| * Instala executor, configuração, cron e logrotate. | |
| * Mantém logs das execuções. | |
| * Usa lock para evitar duas execuções simultâneas. | |
| | |
| ===== Uso ===== | |
| | |
| <code bash> | |
| ./pbs-prune-setup.sh | |
| ./pbs-prune-setup.sh --help | |
| ./pbs-prune-setup.sh --uninstall | |
| </code> | |
| | |
| ===== Fluxo ===== | |
| | |
| <code> | |
| Pré-requisitos | |
| ↓ | |
| Conexão com PBS | |
| ↓ | |
| VMs / CTs + keep-last | |
| ↓ | |
| Dry-run | |
| ↓ | |
| Agendamento | |
| ↓ | |
| Revisão | |
| ↓ | |
| Instalação | |
| ↓ | |
| Prune automático | |
| </code> | |
| | |
| ===== Arquivos instalados ===== | |
| | |
| ^ Arquivo ^ Função ^ | |
| | <code>/usr/local/sbin/pbs-prune</code> | Executor | | |
| | <code>/etc/pbs-prune/pbs-prune.conf</code> | Configuração | | |
| | <code>/etc/pbs-prune/groups.conf</code> | Grupos e retenção | | |
| | <code>/etc/pbs-prune/secret</code> | Secret do token | | |
| | <code>/etc/cron.d/pbs-prune</code> | Agendamento | | |
| | <code>/etc/logrotate.d/pbs-prune</code> | Rotação dos logs | | |
| | <code>/var/log/pbs-prune.log</code> | Log de execução | | |
| | |
| ===== Configuração de grupos ===== | |
| | |
| O arquivo <code>groups.conf</code> usa uma linha por grupo: | |
| | |
| <code> | |
| vm/100 7 | |
| ct/200 14 | |
| </code> | |
| | |
| Isso significa manter os últimos 7 backups da VM 100 e os últimos 14 backups do CT 200. | |
| | |
| ===== Comandos após a instalação ===== | |
| | |
| <code bash> | |
| pbs-prune --dry-run | |
| pbs-prune | |
| pbs-prune --list | |
| tail -f /var/log/pbs-prune.log | tail -f /var/log/pbs-prune.log |
| </code> | </code> |
| | |
| O espaço no datastore é efetivamente liberado quando o **Garbage Collection** do datastore for executado. | |
| | |
| ===== Segurança ===== | |
| | |
| O secret é armazenado separadamente do código e com permissão restrita. O executor usa <code>flock</code> para impedir execuções concorrentes e <code>timeout</code> para limitar chamadas ao cliente do PBS. | |
| | |
| **Nunca publique tokens ou secrets reais no código-fonte.** | |
| | |
| ===== Script completo ===== | |
| | |
| <code bash> | |
| #!/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 | ===== Garbage Collection ===== |
| 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() { | O prune remove as referências dos snapshots. |
| 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() { | O espaço físico no datastore será efetivamente recuperado após a execução do **Garbage Collection (GC)**. |
| 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() { | Por isso, recomenda-se executar na ordem: |
| step_connection | |
| step_groups | |
| step_dryrun | |
| step_schedule | |
| step_review_install | |
| finish | |
| } | |
| |
| # ------------------------------------------------------------------ main | <code> |
| case "${1:-}" in | Backup → Prune Individual → Garbage Collection |
| -h|--help) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; | </code> |
| -v|--version) echo "pbs-prune-setup $VERSION"; exit 0 ;; | |
| --uninstall) banner; uninstall; exit 0 ;; | |
| "") ;; | |
| *) die "Opção inválida: $1 (use --help)" ;; | |
| esac | |
| |
| banner | ===== Desinstalação ===== |
| preflight | |
| if [[ -f $CONF_FILE && -f $GROUPS_FILE ]]; then existing_menu; else full_flow; fi | |
| </code> | |
| |
| ===== Licença ===== | <code bash> |
| | /root/pbs-prune-setup.sh --uninstall |
| | </code> |
| |
| <code> | A desinstalação remove o executor, configurações, cron e logrotate. |
| MIT | |
| </code> | |
| </pre> | |
| </section> | |
| </main> | |
| |
| <footer><div class="wrap">PBS Prune Individual · Projeto Empresa Livre · v1.0.0 · Licença MIT</div></footer> | O API Token criado no PBS não é removido automaticamente. |
| </body> | |
| </html> | |