diff --git a/Cargo.lock b/Cargo.lock index 37e94e1..1ac3ccb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1004,27 +1004,6 @@ dependencies = [ "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]] name = "displaydoc" version = "0.2.6" @@ -1761,15 +1740,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1913,12 +1883,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "outref" version = "0.5.2" @@ -2096,17 +2060,6 @@ dependencies = [ "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]] name = "regex-automata" version = "0.4.14" @@ -2177,7 +2130,6 @@ dependencies = [ "chrono", "clap", "crossterm", - "dirs", "dotenvy", "flate2", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index cb9928c..c40bfdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,6 @@ chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } crossterm = "0.28" dotenvy = "0.15" -dirs = "6" flate2 = "1" futures-util = "0.3" ratatui = "0.29" diff --git a/README.md b/README.md index d4af111..b4e4990 100644 --- a/README.md +++ b/README.md @@ -24,19 +24,17 @@ target/release/rsdb ## 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: - -```bash -~/.config/rsdb/config.yml -``` +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. 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 é: 1. Variáveis de ambiente / `.env` 2. `config.toml` local -3. `~/.config/rsdb/config.yml` +3. `config.yml` na mesma pasta do executável Exemplo `.env`: @@ -53,8 +51,7 @@ cp config.example.toml config.toml Exemplo `config.yml`: ```bash -mkdir -p ~/.config/rsdb -cp config.example.yml ~/.config/rsdb/config.yml +cp config.example.yml "$(dirname "$(realpath target/release/rsdb)")/config.yml" ``` Variáveis obrigatórias: @@ -97,10 +94,36 @@ Restaurar diretamente um dump local ou remoto, ainda com confirmação: 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: ```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 @@ -130,4 +153,10 @@ Logs técnicos são gravados em: rsdb.log ``` +O histórico de restaurações é salvo em: + +```bash +/history.yml +``` + Erros exibidos na TUI ou CLI são resumidos para o usuário; detalhes técnicos ficam no log. diff --git a/src/config.rs b/src/config.rs index be8133e..b0acecd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,16 +43,22 @@ struct FileConfig { impl Config { pub fn load() -> Result { let _ = dotenv(); - let yaml_path = default_yaml_path()?; - let yaml = load_yaml(&yaml_path).unwrap_or_default(); + let executable_yaml_path = executable_yaml_path()?; + let yaml = load_yaml(&executable_yaml_path).unwrap_or_default(); let toml = load_toml().unwrap_or_default(); 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!("As respostas serão salvas em {}", yaml_path.display()); + println!( + "As respostas serão salvas em {}", + executable_yaml_path.display() + ); file = prompt_config()?; - save_yaml(&yaml_path, &file)?; + save_yaml(&executable_yaml_path, &file)?; println!("Configuração salva."); } @@ -98,6 +104,40 @@ impl Config { } Ok(()) } + + pub fn to_masked_yaml(&self) -> Result { + #[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 { @@ -202,10 +242,13 @@ fn prompt_secret(name: &str) -> Result { } } -fn default_yaml_path() -> Result { - let base = - dirs::config_dir().context("não foi possível descobrir o diretório de configuração")?; - Ok(base.join("rsdb").join("config.yml")) +fn executable_yaml_path() -> Result { + let executable = + env::current_exe().context("não foi possível descobrir o caminho do executável")?; + 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 { diff --git a/src/dumps.rs b/src/dumps.rs index 6d2eea7..714861d 100644 --- a/src/dumps.rs +++ b/src/dumps.rs @@ -36,6 +36,18 @@ pub struct DumpEntry { 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] = &[ ".sql", ".sql.gz", @@ -185,3 +197,25 @@ pub fn delete_local_dump(dump: &DumpEntry) -> Result { fs::remove_file(path).with_context(|| format!("falha ao apagar {}", path.display()))?; Ok(path.clone()) } + +pub fn clean_candidates(dir: &Path, older_than_days: Option) -> Result> { + 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) +} diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..8e628a4 --- /dev/null +++ b/src/history.rs @@ -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, + dump: String, + host: String, + database: String, + status: String, + error: Option, +} + +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> { + 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())) +} diff --git a/src/main.rs b/src/main.rs index c580258..245655a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod config; mod dumps; mod errors; +mod history; mod minio; mod postgres_restore; mod tui; @@ -25,8 +26,28 @@ struct Cli { #[derive(Subcommand, Debug)] enum Commands { List, - Restore { arquivo: String }, + Restore { + arquivo: String, + #[arg(long)] + dry_run: bool, + }, + Doctor, ConfigCheck, + Clean { + #[arg(long)] + older_than_days: Option, + #[arg(short, long)] + yes: bool, + }, + Config { + #[command(subcommand)] + command: ConfigCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum ConfigCommands { + Show, } #[tokio::main] @@ -40,8 +61,19 @@ async fn main() -> Result<()> { let result = match cli.command { None => tui::run(config, minio).await, Some(Commands::List) => command_list(&config, &minio).await, - Some(Commands::Restore { arquivo }) => command_restore(&config, &minio, &arquivo).await, - Some(Commands::ConfigCheck) => command_config_check(&config, &minio).await, + Some(Commands::Restore { arquivo, dry_run }) => { + 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 { @@ -87,10 +119,41 @@ async fn command_list(config: &Config, minio: &MinioClient) -> Result<()> { 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 dump = 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?; if !confirm_in_terminal(config, &local_path)? { @@ -98,20 +161,25 @@ async fn command_restore(config: &Config, minio: &MinioClient, name: &str) -> Re return Ok(()); } - restore_dump(config, &local_path, |message| { + let result = restore_dump(config, &local_path, |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()); println!("Restauração concluída."); Ok(()) } -async fn command_config_check(config: &Config, minio: &MinioClient) -> Result<()> { +async fn command_doctor(config: &Config, minio: &MinioClient) -> Result<()> { config.validate()?; ensure_dumps_dir(&config.dumps_dir)?; minio.check().await?; postgres_restore::check_tools().await?; + postgres_restore::check_admin_connection(config).await?; println!("Configuração válida."); println!("Bucket: {}", config.minio_bucket); println!( @@ -121,3 +189,43 @@ async fn command_config_check(config: &Config, minio: &MinioClient) -> Result<() println!("Dumps locais: {}", config.dumps_dir.display()); Ok(()) } + +async fn command_clean(config: &Config, older_than_days: Option, 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 { + 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) +} diff --git a/src/postgres_restore.rs b/src/postgres_restore.rs index 8d6ba5a..62a3724 100644 --- a/src/postgres_restore.rs +++ b/src/postgres_restore.rs @@ -21,6 +21,10 @@ pub async fn check_tools() -> Result<()> { 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<()> { let output = Command::new(name) .arg("--version") diff --git a/src/tui.rs b/src/tui.rs index 39cf49c..11266c1 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -16,6 +16,7 @@ use crate::{ config::Config, dumps::{self, DumpEntry, DumpKind}, errors::RestoreAssistantError, + history, minio::MinioClient, postgres_restore, }; @@ -187,8 +188,11 @@ impl App { return; }; if matches!(dump.kind, DumpKind::RemoteAndLocal) { - self.status = - "Usando cópia local existente. Remova o arquivo para baixar novamente.".to_string(); + self.status = if dump.local_remote_size_mismatch() { + "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 { Ok(path) => { @@ -234,6 +238,10 @@ impl App { info!("{message}"); }) .await; + if let Err(err) = history::record_restore(&self.config, &path, result.as_ref().err()).await + { + error!("{err:?}"); + } match result { Ok(()) => { self.status = "Restauração concluída.".to_string();