Atualiza fluxo de restauracao de dumps

This commit is contained in:
Rogério de Lima Sousa 2026-06-23 11:23:28 -03:00
parent ab37755e77
commit 2f4c25979d
11 changed files with 358 additions and 98 deletions

View File

@ -1,15 +0,0 @@
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=backups
MINIO_REGION=us-east-1
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DATABASE=app_db
POSTGRES_SSLMODE=prefer
DUMPS_DIR=./dumps

56
Cargo.lock generated
View File

@ -2083,6 +2083,34 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "restoredb"
version = "0.1.0"
dependencies = [
"anyhow",
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"bytesize",
"chrono",
"clap",
"crossterm",
"dotenvy",
"flate2",
"futures-util",
"ratatui",
"rpassword",
"serde",
"serde_yaml",
"thiserror",
"tokio",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
"zstd",
]
[[package]] [[package]]
name = "rfc6979" name = "rfc6979"
version = "0.4.0" version = "0.4.0"
@ -2118,34 +2146,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rsdb"
version = "0.1.0"
dependencies = [
"anyhow",
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"bytesize",
"chrono",
"clap",
"crossterm",
"dotenvy",
"flate2",
"futures-util",
"ratatui",
"rpassword",
"serde",
"serde_yaml",
"thiserror",
"tokio",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
"zstd",
]
[[package]] [[package]]
name = "rtoolbox" name = "rtoolbox"
version = "0.0.5" version = "0.0.5"

View File

@ -1,5 +1,5 @@
[package] [package]
name = "rsdb" name = "restoredb"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"

View File

@ -1,4 +1,4 @@
# rsdb # restoredb
Assistente CLI/TUI em Rust para listar dumps em MinIO/S3, baixar arquivos para uma pasta local e restaurar um banco PostgreSQL com `DROP DATABASE` e recriação controlados por confirmação explícita. Assistente CLI/TUI em Rust para listar dumps em MinIO/S3, baixar arquivos para uma pasta local e restaurar um banco PostgreSQL com `DROP DATABASE` e recriação controlados por confirmação explícita.
@ -19,7 +19,7 @@ cargo build --release
O binário ficará em: O binário ficará em:
```bash ```bash
target/release/rsdb target/release/restoredb
``` ```
## Configuração ## Configuração
@ -51,7 +51,7 @@ cp config.example.toml config.toml
Exemplo `config.yml`: Exemplo `config.yml`:
```bash ```bash
cp config.example.yml "$(dirname "$(realpath target/release/rsdb)")/config.yml" cp config.example.yml "$(dirname "$(realpath target/release/restoredb)")/config.yml"
``` ```
Variáveis obrigatórias: Variáveis obrigatórias:
@ -79,51 +79,51 @@ Senhas nunca são exibidas. A senha do PostgreSQL é passada para `psql` e `pg_r
Abrir a TUI: Abrir a TUI:
```bash ```bash
rsdb restoredb
``` ```
Listar dumps remotos e locais: Listar dumps remotos e locais:
```bash ```bash
rsdb list restoredb list
``` ```
Restaurar diretamente um dump local ou remoto, ainda com confirmação: Restaurar diretamente um dump local ou remoto, ainda com confirmação:
```bash ```bash
rsdb restore backup.sql.gz restoredb restore backup.sql.gz
``` ```
Simular uma restauração sem baixar, apagar ou restaurar: Simular uma restauração sem baixar, apagar ou restaurar:
```bash ```bash
rsdb restore backup.sql.gz --dry-run restoredb 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 doctor restoredb doctor
``` ```
`rsdb config-check` continua disponível como comando compatível. `restoredb config-check` continua disponível como comando compatível.
Mostrar a configuração carregada, com senhas mascaradas: Mostrar a configuração carregada, com senhas mascaradas:
```bash ```bash
rsdb config show restoredb config show
``` ```
Apagar dumps locais, após confirmação: Apagar dumps locais, após confirmação:
```bash ```bash
rsdb clean restoredb clean
``` ```
Apagar apenas dumps locais mais antigos que 30 dias: Apagar apenas dumps locais mais antigos que 30 dias:
```bash ```bash
rsdb clean --older-than-days 30 restoredb clean --older-than-days 30
``` ```
## Controles da TUI ## Controles da TUI
@ -136,7 +136,7 @@ rsdb clean --older-than-days 30
- `Enter`: selecionar/restaurar - `Enter`: selecionar/restaurar
- `q`: sair - `q`: sair
Antes da restauração, a TUI mostra o host, banco de destino e dump selecionado, com aviso de que o banco será apagado. A restauração só ocorre após confirmação. Antes da restauração, a TUI sugere o banco de destino a partir do nome do dump, permite editar esse valor e então mostra host, banco de destino e dump selecionado, com aviso de que o banco será apagado. A restauração só ocorre após confirmação.
## Formatos suportados ## Formatos suportados
@ -150,7 +150,7 @@ Antes da restauração, a TUI mostra o host, banco de destino e dump selecionado
Logs técnicos são gravados em: Logs técnicos são gravados em:
```bash ```bash
rsdb.log restoredb.log
``` ```
O histórico de restaurações é salvo em: O histórico de restaurações é salvo em:

10
restoredb.log Normal file
View File

@ -0,0 +1,10 @@
2026-06-23T14:19:02.498663Z INFO restoredb::minio: baixando dump remoto: heteroidentificacao/heteroidentificacao_20260623_050003.sql
2026-06-23T14:23:42.294012Z INFO restoredb::tui: Apagando banco de dados...
2026-06-23T14:23:42.434218Z INFO restoredb::tui: Recriando banco de dados...
2026-06-23T14:23:42.746784Z INFO restoredb::tui: Restaurando dump...
2026-06-23T14:23:46.010945Z INFO restoredb::tui: Restauração finalizada.
2026-06-23T14:23:52.728367Z INFO restoredb::minio: baixando dump remoto: matricula/matricula_20260623_045855.sql
2026-06-23T14:24:12.411620Z INFO restoredb::tui: Apagando banco de dados...
2026-06-23T14:24:12.941459Z INFO restoredb::tui: Recriando banco de dados...
2026-06-23T14:24:13.090076Z INFO restoredb::tui: Restaurando dump...
2026-06-23T14:24:13.132939Z ERROR restoredb::tui: psql retornou erro: psql:dumps/matricula_20260623_045855.sql:47: ERROR: role "dsi" does not exist

View File

@ -52,7 +52,7 @@ impl Config {
&& !PathBuf::from("config.toml").exists() && !PathBuf::from("config.toml").exists()
&& !has_required_env() && !has_required_env()
{ {
println!("Configuração inicial do rsdb"); println!("Configuração inicial do restoredb");
println!( println!(
"As respostas serão salvas em {}", "As respostas serão salvas em {}",
executable_yaml_path.display() executable_yaml_path.display()
@ -138,6 +138,12 @@ impl Config {
}; };
serde_yaml::to_string(&masked).context("falha ao gerar YAML da configuração") serde_yaml::to_string(&masked).context("falha ao gerar YAML da configuração")
} }
pub fn with_postgres_database(&self, postgres_database: String) -> Self {
let mut config = self.clone();
config.postgres_database = postgres_database;
config
}
} }
fn merge_file_config(base: FileConfig, override_with: FileConfig) -> FileConfig { fn merge_file_config(base: FileConfig, override_with: FileConfig) -> FileConfig {

View File

@ -137,6 +137,41 @@ pub fn is_supported_dump(name: &str) -> bool {
SUPPORTED_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) SUPPORTED_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
} }
pub fn suggest_database_name(name: &str) -> String {
let basename = Path::new(name)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(name);
let without_extension = strip_supported_extension(basename).unwrap_or(basename);
strip_timestamp_suffix(without_extension)
.unwrap_or(without_extension)
.to_string()
}
fn strip_supported_extension(name: &str) -> Option<&str> {
let lower = name.to_ascii_lowercase();
SUPPORTED_EXTENSIONS
.iter()
.filter(|ext| lower.ends_with(**ext))
.max_by_key(|ext| ext.len())
.map(|ext| &name[..name.len() - ext.len()])
}
fn strip_timestamp_suffix(name: &str) -> Option<&str> {
let (prefix, time) = name.rsplit_once('_')?;
let (prefix, date) = prefix.rsplit_once('_')?;
if date.len() == 8
&& time.len() == 6
&& date.bytes().all(|byte| byte.is_ascii_digit())
&& time.bytes().all(|byte| byte.is_ascii_digit())
&& !prefix.is_empty()
{
Some(prefix)
} else {
None
}
}
pub fn find_dump_by_name(dumps: &[DumpEntry], name: &str) -> Option<DumpEntry> { pub fn find_dump_by_name(dumps: &[DumpEntry], name: &str) -> Option<DumpEntry> {
dumps dumps
.iter() .iter()
@ -144,6 +179,24 @@ pub fn find_dump_by_name(dumps: &[DumpEntry], name: &str) -> Option<DumpEntry> {
.cloned() .cloned()
} }
#[cfg(test)]
mod tests {
use super::suggest_database_name;
#[test]
fn suggests_database_name_from_common_dump_names() {
assert_eq!(
suggest_database_name("heteroidentificacao_20260623_050003.sql"),
"heteroidentificacao"
);
assert_eq!(
suggest_database_name("backup/minha_base_20260623_050003.dump.gz"),
"minha_base"
);
assert_eq!(suggest_database_name("app_db.sql.zst"), "app_db");
}
}
pub async fn materialize_dump( pub async fn materialize_dump(
config: &Config, config: &Config,
minio: &MinioClient, minio: &MinioClient,

View File

@ -6,17 +6,19 @@ mod minio;
mod postgres_restore; mod postgres_restore;
mod tui; mod tui;
use std::io::Write;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use tracing::{error, info}; use tracing::{error, info};
use crate::config::Config; use crate::config::Config;
use crate::dumps::{collect_dumps, ensure_dumps_dir, find_dump_by_name}; use crate::dumps::{collect_dumps, ensure_dumps_dir, find_dump_by_name, suggest_database_name};
use crate::minio::MinioClient; use crate::minio::MinioClient;
use crate::postgres_restore::{confirm_in_terminal, restore_dump}; use crate::postgres_restore::{confirm_in_terminal, restore_dump};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "rsdb")] #[command(name = "restoredb")]
#[command(about = "Assistente para restaurar PostgreSQL a partir de dumps MinIO/S3")] #[command(about = "Assistente para restaurar PostgreSQL a partir de dumps MinIO/S3")]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
@ -56,6 +58,9 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let config = Config::load().context("falha ao carregar configuração")?; let config = Config::load().context("falha ao carregar configuração")?;
ensure_dumps_dir(&config.dumps_dir)?; ensure_dumps_dir(&config.dumps_dir)?;
postgres_restore::check_tools()
.await
.context("ferramentas necessárias indisponíveis")?;
let minio = MinioClient::new(&config).await?; let minio = MinioClient::new(&config).await?;
let result = match cli.command { let result = match cli.command {
@ -83,14 +88,14 @@ async fn main() -> Result<()> {
} }
fn init_logging() -> Result<tracing_appender::non_blocking::WorkerGuard> { fn init_logging() -> Result<tracing_appender::non_blocking::WorkerGuard> {
let file_appender = tracing_appender::rolling::never(".", "rsdb.log"); let file_appender = tracing_appender::rolling::never(".", "restoredb.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_writer(non_blocking) .with_writer(non_blocking)
.with_ansi(false) .with_ansi(false)
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "rsdb=info".into()), .unwrap_or_else(|_| "restoredb=info".into()),
) )
.init(); .init();
Ok(guard) Ok(guard)
@ -129,6 +134,7 @@ async fn command_restore(
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 { if dry_run {
let suggested_database = suggest_database_name(&dump.name);
println!("Dry-run: nenhuma alteração será executada."); println!("Dry-run: nenhuma alteração será executada.");
println!("Dump: {}", dump.name); println!("Dump: {}", dump.name);
println!("Origem: {}", dump.kind.label()); println!("Origem: {}", dump.kind.label());
@ -141,7 +147,7 @@ async fn command_restore(
); );
println!( println!(
"Destino: {}:{} / {}", "Destino: {}:{} / {}",
config.postgres_host, config.postgres_port, config.postgres_database config.postgres_host, config.postgres_port, suggested_database
); );
if dump.local_remote_size_mismatch() { if dump.local_remote_size_mismatch() {
println!("Aviso: o tamanho local difere do tamanho remoto."); println!("Aviso: o tamanho local difere do tamanho remoto.");
@ -155,17 +161,45 @@ async fn command_restore(
println!("A cópia local será usada. Apague o arquivo local para baixar novamente."); 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?;
let target_database = prompt_target_database(&dump.name)?;
let restore_config = config.with_postgres_database(target_database);
if !confirm_in_terminal(config, &local_path)? { if !confirm_in_terminal(&restore_config, &local_path)? {
println!("Operação cancelada."); println!("Operação cancelada.");
return Ok(()); return Ok(());
} }
let result = restore_dump(config, &local_path, |message| { let active_connections = postgres_restore::count_active_connections(&restore_config).await?;
let terminate_connections = if active_connections > 0 {
print!(
"Existem {active_connections} conexão(ões) ativa(s) no banco \"{}\". Encerrar para continuar? (s/N): ",
restore_config.postgres_database
);
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("s") {
true
} else {
println!("Operação cancelada.");
return Ok(());
}
} else {
false
};
let result = restore_dump(
&restore_config,
&local_path,
terminate_connections,
|message| {
println!("{message}"); println!("{message}");
}) },
)
.await; .await;
if let Err(err) = history::record_restore(config, &local_path, result.as_ref().err()).await { if let Err(err) =
history::record_restore(&restore_config, &local_path, result.as_ref().err()).await
{
error!("{err:?}"); error!("{err:?}");
} }
result?; result?;
@ -229,3 +263,22 @@ fn confirm_text(prompt: &str, expected: &str) -> Result<bool> {
std::io::stdin().read_line(&mut input)?; std::io::stdin().read_line(&mut input)?;
Ok(input.trim() == expected) Ok(input.trim() == expected)
} }
fn prompt_target_database(dump_name: &str) -> Result<String> {
let suggested = suggest_database_name(dump_name);
loop {
print!("Banco de destino [{suggested}]: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let database = if input.trim().is_empty() {
suggested.clone()
} else {
input.trim().to_string()
};
if !database.is_empty() {
return Ok(database);
}
println!("Banco de destino não pode ficar vazio.");
}
}

View File

@ -26,7 +26,7 @@ impl MinioClient {
config.minio_secret_key.clone(), config.minio_secret_key.clone(),
None, None,
None, None,
"rsdb", "restoredb",
); );
let shared_config = aws_config::defaults(BehaviorVersion::latest()) let shared_config = aws_config::defaults(BehaviorVersion::latest())
.endpoint_url(config.minio_endpoint.clone()) .endpoint_url(config.minio_endpoint.clone())

View File

@ -26,17 +26,27 @@ pub async fn check_admin_connection(config: &Config) -> Result<()> {
} }
async fn check_tool(name: &str) -> Result<()> { async fn check_tool(name: &str) -> Result<()> {
let output = Command::new(name) let result = Command::new(name).arg("--version").output().await;
.arg("--version") let ok = matches!(result, Ok(output) if output.status.success());
.output() if !ok {
.await bail!(
.with_context(|| format!("{name} não encontrado no PATH"))?; "{name} não encontrado no PATH ou não pôde ser executado. {}",
if !output.status.success() { install_hint()
bail!("{name} retornou erro em --version"); );
} }
Ok(()) Ok(())
} }
fn install_hint() -> String {
if cfg!(target_os = "macos") {
"Instale com: brew install libpq && brew link --force libpq".to_string()
} else if cfg!(target_os = "windows") {
"Instale o PostgreSQL (inclui psql e pg_restore) em https://www.postgresql.org/download/windows/ e adicione o diretório bin ao PATH".to_string()
} else {
"Instale o pacote cliente do PostgreSQL: sudo apt install postgresql-client (Debian/Ubuntu) ou sudo dnf install postgresql (Fedora/RHEL)".to_string()
}
}
pub fn confirm_in_terminal(config: &Config, dump: &Path) -> Result<bool> { pub fn confirm_in_terminal(config: &Config, dump: &Path) -> Result<bool> {
println!("ATENÇÃO: esta operação apagará e recriará o banco de dados."); println!("ATENÇÃO: esta operação apagará e recriará o banco de dados.");
println!("Host: {}", config.postgres_host); println!("Host: {}", config.postgres_host);
@ -49,7 +59,25 @@ pub fn confirm_in_terminal(config: &Config, dump: &Path) -> Result<bool> {
Ok(input.trim() == "RESTAURAR") Ok(input.trim() == "RESTAURAR")
} }
pub async fn restore_dump<F>(config: &Config, dump_path: &Path, mut status: F) -> Result<()> pub async fn count_active_connections(config: &Config) -> Result<i64> {
let sql = format!(
"SELECT count(*) FROM pg_stat_activity WHERE datname = {} AND pid <> pg_backend_pid();",
quote_literal(&config.postgres_database)
);
let output =
run_psql_admin_output(config, &["-t", "-A", "-v", "ON_ERROR_STOP=1", "-c", &sql]).await?;
output
.trim()
.parse::<i64>()
.context("falha ao interpretar contagem de conexões ativas")
}
pub async fn restore_dump<F>(
config: &Config,
dump_path: &Path,
terminate_connections: bool,
mut status: F,
) -> Result<()>
where where
F: FnMut(&str), F: FnMut(&str),
{ {
@ -64,8 +92,10 @@ where
return Err(RestoreAssistantError::UnsupportedDumpFormat(name.to_string()).into()); return Err(RestoreAssistantError::UnsupportedDumpFormat(name.to_string()).into());
} }
if terminate_connections {
status("Encerrando conexões ativas..."); status("Encerrando conexões ativas...");
terminate_connections(config).await?; terminate_active_connections(config).await?;
}
status("Apagando banco de dados..."); status("Apagando banco de dados...");
drop_database(config).await?; drop_database(config).await?;
status("Recriando banco de dados..."); status("Recriando banco de dados...");
@ -76,7 +106,7 @@ where
Ok(()) Ok(())
} }
async fn terminate_connections(config: &Config) -> Result<()> { async fn terminate_active_connections(config: &Config) -> Result<()> {
let sql = format!( let sql = format!(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = {} AND pid <> pg_backend_pid();", "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = {} AND pid <> pg_backend_pid();",
quote_literal(&config.postgres_database) quote_literal(&config.postgres_database)
@ -218,7 +248,7 @@ fn decompress_pg_restore_archive_to_temp(
let file = std::fs::File::open(source) let file = std::fs::File::open(source)
.with_context(|| format!("falha ao abrir {}", source.display()))?; .with_context(|| format!("falha ao abrir {}", source.display()))?;
let temp_path = std::env::temp_dir().join(format!( let temp_path = std::env::temp_dir().join(format!(
"rsdb-{}-{}.tar", "restoredb-{}-{}.tar",
std::process::id(), std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
)); ));
@ -275,6 +305,10 @@ async fn run_psql_admin(config: &Config, args: &[&str]) -> Result<()> {
run_command(base_psql_command(config, "postgres").args(args), "psql").await run_command(base_psql_command(config, "postgres").args(args), "psql").await
} }
async fn run_psql_admin_output(config: &Config, args: &[&str]) -> Result<String> {
run_command_output(base_psql_command(config, "postgres").args(args), "psql").await
}
async fn run_psql_target(config: &Config, args: &[&str]) -> Result<()> { async fn run_psql_target(config: &Config, args: &[&str]) -> Result<()> {
run_command( run_command(
base_psql_command(config, &config.postgres_database).args(args), base_psql_command(config, &config.postgres_database).args(args),
@ -284,6 +318,10 @@ async fn run_psql_target(config: &Config, args: &[&str]) -> Result<()> {
} }
async fn run_command(command: &mut Command, label: &str) -> Result<()> { async fn run_command(command: &mut Command, label: &str) -> Result<()> {
run_command_output(command, label).await.map(|_| ())
}
async fn run_command_output(command: &mut Command, label: &str) -> Result<String> {
debug!("executando comando externo: {label}"); debug!("executando comando externo: {label}");
let output = command let output = command
.output() .output()
@ -293,7 +331,7 @@ async fn run_command(command: &mut Command, label: &str) -> Result<()> {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
bail!("{label} retornou erro: {}", stderr.trim()); bail!("{label} retornou erro: {}", stderr.trim());
} }
Ok(()) Ok(String::from_utf8_lossy(&output.stdout).to_string())
} }
fn base_psql_command(config: &Config, database: &str) -> Command { fn base_psql_command(config: &Config, database: &str) -> Command {

View File

@ -14,7 +14,7 @@ use tracing::{error, info};
use crate::{ use crate::{
config::Config, config::Config,
dumps::{self, DumpEntry, DumpKind}, dumps::{self, suggest_database_name, DumpEntry, DumpKind},
errors::RestoreAssistantError, errors::RestoreAssistantError,
history, history,
minio::MinioClient, minio::MinioClient,
@ -24,7 +24,19 @@ use crate::{
enum Mode { enum Mode {
Browsing, Browsing,
Filtering, Filtering,
Confirming(PathBuf), EditingDatabase {
path: PathBuf,
input: String,
},
Confirming {
path: PathBuf,
database: String,
},
ConfirmTerminate {
path: PathBuf,
database: String,
count: i64,
},
ConfirmDelete(DumpEntry), ConfirmDelete(DumpEntry),
Working, Working,
Error(String), Error(String),
@ -105,10 +117,39 @@ impl App {
} }
_ => {} _ => {}
}, },
Mode::Confirming(path) => match key.code { Mode::EditingDatabase { .. } => match key.code {
KeyCode::Enter => {
if let Mode::EditingDatabase { path, input } = &self.mode {
let path = path.clone();
let database = input.trim().to_string();
if database.is_empty() {
self.status = "Banco de destino não pode ficar vazio.".to_string();
} else {
self.mode = Mode::Confirming { path, database };
}
}
}
KeyCode::Backspace => {
if let Mode::EditingDatabase { input, .. } = &mut self.mode {
input.pop();
}
}
KeyCode::Char(c) => {
if let Mode::EditingDatabase { input, .. } = &mut self.mode {
input.push(c);
}
}
KeyCode::Esc => {
self.status = "Restauração cancelada.".to_string();
self.mode = Mode::Browsing;
}
_ => {}
},
Mode::Confirming { path, database } => match key.code {
KeyCode::Char('y') | KeyCode::Char('s') => { KeyCode::Char('y') | KeyCode::Char('s') => {
let path = path.clone(); let path = path.clone();
self.restore(path).await; let database = database.clone();
self.check_connections(path, database).await;
} }
KeyCode::Esc | KeyCode::Char('n') => { KeyCode::Esc | KeyCode::Char('n') => {
self.status = "Restauração cancelada.".to_string(); self.status = "Restauração cancelada.".to_string();
@ -116,6 +157,19 @@ impl App {
} }
_ => {} _ => {}
}, },
Mode::ConfirmTerminate { path, database, .. } => match key.code {
KeyCode::Char('y') | KeyCode::Char('s') => {
let path = path.clone();
let database = database.clone();
self.restore(path, database, true).await;
}
KeyCode::Esc | KeyCode::Char('n') => {
self.status =
"Restauração cancelada: conexões ativas não encerradas.".to_string();
self.mode = Mode::Browsing;
}
_ => {}
},
Mode::ConfirmDelete(dump) => match key.code { Mode::ConfirmDelete(dump) => match key.code {
KeyCode::Char('y') | KeyCode::Char('s') => { KeyCode::Char('y') | KeyCode::Char('s') => {
let dump = dump.clone(); let dump = dump.clone();
@ -196,7 +250,10 @@ impl App {
} }
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) => {
self.mode = Mode::Confirming(path); self.mode = Mode::EditingDatabase {
path,
input: suggest_database_name(&dump.name),
};
} }
Err(err) => { Err(err) => {
error!("{err:?}"); error!("{err:?}");
@ -231,14 +288,39 @@ impl App {
} }
} }
async fn restore(&mut self, path: PathBuf) { async fn check_connections(&mut self, path: PathBuf, database: String) {
let restore_config = self.config.with_postgres_database(database.clone());
match postgres_restore::count_active_connections(&restore_config).await {
Ok(count) if count > 0 => {
self.mode = Mode::ConfirmTerminate {
path,
database,
count,
};
}
Ok(_) => self.restore(path, database, false).await,
Err(err) => {
error!("{err:?}");
self.mode = Mode::Error(format!("Falha ao verificar conexões ativas: {err}"));
}
}
}
async fn restore(&mut self, path: PathBuf, database: String, terminate_connections: bool) {
self.mode = Mode::Working; self.mode = Mode::Working;
self.status = "Iniciando restauração...".to_string(); self.status = "Iniciando restauração...".to_string();
let result = postgres_restore::restore_dump(&self.config, &path, |message| { let restore_config = self.config.with_postgres_database(database);
let result = postgres_restore::restore_dump(
&restore_config,
&path,
terminate_connections,
|message| {
info!("{message}"); info!("{message}");
}) },
)
.await; .await;
if let Err(err) = history::record_restore(&self.config, &path, result.as_ref().err()).await if let Err(err) =
history::record_restore(&restore_config, &path, result.as_ref().err()).await
{ {
error!("{err:?}"); error!("{err:?}");
} }
@ -264,8 +346,8 @@ impl App {
.split(area); .split(area);
let title = format!( let title = format!(
"rsdb | destino: {}:{} / {}", "restoredb | PostgreSQL: {}:{}",
self.config.postgres_host, self.config.postgres_port, self.config.postgres_database self.config.postgres_host, self.config.postgres_port
); );
frame.render_widget( frame.render_widget(
Paragraph::new(title).block(Block::default().borders(Borders::ALL)), Paragraph::new(title).block(Block::default().borders(Borders::ALL)),
@ -323,7 +405,15 @@ impl App {
); );
match &self.mode { match &self.mode {
Mode::Confirming(path) => self.draw_confirm(frame, area, path), Mode::EditingDatabase { path, input } => {
self.draw_database_input(frame, area, path, input)
}
Mode::Confirming { path, database } => self.draw_confirm(frame, area, path, database),
Mode::ConfirmTerminate {
path,
database,
count,
} => self.draw_terminate_confirm(frame, area, path, database, *count),
Mode::ConfirmDelete(dump) => self.draw_delete_confirm(frame, area, dump), Mode::ConfirmDelete(dump) => self.draw_delete_confirm(frame, area, dump),
Mode::Working => self.draw_popup( Mode::Working => self.draw_popup(
frame, frame,
@ -336,16 +426,41 @@ impl App {
} }
} }
fn draw_confirm(&self, frame: &mut Frame, area: Rect, path: &PathBuf) { fn draw_database_input(&self, frame: &mut Frame, area: Rect, path: &PathBuf, input: &str) {
let text = format!(
"Informe o banco de destino.\n\nDump: {}\nBanco: {}\n\nEnter confirma, Esc cancela.",
path.display(),
input
);
self.draw_popup(frame, area, "Banco de destino", &text);
}
fn draw_confirm(&self, frame: &mut Frame, area: Rect, path: &PathBuf, database: &str) {
let text = format!( let text = format!(
"Esta operação apagará e recriará o banco.\n\nHost: {}\nBanco: {}\nDump: {}\n\nPressione y/s para confirmar ou Esc/n para cancelar.", "Esta operação apagará e recriará o banco.\n\nHost: {}\nBanco: {}\nDump: {}\n\nPressione y/s para confirmar ou Esc/n para cancelar.",
self.config.postgres_host, self.config.postgres_host,
self.config.postgres_database, database,
path.display() path.display()
); );
self.draw_popup(frame, area, "Confirmar restauração", &text); self.draw_popup(frame, area, "Confirmar restauração", &text);
} }
fn draw_terminate_confirm(
&self,
frame: &mut Frame,
area: Rect,
path: &PathBuf,
database: &str,
count: i64,
) {
let text = format!(
"Existem {count} conexão(ões) ativa(s) no banco \"{}\".\nDump: {}\n\nEncerrar as conexões para continuar?\n\nPressione y/s para confirmar ou Esc/n para cancelar.",
database,
path.display()
);
self.draw_popup(frame, area, "Conexões ativas", &text);
}
fn draw_delete_confirm(&self, frame: &mut Frame, area: Rect, dump: &DumpEntry) { fn draw_delete_confirm(&self, frame: &mut Frame, area: Rect, dump: &DumpEntry) {
let path = dump let path = dump
.local_path .local_path