Improve CLI safety and maintenance commands

This commit is contained in:
Rogério Lima 2026-06-22 18:57:30 -03:00
parent c66a85b192
commit ab37755e77
9 changed files with 301 additions and 76 deletions

48
Cargo.lock generated
View File

@ -1004,27 +1004,6 @@ dependencies = [
"ctutils", "ctutils",
] ]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@ -1761,15 +1740,6 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@ -1913,12 +1883,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "outref" name = "outref"
version = "0.5.2" version = "0.5.2"
@ -2096,17 +2060,6 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@ -2177,7 +2130,6 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"crossterm", "crossterm",
"dirs",
"dotenvy", "dotenvy",
"flate2", "flate2",
"futures-util", "futures-util",

View File

@ -13,7 +13,6 @@ chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
crossterm = "0.28" crossterm = "0.28"
dotenvy = "0.15" dotenvy = "0.15"
dirs = "6"
flate2 = "1" flate2 = "1"
futures-util = "0.3" futures-util = "0.3"
ratatui = "0.29" ratatui = "0.29"

View File

@ -24,19 +24,17 @@ target/release/rsdb
## Configuração ## Configuração
Na primeira execução, se nenhuma configuração completa existir, o programa pergunta os campos um a um, no estilo do `rclone`, e salva em: Na primeira execução, se nenhuma configuração completa existir, o programa pergunta os campos um a um, no estilo do `rclone`, e salva em `config.yml` na mesma pasta do executável.
```bash
~/.config/rsdb/config.yml
```
Senhas são perguntadas sem eco no terminal. O programa também continua aceitando `.env` e `config.toml` na raiz do projeto. Senhas são perguntadas sem eco no terminal. O programa também continua aceitando `.env` e `config.toml` na raiz do projeto.
Também é possível distribuir esse `config.yml` junto do binário. Ele é usado como fallback para completar valores ausentes.
A ordem de precedência é: A ordem de precedência é:
1. Variáveis de ambiente / `.env` 1. Variáveis de ambiente / `.env`
2. `config.toml` local 2. `config.toml` local
3. `~/.config/rsdb/config.yml` 3. `config.yml` na mesma pasta do executável
Exemplo `.env`: Exemplo `.env`:
@ -53,8 +51,7 @@ cp config.example.toml config.toml
Exemplo `config.yml`: Exemplo `config.yml`:
```bash ```bash
mkdir -p ~/.config/rsdb cp config.example.yml "$(dirname "$(realpath target/release/rsdb)")/config.yml"
cp config.example.yml ~/.config/rsdb/config.yml
``` ```
Variáveis obrigatórias: Variáveis obrigatórias:
@ -97,10 +94,36 @@ Restaurar diretamente um dump local ou remoto, ainda com confirmação:
rsdb restore backup.sql.gz rsdb restore backup.sql.gz
``` ```
Simular uma restauração sem baixar, apagar ou restaurar:
```bash
rsdb restore backup.sql.gz --dry-run
```
Validar configuração, pasta local, conexão S3 e ferramentas PostgreSQL: Validar configuração, pasta local, conexão S3 e ferramentas PostgreSQL:
```bash ```bash
rsdb config-check rsdb doctor
```
`rsdb config-check` continua disponível como comando compatível.
Mostrar a configuração carregada, com senhas mascaradas:
```bash
rsdb config show
```
Apagar dumps locais, após confirmação:
```bash
rsdb clean
```
Apagar apenas dumps locais mais antigos que 30 dias:
```bash
rsdb clean --older-than-days 30
``` ```
## Controles da TUI ## Controles da TUI
@ -130,4 +153,10 @@ Logs técnicos são gravados em:
rsdb.log rsdb.log
``` ```
O histórico de restaurações é salvo em:
```bash
<DUMPS_DIR>/history.yml
```
Erros exibidos na TUI ou CLI são resumidos para o usuário; detalhes técnicos ficam no log. Erros exibidos na TUI ou CLI são resumidos para o usuário; detalhes técnicos ficam no log.

View File

@ -43,16 +43,22 @@ struct FileConfig {
impl Config { impl Config {
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
let _ = dotenv(); let _ = dotenv();
let yaml_path = default_yaml_path()?; let executable_yaml_path = executable_yaml_path()?;
let yaml = load_yaml(&yaml_path).unwrap_or_default(); let yaml = load_yaml(&executable_yaml_path).unwrap_or_default();
let toml = load_toml().unwrap_or_default(); let toml = load_toml().unwrap_or_default();
let mut file = merge_file_config(yaml, toml); let mut file = merge_file_config(yaml, toml);
if !yaml_path.exists() && !PathBuf::from("config.toml").exists() && !has_required_env() { if !executable_yaml_path.exists()
&& !PathBuf::from("config.toml").exists()
&& !has_required_env()
{
println!("Configuração inicial do rsdb"); println!("Configuração inicial do rsdb");
println!("As respostas serão salvas em {}", yaml_path.display()); println!(
"As respostas serão salvas em {}",
executable_yaml_path.display()
);
file = prompt_config()?; file = prompt_config()?;
save_yaml(&yaml_path, &file)?; save_yaml(&executable_yaml_path, &file)?;
println!("Configuração salva."); println!("Configuração salva.");
} }
@ -98,6 +104,40 @@ impl Config {
} }
Ok(()) Ok(())
} }
pub fn to_masked_yaml(&self) -> Result<String> {
#[derive(Serialize)]
struct MaskedConfig<'a> {
minio_endpoint: &'a str,
minio_access_key: &'a str,
minio_secret_key: &'a str,
minio_bucket: &'a str,
minio_region: &'a str,
postgres_host: &'a str,
postgres_port: u16,
postgres_user: &'a str,
postgres_password: &'a str,
postgres_database: &'a str,
postgres_sslmode: &'a str,
dumps_dir: &'a PathBuf,
}
let masked = MaskedConfig {
minio_endpoint: &self.minio_endpoint,
minio_access_key: &self.minio_access_key,
minio_secret_key: "***",
minio_bucket: &self.minio_bucket,
minio_region: &self.minio_region,
postgres_host: &self.postgres_host,
postgres_port: self.postgres_port,
postgres_user: &self.postgres_user,
postgres_password: "***",
postgres_database: &self.postgres_database,
postgres_sslmode: &self.postgres_sslmode,
dumps_dir: &self.dumps_dir,
};
serde_yaml::to_string(&masked).context("falha ao gerar YAML da configuração")
}
} }
fn merge_file_config(base: FileConfig, override_with: FileConfig) -> FileConfig { fn merge_file_config(base: FileConfig, override_with: FileConfig) -> FileConfig {
@ -202,10 +242,13 @@ fn prompt_secret(name: &str) -> Result<String> {
} }
} }
fn default_yaml_path() -> Result<PathBuf> { fn executable_yaml_path() -> Result<PathBuf> {
let base = let executable =
dirs::config_dir().context("não foi possível descobrir o diretório de configuração")?; env::current_exe().context("não foi possível descobrir o caminho do executável")?;
Ok(base.join("rsdb").join("config.yml")) let parent = executable
.parent()
.context("não foi possível descobrir o diretório do executável")?;
Ok(parent.join("config.yml"))
} }
fn load_yaml(path: &PathBuf) -> Result<FileConfig> { fn load_yaml(path: &PathBuf) -> Result<FileConfig> {

View File

@ -36,6 +36,18 @@ pub struct DumpEntry {
pub kind: DumpKind, pub kind: DumpKind,
} }
impl DumpEntry {
pub fn local_remote_size_mismatch(&self) -> bool {
matches!(self.kind, DumpKind::RemoteAndLocal)
&& self
.local_path
.as_ref()
.and_then(|path| path.metadata().ok())
.map(|metadata| Some(metadata.len()) != self.size)
.unwrap_or(false)
}
}
pub const SUPPORTED_EXTENSIONS: &[&str] = &[ pub const SUPPORTED_EXTENSIONS: &[&str] = &[
".sql", ".sql",
".sql.gz", ".sql.gz",
@ -185,3 +197,25 @@ pub fn delete_local_dump(dump: &DumpEntry) -> Result<PathBuf> {
fs::remove_file(path).with_context(|| format!("falha ao apagar {}", path.display()))?; fs::remove_file(path).with_context(|| format!("falha ao apagar {}", path.display()))?;
Ok(path.clone()) Ok(path.clone())
} }
pub fn clean_candidates(dir: &Path, older_than_days: Option<u64>) -> Result<Vec<PathBuf>> {
let cutoff = older_than_days.map(|days| {
std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(days * 24 * 60 * 60))
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
let mut candidates = Vec::new();
for dump in list_local_dumps(dir)? {
let Some(path) = dump.local_path else {
continue;
};
if let Some(cutoff) = cutoff {
let modified = path.metadata()?.modified()?;
if modified > cutoff {
continue;
}
}
candidates.push(path);
}
Ok(candidates)
}

48
src/history.rs Normal file
View File

@ -0,0 +1,48 @@
use std::path::Path;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::config::Config;
#[derive(Debug, Deserialize, Serialize)]
struct HistoryEntry {
timestamp: DateTime<Utc>,
dump: String,
host: String,
database: String,
status: String,
error: Option<String>,
}
pub async fn record_restore(
config: &Config,
dump_path: &Path,
error: Option<&anyhow::Error>,
) -> Result<()> {
let history_path = config.dumps_dir.join("history.yml");
let mut entries = read_history(&history_path)?;
entries.push(HistoryEntry {
timestamp: Utc::now(),
dump: dump_path.display().to_string(),
host: config.postgres_host.clone(),
database: config.postgres_database.clone(),
status: if error.is_some() { "error" } else { "success" }.to_string(),
error: error.map(|err| err.to_string()),
});
let raw = serde_yaml::to_string(&entries).context("falha ao gerar histórico YAML")?;
tokio::fs::write(&history_path, raw)
.await
.with_context(|| format!("falha ao salvar {}", history_path.display()))?;
Ok(())
}
fn read_history(path: &Path) -> Result<Vec<HistoryEntry>> {
if !path.exists() {
return Ok(Vec::new());
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("falha ao ler {}", path.display()))?;
serde_yaml::from_str(&raw).with_context(|| format!("{} inválido", path.display()))
}

View File

@ -1,6 +1,7 @@
mod config; mod config;
mod dumps; mod dumps;
mod errors; mod errors;
mod history;
mod minio; mod minio;
mod postgres_restore; mod postgres_restore;
mod tui; mod tui;
@ -25,8 +26,28 @@ struct Cli {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum Commands { enum Commands {
List, List,
Restore { arquivo: String }, Restore {
arquivo: String,
#[arg(long)]
dry_run: bool,
},
Doctor,
ConfigCheck, ConfigCheck,
Clean {
#[arg(long)]
older_than_days: Option<u64>,
#[arg(short, long)]
yes: bool,
},
Config {
#[command(subcommand)]
command: ConfigCommands,
},
}
#[derive(Subcommand, Debug)]
enum ConfigCommands {
Show,
} }
#[tokio::main] #[tokio::main]
@ -40,8 +61,19 @@ async fn main() -> Result<()> {
let result = match cli.command { let result = match cli.command {
None => tui::run(config, minio).await, None => tui::run(config, minio).await,
Some(Commands::List) => command_list(&config, &minio).await, Some(Commands::List) => command_list(&config, &minio).await,
Some(Commands::Restore { arquivo }) => command_restore(&config, &minio, &arquivo).await, Some(Commands::Restore { arquivo, dry_run }) => {
Some(Commands::ConfigCheck) => command_config_check(&config, &minio).await, command_restore(&config, &minio, &arquivo, dry_run).await
}
Some(Commands::Doctor) | Some(Commands::ConfigCheck) => {
command_doctor(&config, &minio).await
}
Some(Commands::Clean {
older_than_days,
yes,
}) => command_clean(&config, older_than_days, yes).await,
Some(Commands::Config { command }) => match command {
ConfigCommands::Show => command_config_show(&config),
},
}; };
if let Err(err) = &result { if let Err(err) = &result {
@ -87,10 +119,41 @@ async fn command_list(config: &Config, minio: &MinioClient) -> Result<()> {
Ok(()) Ok(())
} }
async fn command_restore(config: &Config, minio: &MinioClient, name: &str) -> Result<()> { async fn command_restore(
config: &Config,
minio: &MinioClient,
name: &str,
dry_run: bool,
) -> Result<()> {
let dumps = collect_dumps(config, minio).await?; let dumps = collect_dumps(config, minio).await?;
let dump = let dump =
find_dump_by_name(&dumps, name).with_context(|| format!("dump não encontrado: {name}"))?; find_dump_by_name(&dumps, name).with_context(|| format!("dump não encontrado: {name}"))?;
if dry_run {
println!("Dry-run: nenhuma alteração será executada.");
println!("Dump: {}", dump.name);
println!("Origem: {}", dump.kind.label());
println!(
"Arquivo local: {}",
dump.local_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| config.dumps_dir.join(&dump.name).display().to_string())
);
println!(
"Destino: {}:{} / {}",
config.postgres_host, config.postgres_port, config.postgres_database
);
if dump.local_remote_size_mismatch() {
println!("Aviso: o tamanho local difere do tamanho remoto.");
}
println!("Ações: baixar se necessário, encerrar conexões, DROP DATABASE, CREATE DATABASE, restaurar.");
return Ok(());
}
if dump.local_remote_size_mismatch() {
println!("Aviso: existe cópia local com tamanho diferente do remoto.");
println!("A cópia local será usada. Apague o arquivo local para baixar novamente.");
}
let local_path = dumps::materialize_dump(config, minio, &dump, false).await?; let local_path = dumps::materialize_dump(config, minio, &dump, false).await?;
if !confirm_in_terminal(config, &local_path)? { if !confirm_in_terminal(config, &local_path)? {
@ -98,20 +161,25 @@ async fn command_restore(config: &Config, minio: &MinioClient, name: &str) -> Re
return Ok(()); return Ok(());
} }
restore_dump(config, &local_path, |message| { let result = restore_dump(config, &local_path, |message| {
println!("{message}"); println!("{message}");
}) })
.await?; .await;
if let Err(err) = history::record_restore(config, &local_path, result.as_ref().err()).await {
error!("{err:?}");
}
result?;
info!("restauração concluída: {}", local_path.display()); info!("restauração concluída: {}", local_path.display());
println!("Restauração concluída."); println!("Restauração concluída.");
Ok(()) Ok(())
} }
async fn command_config_check(config: &Config, minio: &MinioClient) -> Result<()> { async fn command_doctor(config: &Config, minio: &MinioClient) -> Result<()> {
config.validate()?; config.validate()?;
ensure_dumps_dir(&config.dumps_dir)?; ensure_dumps_dir(&config.dumps_dir)?;
minio.check().await?; minio.check().await?;
postgres_restore::check_tools().await?; postgres_restore::check_tools().await?;
postgres_restore::check_admin_connection(config).await?;
println!("Configuração válida."); println!("Configuração válida.");
println!("Bucket: {}", config.minio_bucket); println!("Bucket: {}", config.minio_bucket);
println!( println!(
@ -121,3 +189,43 @@ async fn command_config_check(config: &Config, minio: &MinioClient) -> Result<()
println!("Dumps locais: {}", config.dumps_dir.display()); println!("Dumps locais: {}", config.dumps_dir.display());
Ok(()) Ok(())
} }
async fn command_clean(config: &Config, older_than_days: Option<u64>, yes: bool) -> Result<()> {
let candidates = dumps::clean_candidates(&config.dumps_dir, older_than_days)?;
if candidates.is_empty() {
println!("Nenhum dump local para apagar.");
return Ok(());
}
println!("Arquivos locais que serão apagados:");
for dump in &candidates {
println!("- {}", dump.display());
}
if !yes && !confirm_text("Digite exatamente APAGAR para continuar: ", "APAGAR")? {
println!("Operação cancelada.");
return Ok(());
}
for dump in candidates {
std::fs::remove_file(&dump)
.with_context(|| format!("falha ao apagar {}", dump.display()))?;
println!("Apagado: {}", dump.display());
}
Ok(())
}
fn command_config_show(config: &Config) -> Result<()> {
println!("{}", config.to_masked_yaml()?);
Ok(())
}
fn confirm_text(prompt: &str, expected: &str) -> Result<bool> {
use std::io::Write;
print!("{prompt}");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(input.trim() == expected)
}

View File

@ -21,6 +21,10 @@ pub async fn check_tools() -> Result<()> {
Ok(()) Ok(())
} }
pub async fn check_admin_connection(config: &Config) -> Result<()> {
run_psql_admin(config, &["-v", "ON_ERROR_STOP=1", "-q", "-c", "SELECT 1;"]).await
}
async fn check_tool(name: &str) -> Result<()> { async fn check_tool(name: &str) -> Result<()> {
let output = Command::new(name) let output = Command::new(name)
.arg("--version") .arg("--version")

View File

@ -16,6 +16,7 @@ use crate::{
config::Config, config::Config,
dumps::{self, DumpEntry, DumpKind}, dumps::{self, DumpEntry, DumpKind},
errors::RestoreAssistantError, errors::RestoreAssistantError,
history,
minio::MinioClient, minio::MinioClient,
postgres_restore, postgres_restore,
}; };
@ -187,8 +188,11 @@ impl App {
return; return;
}; };
if matches!(dump.kind, DumpKind::RemoteAndLocal) { if matches!(dump.kind, DumpKind::RemoteAndLocal) {
self.status = self.status = if dump.local_remote_size_mismatch() {
"Usando cópia local existente. Remova o arquivo para baixar novamente.".to_string(); "Aviso: cópia local tem tamanho diferente do remoto; ela será usada.".to_string()
} else {
"Usando cópia local existente. Remova o arquivo para baixar novamente.".to_string()
};
} }
match dumps::materialize_dump(&self.config, &self.minio, &dump, false).await { match dumps::materialize_dump(&self.config, &self.minio, &dump, false).await {
Ok(path) => { Ok(path) => {
@ -234,6 +238,10 @@ impl App {
info!("{message}"); info!("{message}");
}) })
.await; .await;
if let Err(err) = history::record_restore(&self.config, &path, result.as_ref().err()).await
{
error!("{err:?}");
}
match result { match result {
Ok(()) => { Ok(()) => {
self.status = "Restauração concluída.".to_string(); self.status = "Restauração concluída.".to_string();