Atualiza fluxo de restauracao de dumps
This commit is contained in:
parent
ab37755e77
commit
2f4c25979d
15
.env.example
15
.env.example
@ -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
56
Cargo.lock
generated
@ -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"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rsdb"
|
name = "restoredb"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|||||||
28
README.md
28
README.md
@ -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
10
restoredb.log
Normal 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
|
||||||
@ -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 {
|
||||||
|
|||||||
53
src/dumps.rs
53
src/dumps.rs
@ -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,
|
||||||
|
|||||||
73
src/main.rs
73
src/main.rs
@ -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?;
|
||||||
println!("{message}");
|
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}");
|
||||||
|
},
|
||||||
|
)
|
||||||
.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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
status("Encerrando conexões ativas...");
|
if terminate_connections {
|
||||||
terminate_connections(config).await?;
|
status("Encerrando conexões ativas...");
|
||||||
|
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 {
|
||||||
|
|||||||
145
src/tui.rs
145
src/tui.rs
@ -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);
|
||||||
info!("{message}");
|
let result = postgres_restore::restore_dump(
|
||||||
})
|
&restore_config,
|
||||||
|
&path,
|
||||||
|
terminate_connections,
|
||||||
|
|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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user