Initial commit
This commit is contained in:
commit
91932c78b8
15
.env.example
Normal file
15
.env.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
|
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
/dumps
|
||||||
|
/.env
|
||||||
|
/restore-assistant.log
|
||||||
|
|
||||||
3419
Cargo.lock
generated
Normal file
3419
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
Cargo.toml
Normal file
29
Cargo.toml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "restore-assistant"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
aws-config = "1"
|
||||||
|
aws-credential-types = "1"
|
||||||
|
aws-sdk-s3 = "1"
|
||||||
|
bytesize = "1"
|
||||||
|
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"
|
||||||
|
rpassword = "7"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
toml = "0.8"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
zstd = "0.13"
|
||||||
133
README.md
Normal file
133
README.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# Restore Assistant
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Rust stable
|
||||||
|
- Ferramentas PostgreSQL no `PATH`:
|
||||||
|
- `psql`
|
||||||
|
- `pg_restore`
|
||||||
|
- Acesso ao bucket MinIO/S3 com API compatível com S3
|
||||||
|
|
||||||
|
## Instalação
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
O binário ficará em:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
target/release/restore-assistant
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Senhas são perguntadas sem eco no terminal. O programa também continua aceitando `.env` e `config.toml` na raiz do projeto.
|
||||||
|
|
||||||
|
A ordem de precedência é:
|
||||||
|
|
||||||
|
1. Variáveis de ambiente / `.env`
|
||||||
|
2. `config.toml` local
|
||||||
|
3. `~/.config/rsdb/config.yml`
|
||||||
|
|
||||||
|
Exemplo `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemplo `config.toml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemplo `config.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/rsdb
|
||||||
|
cp config.example.yml ~/.config/rsdb/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Variáveis obrigatórias:
|
||||||
|
|
||||||
|
- `MINIO_ENDPOINT`
|
||||||
|
- `MINIO_ACCESS_KEY`
|
||||||
|
- `MINIO_SECRET_KEY`
|
||||||
|
- `MINIO_BUCKET`
|
||||||
|
- `POSTGRES_HOST`
|
||||||
|
- `POSTGRES_PORT`
|
||||||
|
- `POSTGRES_USER`
|
||||||
|
- `POSTGRES_PASSWORD`
|
||||||
|
- `POSTGRES_DATABASE`
|
||||||
|
|
||||||
|
Opcionais:
|
||||||
|
|
||||||
|
- `MINIO_REGION`, padrão `us-east-1`
|
||||||
|
- `POSTGRES_SSLMODE`, padrão `prefer`
|
||||||
|
- `DUMPS_DIR`, padrão `./dumps`
|
||||||
|
|
||||||
|
Senhas nunca são exibidas. A senha do PostgreSQL é passada para `psql` e `pg_restore` por `PGPASSWORD`.
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
Abrir a TUI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
restore-assistant
|
||||||
|
```
|
||||||
|
|
||||||
|
Listar dumps remotos e locais:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
restore-assistant list
|
||||||
|
```
|
||||||
|
|
||||||
|
Restaurar diretamente um dump local ou remoto, ainda com confirmação:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
restore-assistant restore backup.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Validar configuração, pasta local, conexão S3 e ferramentas PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
restore-assistant config-check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controles da TUI
|
||||||
|
|
||||||
|
- `↑`/`↓` ou `k`/`j`: navegar
|
||||||
|
- `/`: filtrar por nome
|
||||||
|
- `Esc`: limpar filtro ou cancelar confirmação
|
||||||
|
- `r`: atualizar listas
|
||||||
|
- `d`: apagar o arquivo local do dump selecionado, após confirmação
|
||||||
|
- `Enter`: selecionar/restaurar
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Formatos suportados
|
||||||
|
|
||||||
|
- `.sql`: restaurado com `psql`
|
||||||
|
- `.sql.gz`, `.sql.zst`: descompactados e restaurados com `psql`
|
||||||
|
- `.dump`, `.backup`, `.bin`, `.binary`, `.tar`: restaurados com `pg_restore`
|
||||||
|
- `.dump.gz`, `.dump.zst`, `.backup.gz`, `.backup.zst`, `.bin.gz`, `.bin.zst`, `.binary.gz`, `.binary.zst`, `.tar.gz`, `.tar.zst`: descompactados para arquivo temporário e restaurados com `pg_restore`
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Logs técnicos são gravados em:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
restore-assistant.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Erros exibidos na TUI ou CLI são resumidos para o usuário; detalhes técnicos ficam no log.
|
||||||
15
config.example.toml
Normal file
15
config.example.toml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
13
config.example.yml
Normal file
13
config.example.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
|
||||||
255
src/config.rs
Normal file
255
src/config.rs
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
io::{self, IsTerminal, Write},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use dotenvy::dotenv;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub minio_endpoint: String,
|
||||||
|
pub minio_access_key: String,
|
||||||
|
pub minio_secret_key: String,
|
||||||
|
pub minio_bucket: String,
|
||||||
|
pub minio_region: String,
|
||||||
|
pub postgres_host: String,
|
||||||
|
pub postgres_port: u16,
|
||||||
|
pub postgres_user: String,
|
||||||
|
pub postgres_password: String,
|
||||||
|
pub postgres_database: String,
|
||||||
|
pub postgres_sslmode: String,
|
||||||
|
pub dumps_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||||
|
struct FileConfig {
|
||||||
|
minio_endpoint: Option<String>,
|
||||||
|
minio_access_key: Option<String>,
|
||||||
|
minio_secret_key: Option<String>,
|
||||||
|
minio_bucket: Option<String>,
|
||||||
|
minio_region: Option<String>,
|
||||||
|
postgres_host: Option<String>,
|
||||||
|
postgres_port: Option<u16>,
|
||||||
|
postgres_user: Option<String>,
|
||||||
|
postgres_password: Option<String>,
|
||||||
|
postgres_database: Option<String>,
|
||||||
|
postgres_sslmode: Option<String>,
|
||||||
|
dumps_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let _ = dotenv();
|
||||||
|
let yaml_path = default_yaml_path()?;
|
||||||
|
let yaml = load_yaml(&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() {
|
||||||
|
println!("Configuração inicial do restore-assistant");
|
||||||
|
println!("As respostas serão salvas em {}", yaml_path.display());
|
||||||
|
file = prompt_config()?;
|
||||||
|
save_yaml(&yaml_path, &file)?;
|
||||||
|
println!("Configuração salva.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Self {
|
||||||
|
minio_endpoint: read_string("MINIO_ENDPOINT", file.minio_endpoint)?,
|
||||||
|
minio_access_key: read_string("MINIO_ACCESS_KEY", file.minio_access_key)?,
|
||||||
|
minio_secret_key: read_string("MINIO_SECRET_KEY", file.minio_secret_key)?,
|
||||||
|
minio_bucket: read_string("MINIO_BUCKET", file.minio_bucket)?,
|
||||||
|
minio_region: read_optional_string("MINIO_REGION", file.minio_region)
|
||||||
|
.unwrap_or_else(|| "us-east-1".to_string()),
|
||||||
|
postgres_host: read_string("POSTGRES_HOST", file.postgres_host)?,
|
||||||
|
postgres_port: read_optional_parse("POSTGRES_PORT", file.postgres_port)?
|
||||||
|
.unwrap_or(5432),
|
||||||
|
postgres_user: read_string("POSTGRES_USER", file.postgres_user)?,
|
||||||
|
postgres_password: read_string("POSTGRES_PASSWORD", file.postgres_password)?,
|
||||||
|
postgres_database: read_string("POSTGRES_DATABASE", file.postgres_database)?,
|
||||||
|
postgres_sslmode: read_optional_string("POSTGRES_SSLMODE", file.postgres_sslmode)
|
||||||
|
.unwrap_or_else(|| "prefer".to_string()),
|
||||||
|
dumps_dir: env::var("DUMPS_DIR")
|
||||||
|
.ok()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or(file.dumps_dir)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("./dumps")),
|
||||||
|
};
|
||||||
|
config.validate()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
for (name, value) in [
|
||||||
|
("MINIO_ENDPOINT", &self.minio_endpoint),
|
||||||
|
("MINIO_ACCESS_KEY", &self.minio_access_key),
|
||||||
|
("MINIO_SECRET_KEY", &self.minio_secret_key),
|
||||||
|
("MINIO_BUCKET", &self.minio_bucket),
|
||||||
|
("POSTGRES_HOST", &self.postgres_host),
|
||||||
|
("POSTGRES_USER", &self.postgres_user),
|
||||||
|
("POSTGRES_PASSWORD", &self.postgres_password),
|
||||||
|
("POSTGRES_DATABASE", &self.postgres_database),
|
||||||
|
] {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
bail!("{name} não pode ficar vazio");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_file_config(base: FileConfig, override_with: FileConfig) -> FileConfig {
|
||||||
|
FileConfig {
|
||||||
|
minio_endpoint: override_with.minio_endpoint.or(base.minio_endpoint),
|
||||||
|
minio_access_key: override_with.minio_access_key.or(base.minio_access_key),
|
||||||
|
minio_secret_key: override_with.minio_secret_key.or(base.minio_secret_key),
|
||||||
|
minio_bucket: override_with.minio_bucket.or(base.minio_bucket),
|
||||||
|
minio_region: override_with.minio_region.or(base.minio_region),
|
||||||
|
postgres_host: override_with.postgres_host.or(base.postgres_host),
|
||||||
|
postgres_port: override_with.postgres_port.or(base.postgres_port),
|
||||||
|
postgres_user: override_with.postgres_user.or(base.postgres_user),
|
||||||
|
postgres_password: override_with.postgres_password.or(base.postgres_password),
|
||||||
|
postgres_database: override_with.postgres_database.or(base.postgres_database),
|
||||||
|
postgres_sslmode: override_with.postgres_sslmode.or(base.postgres_sslmode),
|
||||||
|
dumps_dir: override_with.dumps_dir.or(base.dumps_dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_required_env() -> bool {
|
||||||
|
[
|
||||||
|
"MINIO_ENDPOINT",
|
||||||
|
"MINIO_ACCESS_KEY",
|
||||||
|
"MINIO_SECRET_KEY",
|
||||||
|
"MINIO_BUCKET",
|
||||||
|
"POSTGRES_HOST",
|
||||||
|
"POSTGRES_USER",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
"POSTGRES_DATABASE",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.all(|name| env::var(name).is_ok_and(|value| !value.trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_config() -> Result<FileConfig> {
|
||||||
|
if !io::stdin().is_terminal() {
|
||||||
|
bail!("configuração não encontrada e stdin não é interativo");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(FileConfig {
|
||||||
|
minio_endpoint: Some(prompt_required("MINIO_ENDPOINT", None)?),
|
||||||
|
minio_access_key: Some(prompt_required("MINIO_ACCESS_KEY", None)?),
|
||||||
|
minio_secret_key: Some(prompt_secret("MINIO_SECRET_KEY")?),
|
||||||
|
minio_bucket: Some(prompt_required("MINIO_BUCKET", None)?),
|
||||||
|
minio_region: Some(prompt_required("MINIO_REGION", Some("us-east-1"))?),
|
||||||
|
postgres_host: Some(prompt_required("POSTGRES_HOST", Some("localhost"))?),
|
||||||
|
postgres_port: Some(prompt_port("POSTGRES_PORT", 5432)?),
|
||||||
|
postgres_user: Some(prompt_required("POSTGRES_USER", Some("postgres"))?),
|
||||||
|
postgres_password: Some(prompt_secret("POSTGRES_PASSWORD")?),
|
||||||
|
postgres_database: Some(prompt_required("POSTGRES_DATABASE", None)?),
|
||||||
|
postgres_sslmode: Some(prompt_required("POSTGRES_SSLMODE", Some("prefer"))?),
|
||||||
|
dumps_dir: Some(PathBuf::from(prompt_required(
|
||||||
|
"DUMPS_DIR",
|
||||||
|
Some("./dumps"),
|
||||||
|
)?)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_required(name: &str, default: Option<&str>) -> Result<String> {
|
||||||
|
loop {
|
||||||
|
let value = prompt_line(name, default)?;
|
||||||
|
if !value.trim().is_empty() {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
println!("{name} é obrigatório.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_port(name: &str, default: u16) -> Result<u16> {
|
||||||
|
loop {
|
||||||
|
let value = prompt_line(name, Some(&default.to_string()))?;
|
||||||
|
match value.parse() {
|
||||||
|
Ok(port) => return Ok(port),
|
||||||
|
Err(_) => println!("{name} deve ser um número de porta válido."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_line(name: &str, default: Option<&str>) -> Result<String> {
|
||||||
|
match default {
|
||||||
|
Some(default) => print!("{name} [{default}]: "),
|
||||||
|
None => print!("{name}: "),
|
||||||
|
}
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_line(&mut input)?;
|
||||||
|
let value = input.trim().to_string();
|
||||||
|
if value.is_empty() {
|
||||||
|
Ok(default.unwrap_or_default().to_string())
|
||||||
|
} else {
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_secret(name: &str) -> Result<String> {
|
||||||
|
loop {
|
||||||
|
let value = rpassword::prompt_password(format!("{name}: "))?;
|
||||||
|
if !value.trim().is_empty() {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
println!("{name} é obrigatório.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_yaml_path() -> Result<PathBuf> {
|
||||||
|
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 load_yaml(path: &PathBuf) -> Result<FileConfig> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(FileConfig::default());
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("não foi possível ler {}", path.display()))?;
|
||||||
|
serde_yaml::from_str(&raw).with_context(|| format!("{} inválido", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_yaml(path: &PathBuf, config: &FileConfig) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("não foi possível criar {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let raw = serde_yaml::to_string(config).context("falha ao gerar YAML de configuração")?;
|
||||||
|
std::fs::write(path, raw).with_context(|| format!("não foi possível salvar {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_toml() -> Result<FileConfig> {
|
||||||
|
let path = PathBuf::from("config.toml");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(FileConfig::default());
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(&path).context("não foi possível ler config.toml")?;
|
||||||
|
toml::from_str(&raw).context("config.toml inválido")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_string(name: &str, fallback: Option<String>) -> Result<String> {
|
||||||
|
read_optional_string(name, fallback).with_context(|| format!("{name} não configurado"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_optional_string(name: &str, fallback: Option<String>) -> Option<String> {
|
||||||
|
env::var(name).ok().or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_optional_parse<T>(name: &str, fallback: Option<T>) -> Result<Option<T>>
|
||||||
|
where
|
||||||
|
T: std::str::FromStr,
|
||||||
|
T::Err: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
match env::var(name) {
|
||||||
|
Ok(value) => Ok(Some(value.parse()?)),
|
||||||
|
Err(_) => Ok(fallback),
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/dumps.rs
Normal file
187
src/dumps.rs
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
use crate::{config::Config, minio::MinioClient};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DumpKind {
|
||||||
|
Local,
|
||||||
|
Remote,
|
||||||
|
RemoteAndLocal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DumpKind {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Local => "local",
|
||||||
|
Self::Remote => "remote",
|
||||||
|
Self::RemoteAndLocal => "remote+local",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DumpEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub key: Option<String>,
|
||||||
|
pub local_path: Option<PathBuf>,
|
||||||
|
pub size: Option<u64>,
|
||||||
|
pub modified: Option<DateTime<Utc>>,
|
||||||
|
pub kind: DumpKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const SUPPORTED_EXTENSIONS: &[&str] = &[
|
||||||
|
".sql",
|
||||||
|
".sql.gz",
|
||||||
|
".sql.zst",
|
||||||
|
".dump",
|
||||||
|
".dump.gz",
|
||||||
|
".dump.zst",
|
||||||
|
".backup",
|
||||||
|
".backup.gz",
|
||||||
|
".backup.zst",
|
||||||
|
".bin",
|
||||||
|
".bin.gz",
|
||||||
|
".bin.zst",
|
||||||
|
".binary",
|
||||||
|
".binary.gz",
|
||||||
|
".binary.zst",
|
||||||
|
".tar",
|
||||||
|
".tar.gz",
|
||||||
|
".tar.zst",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn ensure_dumps_dir(path: &Path) -> Result<()> {
|
||||||
|
fs::create_dir_all(path).with_context(|| format!("não foi possível criar {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collect_dumps(config: &Config, minio: &MinioClient) -> Result<Vec<DumpEntry>> {
|
||||||
|
ensure_dumps_dir(&config.dumps_dir)?;
|
||||||
|
let locals = list_local_dumps(&config.dumps_dir)?;
|
||||||
|
let remotes = minio.list_dumps().await?;
|
||||||
|
|
||||||
|
let mut by_name: BTreeMap<String, DumpEntry> = BTreeMap::new();
|
||||||
|
for local in locals {
|
||||||
|
by_name.insert(local.name.clone(), local);
|
||||||
|
}
|
||||||
|
for remote in remotes {
|
||||||
|
match by_name.get_mut(&remote.name) {
|
||||||
|
Some(existing) => {
|
||||||
|
existing.key = remote.key;
|
||||||
|
existing.size = remote.size.or(existing.size);
|
||||||
|
existing.modified = remote.modified.or(existing.modified);
|
||||||
|
existing.kind = DumpKind::RemoteAndLocal;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
by_name.insert(remote.name.clone(), remote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(by_name.into_values().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_local_dumps(dir: &Path) -> Result<Vec<DumpEntry>> {
|
||||||
|
ensure_dumps_dir(dir)?;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in fs::read_dir(dir).with_context(|| format!("falha ao ler {}", dir.display()))? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !is_supported_dump(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = entry.metadata()?;
|
||||||
|
let modified = metadata.modified().ok().map(DateTime::<Utc>::from);
|
||||||
|
entries.push(DumpEntry {
|
||||||
|
name,
|
||||||
|
key: None,
|
||||||
|
local_path: Some(path),
|
||||||
|
size: Some(metadata.len()),
|
||||||
|
modified,
|
||||||
|
kind: DumpKind::Local,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_supported_dump(name: &str) -> bool {
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
SUPPORTED_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_dump_by_name(dumps: &[DumpEntry], name: &str) -> Option<DumpEntry> {
|
||||||
|
dumps
|
||||||
|
.iter()
|
||||||
|
.find(|dump| dump.name == name || dump.key.as_deref() == Some(name))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn materialize_dump(
|
||||||
|
config: &Config,
|
||||||
|
minio: &MinioClient,
|
||||||
|
dump: &DumpEntry,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
if let Some(path) = &dump.local_path {
|
||||||
|
if path.exists() && !overwrite {
|
||||||
|
return Ok(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(dump.kind, DumpKind::Local) {
|
||||||
|
return dump
|
||||||
|
.local_path
|
||||||
|
.clone()
|
||||||
|
.with_context(|| format!("dump local sem caminho: {}", dump.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = dump
|
||||||
|
.key
|
||||||
|
.as_deref()
|
||||||
|
.with_context(|| format!("dump remoto sem chave S3: {}", dump.name))?;
|
||||||
|
let destination = config.dumps_dir.join(&dump.name);
|
||||||
|
if destination.exists() && !overwrite {
|
||||||
|
return Ok(destination);
|
||||||
|
}
|
||||||
|
minio
|
||||||
|
.download_dump(key, &destination, |_| {})
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("falha ao baixar {}", dump.name))?;
|
||||||
|
Ok(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_local_dump(dump: &DumpEntry) -> Result<PathBuf> {
|
||||||
|
let path = dump
|
||||||
|
.local_path
|
||||||
|
.as_ref()
|
||||||
|
.with_context(|| format!("{} não possui arquivo local para apagar", dump.name))?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"arquivo local não existe: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"caminho local não é arquivo: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
fs::remove_file(path).with_context(|| format!("falha ao apagar {}", path.display()))?;
|
||||||
|
Ok(path.clone())
|
||||||
|
}
|
||||||
9
src/errors.rs
Normal file
9
src/errors.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum RestoreAssistantError {
|
||||||
|
#[error("formato de dump não suportado: {0}")]
|
||||||
|
UnsupportedDumpFormat(String),
|
||||||
|
#[error("operação cancelada pelo usuário")]
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
123
src/main.rs
Normal file
123
src/main.rs
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
mod config;
|
||||||
|
mod dumps;
|
||||||
|
mod errors;
|
||||||
|
mod minio;
|
||||||
|
mod postgres_restore;
|
||||||
|
mod tui;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::dumps::{collect_dumps, ensure_dumps_dir, find_dump_by_name};
|
||||||
|
use crate::minio::MinioClient;
|
||||||
|
use crate::postgres_restore::{confirm_in_terminal, restore_dump};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "restore-assistant")]
|
||||||
|
#[command(about = "Assistente para restaurar PostgreSQL a partir de dumps MinIO/S3")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum Commands {
|
||||||
|
List,
|
||||||
|
Restore { arquivo: String },
|
||||||
|
ConfigCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let _guard = init_logging()?;
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config = Config::load().context("falha ao carregar configuração")?;
|
||||||
|
ensure_dumps_dir(&config.dumps_dir)?;
|
||||||
|
let minio = MinioClient::new(&config).await?;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = &result {
|
||||||
|
error!("{err:?}");
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging() -> Result<tracing_appender::non_blocking::WorkerGuard> {
|
||||||
|
let file_appender = tracing_appender::rolling::never(".", "restore-assistant.log");
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(non_blocking)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "restore_assistant=info,restore_assistant=debug,info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
Ok(guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_list(config: &Config, minio: &MinioClient) -> Result<()> {
|
||||||
|
let dumps = collect_dumps(config, minio).await?;
|
||||||
|
for dump in dumps {
|
||||||
|
let size = dump
|
||||||
|
.size
|
||||||
|
.map(bytesize::ByteSize::b)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
let modified = dump
|
||||||
|
.modified
|
||||||
|
.map(|d| d.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
println!(
|
||||||
|
"{:<18} {:>12} {:<25} {}",
|
||||||
|
dump.kind.label(),
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
dump.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_restore(config: &Config, minio: &MinioClient, name: &str) -> Result<()> {
|
||||||
|
let dumps = collect_dumps(config, minio).await?;
|
||||||
|
let dump =
|
||||||
|
find_dump_by_name(&dumps, name).with_context(|| format!("dump não encontrado: {name}"))?;
|
||||||
|
let local_path = dumps::materialize_dump(config, minio, &dump, false).await?;
|
||||||
|
|
||||||
|
if !confirm_in_terminal(config, &local_path)? {
|
||||||
|
println!("Operação cancelada.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_dump(config, &local_path, |message| {
|
||||||
|
println!("{message}");
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
info!("restauração concluída: {}", local_path.display());
|
||||||
|
println!("Restauração concluída.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_config_check(config: &Config, minio: &MinioClient) -> Result<()> {
|
||||||
|
config.validate()?;
|
||||||
|
ensure_dumps_dir(&config.dumps_dir)?;
|
||||||
|
minio.check().await?;
|
||||||
|
postgres_restore::check_tools().await?;
|
||||||
|
println!("Configuração válida.");
|
||||||
|
println!("Bucket: {}", config.minio_bucket);
|
||||||
|
println!(
|
||||||
|
"PostgreSQL: {}:{} / {}",
|
||||||
|
config.postgres_host, config.postgres_port, config.postgres_database
|
||||||
|
);
|
||||||
|
println!("Dumps locais: {}", config.dumps_dir.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
137
src/minio.rs
Normal file
137
src/minio.rs
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use aws_config::BehaviorVersion;
|
||||||
|
use aws_credential_types::Credentials;
|
||||||
|
use aws_sdk_s3::{config::Region, primitives::ByteStream, Client};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use tokio::{fs::File, io::AsyncWriteExt};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::Config,
|
||||||
|
dumps::{is_supported_dump, DumpEntry, DumpKind},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MinioClient {
|
||||||
|
bucket: String,
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MinioClient {
|
||||||
|
pub async fn new(config: &Config) -> Result<Self> {
|
||||||
|
let credentials = Credentials::new(
|
||||||
|
config.minio_access_key.clone(),
|
||||||
|
config.minio_secret_key.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"restore-assistant",
|
||||||
|
);
|
||||||
|
let shared_config = aws_config::defaults(BehaviorVersion::latest())
|
||||||
|
.endpoint_url(config.minio_endpoint.clone())
|
||||||
|
.region(Region::new(config.minio_region.clone()))
|
||||||
|
.credentials_provider(credentials)
|
||||||
|
.load()
|
||||||
|
.await;
|
||||||
|
let s3_config = aws_sdk_s3::config::Builder::from(&shared_config)
|
||||||
|
.force_path_style(true)
|
||||||
|
.build();
|
||||||
|
Ok(Self {
|
||||||
|
bucket: config.minio_bucket.clone(),
|
||||||
|
client: Client::from_conf(s3_config),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check(&self) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.head_bucket()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("não foi possível acessar o bucket MinIO/S3")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_dumps(&self) -> Result<Vec<DumpEntry>> {
|
||||||
|
let mut token = None;
|
||||||
|
let mut dumps = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.set_continuation_token(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("falha ao listar objetos no MinIO/S3")?;
|
||||||
|
|
||||||
|
for object in response.contents() {
|
||||||
|
let Some(key) = object.key() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = key.rsplit('/').next().unwrap_or(key).to_string();
|
||||||
|
if !is_supported_dump(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let size = object.size().and_then(|s| u64::try_from(s).ok());
|
||||||
|
let modified = object
|
||||||
|
.last_modified()
|
||||||
|
.and_then(|dt| DateTime::parse_from_rfc3339(&dt.to_string()).ok())
|
||||||
|
.map(|dt| dt.with_timezone(&Utc));
|
||||||
|
dumps.push(DumpEntry {
|
||||||
|
name,
|
||||||
|
key: Some(key.to_string()),
|
||||||
|
local_path: None,
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
kind: DumpKind::Remote,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.is_truncated().unwrap_or(false) {
|
||||||
|
token = response.next_continuation_token().map(str::to_string);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(dumps)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_dump<F>(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
destination: &Path,
|
||||||
|
mut progress: F,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
F: FnMut(u64),
|
||||||
|
{
|
||||||
|
info!("baixando dump remoto: {key}");
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("falha ao baixar objeto {key}"))?;
|
||||||
|
|
||||||
|
let bytes = collect_body(response.body).await?;
|
||||||
|
if let Some(parent) = destination.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
let mut file = File::create(destination).await?;
|
||||||
|
file.write_all(&bytes).await?;
|
||||||
|
file.flush().await?;
|
||||||
|
progress(bytes.len() as u64);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_body(body: ByteStream) -> Result<Vec<u8>> {
|
||||||
|
let data = body.collect().await.context("falha ao ler stream S3")?;
|
||||||
|
Ok(data.into_bytes().to_vec())
|
||||||
|
}
|
||||||
341
src/postgres_restore.rs
Normal file
341
src/postgres_restore.rs
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
use std::{
|
||||||
|
ffi::OsStr,
|
||||||
|
io::Write,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Stdio,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use tokio::{
|
||||||
|
io::AsyncWriteExt,
|
||||||
|
process::{ChildStdin, Command},
|
||||||
|
};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::{config::Config, dumps::is_supported_dump, errors::RestoreAssistantError};
|
||||||
|
|
||||||
|
pub async fn check_tools() -> Result<()> {
|
||||||
|
check_tool("psql").await?;
|
||||||
|
check_tool("pg_restore").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_tool(name: &str) -> Result<()> {
|
||||||
|
let output = Command::new(name)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("{name} não encontrado no PATH"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!("{name} retornou erro em --version");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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!("Host: {}", config.postgres_host);
|
||||||
|
println!("Banco: {}", config.postgres_database);
|
||||||
|
println!("Dump: {}", dump.display());
|
||||||
|
print!("Digite exatamente RESTAURAR para continuar: ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
Ok(input.trim() == "RESTAURAR")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore_dump<F>(config: &Config, dump_path: &Path, mut status: F) -> Result<()>
|
||||||
|
where
|
||||||
|
F: FnMut(&str),
|
||||||
|
{
|
||||||
|
if !dump_path.exists() {
|
||||||
|
bail!("arquivo não existe: {}", dump_path.display());
|
||||||
|
}
|
||||||
|
let name = dump_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !is_supported_dump(name) {
|
||||||
|
return Err(RestoreAssistantError::UnsupportedDumpFormat(name.to_string()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
status("Encerrando conexões ativas...");
|
||||||
|
terminate_connections(config).await?;
|
||||||
|
status("Apagando banco de dados...");
|
||||||
|
drop_database(config).await?;
|
||||||
|
status("Recriando banco de dados...");
|
||||||
|
create_database(config).await?;
|
||||||
|
status("Restaurando dump...");
|
||||||
|
restore_contents(config, dump_path).await?;
|
||||||
|
status("Restauração finalizada.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn terminate_connections(config: &Config) -> Result<()> {
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = {} AND pid <> pg_backend_pid();",
|
||||||
|
quote_literal(&config.postgres_database)
|
||||||
|
);
|
||||||
|
run_psql_admin(config, &["-v", "ON_ERROR_STOP=1", "-c", &sql]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drop_database(config: &Config) -> Result<()> {
|
||||||
|
let sql = format!(
|
||||||
|
"DROP DATABASE IF EXISTS {};",
|
||||||
|
quote_ident(&config.postgres_database)
|
||||||
|
);
|
||||||
|
run_psql_admin(config, &["-v", "ON_ERROR_STOP=1", "-c", &sql]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_database(config: &Config) -> Result<()> {
|
||||||
|
let sql = format!(
|
||||||
|
"CREATE DATABASE {};",
|
||||||
|
quote_ident(&config.postgres_database)
|
||||||
|
);
|
||||||
|
run_psql_admin(config, &["-v", "ON_ERROR_STOP=1", "-c", &sql]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_contents(config: &Config, dump_path: &Path) -> Result<()> {
|
||||||
|
let name = dump_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if name.ends_with(".sql.gz") {
|
||||||
|
restore_compressed_sql(config, dump_path, Compression::Gzip).await
|
||||||
|
} else if name.ends_with(".sql.zst") {
|
||||||
|
restore_compressed_sql(config, dump_path, Compression::Zstd).await
|
||||||
|
} else if name.ends_with(".sql") {
|
||||||
|
run_psql_target(
|
||||||
|
config,
|
||||||
|
&["-v", "ON_ERROR_STOP=1", "-f", path_str(dump_path)?],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else if is_compressed_pg_restore_archive(&name) && name.ends_with(".gz") {
|
||||||
|
restore_compressed_pg_restore_archive(config, dump_path, Compression::Gzip).await
|
||||||
|
} else if is_compressed_pg_restore_archive(&name) && name.ends_with(".zst") {
|
||||||
|
restore_compressed_pg_restore_archive(config, dump_path, Compression::Zstd).await
|
||||||
|
} else if is_pg_restore_archive(&name) {
|
||||||
|
run_pg_restore(config, dump_path).await
|
||||||
|
} else {
|
||||||
|
Err(RestoreAssistantError::UnsupportedDumpFormat(name).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_compressed_sql(
|
||||||
|
config: &Config,
|
||||||
|
dump_path: &Path,
|
||||||
|
compression: Compression,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut child = base_psql_command(config, &config.postgres_database)
|
||||||
|
.args(["-v", "ON_ERROR_STOP=1"])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("falha ao iniciar psql para restaurar SQL comprimido")?;
|
||||||
|
let stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.context("não foi possível abrir stdin do psql")?;
|
||||||
|
let source = dump_path.to_path_buf();
|
||||||
|
let writer =
|
||||||
|
tokio::spawn(
|
||||||
|
async move { stream_compressed_sql_to_stdin(source, compression, stdin).await },
|
||||||
|
);
|
||||||
|
let status = child.wait().await?;
|
||||||
|
writer.await??;
|
||||||
|
if !status.success() {
|
||||||
|
bail!("psql retornou erro ao restaurar SQL comprimido");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_compressed_sql_to_stdin(
|
||||||
|
source: PathBuf,
|
||||||
|
compression: Compression,
|
||||||
|
mut stdin: ChildStdin,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sql = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
|
||||||
|
let file = std::fs::File::open(&source)
|
||||||
|
.with_context(|| format!("falha ao abrir {}", source.display()))?;
|
||||||
|
let mut sql = Vec::new();
|
||||||
|
match compression {
|
||||||
|
Compression::Gzip => {
|
||||||
|
let mut decoder = GzDecoder::new(file);
|
||||||
|
std::io::copy(&mut decoder, &mut sql)?;
|
||||||
|
}
|
||||||
|
Compression::Zstd => {
|
||||||
|
let mut decoder = zstd::stream::read::Decoder::new(file)?;
|
||||||
|
std::io::copy(&mut decoder, &mut sql)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(sql)
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
write_chunks_to_stdin(sql.chunks(64 * 1024), &mut stdin).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_chunks_to_stdin(
|
||||||
|
chunks: std::slice::Chunks<'_, u8>,
|
||||||
|
stdin: &mut ChildStdin,
|
||||||
|
) -> Result<()> {
|
||||||
|
for chunk in chunks {
|
||||||
|
stdin.write_all(chunk).await?;
|
||||||
|
}
|
||||||
|
stdin.shutdown().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum Compression {
|
||||||
|
Gzip,
|
||||||
|
Zstd,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_compressed_pg_restore_archive(
|
||||||
|
config: &Config,
|
||||||
|
dump_path: &Path,
|
||||||
|
compression: Compression,
|
||||||
|
) -> Result<()> {
|
||||||
|
let source = dump_path.to_path_buf();
|
||||||
|
let temp_path = tokio::task::spawn_blocking(move || {
|
||||||
|
decompress_pg_restore_archive_to_temp(&source, compression)
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
let result = run_pg_restore(config, &temp_path).await;
|
||||||
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decompress_pg_restore_archive_to_temp(
|
||||||
|
source: &Path,
|
||||||
|
compression: Compression,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let file = std::fs::File::open(source)
|
||||||
|
.with_context(|| format!("falha ao abrir {}", source.display()))?;
|
||||||
|
let temp_path = std::env::temp_dir().join(format!(
|
||||||
|
"restore-assistant-{}-{}.tar",
|
||||||
|
std::process::id(),
|
||||||
|
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
|
||||||
|
));
|
||||||
|
let mut output = std::fs::File::create(&temp_path)
|
||||||
|
.with_context(|| format!("falha ao criar {}", temp_path.display()))?;
|
||||||
|
|
||||||
|
match compression {
|
||||||
|
Compression::Gzip => {
|
||||||
|
let mut decoder = GzDecoder::new(file);
|
||||||
|
std::io::copy(&mut decoder, &mut output)?;
|
||||||
|
}
|
||||||
|
Compression::Zstd => {
|
||||||
|
let mut decoder = zstd::stream::read::Decoder::new(file)?;
|
||||||
|
std::io::copy(&mut decoder, &mut output)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.flush()?;
|
||||||
|
Ok(temp_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_pg_restore(config: &Config, dump_path: &Path) -> Result<()> {
|
||||||
|
let status = base_pg_command(
|
||||||
|
Command::new("pg_restore"),
|
||||||
|
config,
|
||||||
|
&config.postgres_database,
|
||||||
|
)
|
||||||
|
.args(["--clean", "--if-exists", "--no-owner", "--dbname"])
|
||||||
|
.arg(connection_uri(config, &config.postgres_database))
|
||||||
|
.arg(dump_path)
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.context("falha ao executar pg_restore")?;
|
||||||
|
if !status.success() {
|
||||||
|
bail!("pg_restore retornou erro");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pg_restore_archive(name: &str) -> bool {
|
||||||
|
name.ends_with(".dump")
|
||||||
|
|| name.ends_with(".backup")
|
||||||
|
|| name.ends_with(".bin")
|
||||||
|
|| name.ends_with(".binary")
|
||||||
|
|| name.ends_with(".tar")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_compressed_pg_restore_archive(name: &str) -> bool {
|
||||||
|
[".dump", ".backup", ".bin", ".binary", ".tar"]
|
||||||
|
.iter()
|
||||||
|
.any(|base| name.ends_with(&format!("{base}.gz")) || name.ends_with(&format!("{base}.zst")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_psql_admin(config: &Config, args: &[&str]) -> Result<()> {
|
||||||
|
run_command(base_psql_command(config, "postgres").args(args), "psql").await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_psql_target(config: &Config, args: &[&str]) -> Result<()> {
|
||||||
|
run_command(
|
||||||
|
base_psql_command(config, &config.postgres_database).args(args),
|
||||||
|
"psql",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_command(command: &mut Command, label: &str) -> Result<()> {
|
||||||
|
debug!("executando comando externo: {label}");
|
||||||
|
let output = command
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("falha ao executar {label}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
bail!("{label} retornou erro: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_psql_command(config: &Config, database: &str) -> Command {
|
||||||
|
let mut command = base_pg_command(Command::new("psql"), config, database);
|
||||||
|
command.arg(connection_uri(config, database));
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_pg_command(mut command: Command, config: &Config, _database: &str) -> Command {
|
||||||
|
command.env("PGPASSWORD", &config.postgres_password);
|
||||||
|
command.env("PGSSLMODE", &config.postgres_sslmode);
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection_uri(config: &Config, database: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"postgresql://{}@{}:{}/{}?sslmode={}",
|
||||||
|
url_encode(&config.postgres_user),
|
||||||
|
config.postgres_host,
|
||||||
|
config.postgres_port,
|
||||||
|
url_encode(database),
|
||||||
|
url_encode(&config.postgres_sslmode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_str(path: &Path) -> Result<&str> {
|
||||||
|
path.to_str()
|
||||||
|
.with_context(|| format!("caminho não UTF-8: {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote_ident(value: &str) -> String {
|
||||||
|
format!("\"{}\"", value.replace('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote_literal(value: &str) -> String {
|
||||||
|
format!("'{}'", value.replace('\'', "''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_encode(value: &str) -> String {
|
||||||
|
let mut encoded = String::new();
|
||||||
|
for byte in value.bytes() {
|
||||||
|
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
||||||
|
encoded.push(byte as char);
|
||||||
|
} else {
|
||||||
|
encoded.push_str(&format!("%{byte:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
401
src/tui.rs
Normal file
401
src/tui.rs
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
use std::{io, path::PathBuf, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, Event, KeyCode, KeyEventKind},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
prelude::*,
|
||||||
|
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::Config,
|
||||||
|
dumps::{self, DumpEntry, DumpKind},
|
||||||
|
errors::RestoreAssistantError,
|
||||||
|
minio::MinioClient,
|
||||||
|
postgres_restore,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
Browsing,
|
||||||
|
Filtering,
|
||||||
|
Confirming(PathBuf),
|
||||||
|
ConfirmDelete(DumpEntry),
|
||||||
|
Working,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
config: Config,
|
||||||
|
minio: MinioClient,
|
||||||
|
dumps: Vec<DumpEntry>,
|
||||||
|
filtered: Vec<usize>,
|
||||||
|
selected: usize,
|
||||||
|
filter: String,
|
||||||
|
status: String,
|
||||||
|
mode: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(config: Config, minio: MinioClient) -> Result<()> {
|
||||||
|
let mut terminal = setup_terminal()?;
|
||||||
|
let mut app = App::new(config, minio).await?;
|
||||||
|
let result = app.run(&mut terminal).await;
|
||||||
|
restore_terminal(&mut terminal)?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
async fn new(config: Config, minio: MinioClient) -> Result<Self> {
|
||||||
|
let mut app = Self {
|
||||||
|
config,
|
||||||
|
minio,
|
||||||
|
dumps: Vec::new(),
|
||||||
|
filtered: Vec::new(),
|
||||||
|
selected: 0,
|
||||||
|
filter: String::new(),
|
||||||
|
status: "Carregando dumps...".to_string(),
|
||||||
|
mode: Mode::Browsing,
|
||||||
|
};
|
||||||
|
app.refresh().await;
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
|
if !event::poll(Duration::from_millis(120))? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Event::Key(key) = event::read()? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match &self.mode {
|
||||||
|
Mode::Browsing => match key.code {
|
||||||
|
KeyCode::Char('q') => break,
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => self.next(),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => self.previous(),
|
||||||
|
KeyCode::Char('/') => self.mode = Mode::Filtering,
|
||||||
|
KeyCode::Char('r') => self.refresh().await,
|
||||||
|
KeyCode::Char('d') => self.prepare_delete(),
|
||||||
|
KeyCode::Enter => self.prepare_restore().await,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Mode::Filtering => match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.filter.clear();
|
||||||
|
self.apply_filter();
|
||||||
|
self.mode = Mode::Browsing;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => self.mode = Mode::Browsing,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.filter.pop();
|
||||||
|
self.apply_filter();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.filter.push(c);
|
||||||
|
self.apply_filter();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Mode::Confirming(path) => match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('s') => {
|
||||||
|
let path = path.clone();
|
||||||
|
self.restore(path).await;
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('n') => {
|
||||||
|
self.status = "Restauração cancelada.".to_string();
|
||||||
|
self.mode = Mode::Browsing;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Mode::ConfirmDelete(dump) => match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('s') => {
|
||||||
|
let dump = dump.clone();
|
||||||
|
self.delete_local(dump).await;
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('n') => {
|
||||||
|
self.status = "Exclusão cancelada.".to_string();
|
||||||
|
self.mode = Mode::Browsing;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Mode::Working => {}
|
||||||
|
Mode::Error(_) => match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Enter => self.mode = Mode::Browsing,
|
||||||
|
KeyCode::Char('q') => break,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(&mut self) {
|
||||||
|
match dumps::collect_dumps(&self.config, &self.minio).await {
|
||||||
|
Ok(dumps) => {
|
||||||
|
self.dumps = dumps;
|
||||||
|
self.apply_filter();
|
||||||
|
self.status = format!("{} dump(s) encontrados.", self.dumps.len());
|
||||||
|
self.mode = Mode::Browsing;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("{err:?}");
|
||||||
|
self.mode = Mode::Error(format!("Falha ao listar dumps: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_filter(&mut self) {
|
||||||
|
let filter = self.filter.to_ascii_lowercase();
|
||||||
|
self.filtered = self
|
||||||
|
.dumps
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, dump)| dump.name.to_ascii_lowercase().contains(&filter))
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
.collect();
|
||||||
|
self.selected = self.selected.min(self.filtered.len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_dump(&self) -> Option<DumpEntry> {
|
||||||
|
self.filtered
|
||||||
|
.get(self.selected)
|
||||||
|
.and_then(|idx| self.dumps.get(*idx))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next(&mut self) {
|
||||||
|
if !self.filtered.is_empty() {
|
||||||
|
self.selected = (self.selected + 1).min(self.filtered.len() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous(&mut self) {
|
||||||
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_restore(&mut self) {
|
||||||
|
let Some(dump) = self.selected_dump() else {
|
||||||
|
self.status = "Nenhum dump selecionado.".to_string();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if matches!(dump.kind, DumpKind::RemoteAndLocal) {
|
||||||
|
self.status =
|
||||||
|
"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) => {
|
||||||
|
self.mode = Mode::Confirming(path);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("{err:?}");
|
||||||
|
self.mode = Mode::Error(format!("Falha ao preparar dump: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_delete(&mut self) {
|
||||||
|
let Some(dump) = self.selected_dump() else {
|
||||||
|
self.status = "Nenhum dump selecionado.".to_string();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if dump.local_path.is_none() {
|
||||||
|
self.status =
|
||||||
|
"Este dump existe apenas no MinIO; não há arquivo local para apagar.".to_string();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.mode = Mode::ConfirmDelete(dump);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_local(&mut self, dump: DumpEntry) {
|
||||||
|
match dumps::delete_local_dump(&dump) {
|
||||||
|
Ok(path) => {
|
||||||
|
self.status = format!("Arquivo local apagado: {}", path.display());
|
||||||
|
self.refresh().await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("{err:?}");
|
||||||
|
self.mode = Mode::Error(format!("Falha ao apagar arquivo local: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore(&mut self, path: PathBuf) {
|
||||||
|
self.mode = Mode::Working;
|
||||||
|
self.status = "Iniciando restauração...".to_string();
|
||||||
|
let result = postgres_restore::restore_dump(&self.config, &path, |message| {
|
||||||
|
info!("{message}");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
self.status = "Restauração concluída.".to_string();
|
||||||
|
self.mode = Mode::Browsing;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("{err:?}");
|
||||||
|
self.mode = Mode::Error(format!("Falha na restauração: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&self, frame: &mut Frame) {
|
||||||
|
let area = frame.area();
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let title = format!(
|
||||||
|
"Restore Assistant | destino: {}:{} / {}",
|
||||||
|
self.config.postgres_host, self.config.postgres_port, self.config.postgres_database
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(title).block(Block::default().borders(Borders::ALL)),
|
||||||
|
chunks[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = self
|
||||||
|
.filtered
|
||||||
|
.iter()
|
||||||
|
.filter_map(|idx| self.dumps.get(*idx))
|
||||||
|
.map(|dump| {
|
||||||
|
let marker = match dump.kind {
|
||||||
|
DumpKind::Local => "[local]",
|
||||||
|
DumpKind::Remote => "[minio]",
|
||||||
|
DumpKind::RemoteAndLocal => "[minio+local]",
|
||||||
|
};
|
||||||
|
let size = dump
|
||||||
|
.size
|
||||||
|
.map(bytesize::ByteSize::b)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
let style = match dump.kind {
|
||||||
|
DumpKind::Local => Style::default().fg(Color::Green),
|
||||||
|
DumpKind::Remote => Style::default().fg(Color::Cyan),
|
||||||
|
DumpKind::RemoteAndLocal => Style::default().fg(Color::Yellow),
|
||||||
|
};
|
||||||
|
ListItem::new(format!("{marker:<13} {size:>12} {}", dump.name)).style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut state = ListState::default();
|
||||||
|
if !items.is_empty() {
|
||||||
|
state.select(Some(self.selected));
|
||||||
|
}
|
||||||
|
frame.render_stateful_widget(
|
||||||
|
List::new(items)
|
||||||
|
.block(Block::default().title("Dumps").borders(Borders::ALL))
|
||||||
|
.highlight_symbol("> ")
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
chunks[1],
|
||||||
|
&mut state,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mode_text = match self.mode {
|
||||||
|
Mode::Filtering => format!("Filtro: {}", self.filter),
|
||||||
|
_ => format!(
|
||||||
|
"{} | / filtrar | r atualizar | d apagar local | Enter restaurar | q sair",
|
||||||
|
self.status
|
||||||
|
),
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(mode_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
chunks[2],
|
||||||
|
);
|
||||||
|
|
||||||
|
match &self.mode {
|
||||||
|
Mode::Confirming(path) => self.draw_confirm(frame, area, path),
|
||||||
|
Mode::ConfirmDelete(dump) => self.draw_delete_confirm(frame, area, dump),
|
||||||
|
Mode::Working => self.draw_popup(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
"Restaurando",
|
||||||
|
"Aguarde. O banco está sendo apagado, recriado e restaurado.",
|
||||||
|
),
|
||||||
|
Mode::Error(message) => self.draw_popup(frame, area, "Erro", message),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_confirm(&self, frame: &mut Frame, area: Rect, path: &PathBuf) {
|
||||||
|
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.",
|
||||||
|
self.config.postgres_host,
|
||||||
|
self.config.postgres_database,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
self.draw_popup(frame, area, "Confirmar restauração", &text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_delete_confirm(&self, frame: &mut Frame, area: Rect, dump: &DumpEntry) {
|
||||||
|
let path = dump
|
||||||
|
.local_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
let text = format!(
|
||||||
|
"Esta operação apagará apenas o arquivo local.\n\nDump: {}\nArquivo: {}\n\nO objeto no MinIO não será removido.\n\nPressione y/s para confirmar ou Esc/n para cancelar.",
|
||||||
|
dump.name,
|
||||||
|
path
|
||||||
|
);
|
||||||
|
self.draw_popup(frame, area, "Apagar arquivo local", &text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_popup(&self, frame: &mut Frame, area: Rect, title: &str, text: &str) {
|
||||||
|
let popup = centered_rect(70, 45, area);
|
||||||
|
frame.render_widget(Clear, popup);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(text.to_string())
|
||||||
|
.block(Block::default().title(title).borders(Borders::ALL))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
popup,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||||
|
enable_raw_mode().context("falha ao habilitar modo raw")?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
Terminal::new(CrosstermBackend::new(stdout)).context("falha ao iniciar terminal")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||||
|
disable_raw_mode().context("falha ao desabilitar modo raw")?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||||
|
let vertical = Layout::vertical([
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
Constraint::Percentage(percent_y),
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
])
|
||||||
|
.split(r);
|
||||||
|
let horizontal = Layout::horizontal([
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
Constraint::Percentage(percent_x),
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
])
|
||||||
|
.split(vertical[1]);
|
||||||
|
horizontal[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RestoreAssistantError> for String {
|
||||||
|
fn from(value: RestoreAssistantError) -> Self {
|
||||||
|
value.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user