Adiciona publicacao Gitea e progresso na restauracao

This commit is contained in:
Rogério de Lima Sousa 2026-06-23 11:42:16 -03:00
parent 2f4c25979d
commit 6afe610f4c
9 changed files with 563 additions and 72 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
/target /target
/dist
/dumps /dumps
/.env /.env
/restore-assistant.log /restore-assistant.log

View File

@ -4,14 +4,45 @@ Assistente CLI/TUI em Rust para listar dumps em MinIO/S3, baixar arquivos para u
## Requisitos ## Requisitos
- Rust stable
- Ferramentas PostgreSQL no `PATH`: - Ferramentas PostgreSQL no `PATH`:
- `psql` - `psql`
- `pg_restore` - `pg_restore`
- Acesso ao bucket MinIO/S3 com API compatível com S3 - Acesso ao bucket MinIO/S3 com API compatível com S3
- Rust stable, apenas para compilar a partir do código-fonte
## Instalação ## Instalação
### A partir do pacote do Gitea
Baixe a versão publicada no Generic Package Registry, extraia e instale no `PATH`:
```bash
VERSION=0.1.0
ARCHIVE="restoredb-x86_64-unknown-linux-gnu.tar.gz"
curl -L -o "$ARCHIVE" \
"https://gitea.solucaoti.net.br/api/packages/rogeriolima/generic/restoredb/$VERSION/$ARCHIVE"
tar -xzf "$ARCHIVE"
sudo install -m 0755 restoredb /usr/local/bin/restoredb
```
Confira:
```bash
restoredb --help
```
Se o pacote privado exigir autenticação, use um token do Gitea:
```bash
GITEA_TOKEN=seu_token
curl -L -u "rogeriolima:$GITEA_TOKEN" -o "$ARCHIVE" \
"https://gitea.solucaoti.net.br/api/packages/rogeriolima/generic/restoredb/$VERSION/$ARCHIVE"
```
### Compilando localmente
```bash ```bash
cargo build --release cargo build --release
``` ```
@ -22,6 +53,24 @@ O binário ficará em:
target/release/restoredb target/release/restoredb
``` ```
## Publicação no Gitea
Para empacotar o executável release e publicar no Generic Package Registry do Gitea:
```bash
GITEA_TOKEN=seu_token scripts/publish-gitea-package.sh
```
Por padrão, o script publica `restoredb` na versão do `Cargo.toml` em `https://gitea.solucaoti.net.br/api/packages/rogeriolima/generic/restoredb/<versao>/`.
Opções úteis:
```bash
scripts/publish-gitea-package.sh --dry-run
scripts/publish-gitea-package.sh --version 0.1.1
scripts/publish-gitea-package.sh --no-build
```
## Configuração ## Configuração
Na primeira execução, se nenhuma configuração completa existir, o programa pergunta os campos um a um, no estilo do `rclone`, e salva em `config.yml` na mesma pasta do executável. Na primeira execução, se nenhuma configuração completa existir, o programa pergunta os campos um a um, no estilo do `rclone`, e salva em `config.yml` na mesma pasta do executável.
@ -138,6 +187,8 @@ restoredb clean --older-than-days 30
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. 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.
Durante download e restauração, CLI e TUI exibem barra de progresso. Se a restauração falhar com `ERROR: role "<usuario>" does not exist`, o assistente cria a role PostgreSQL com `LOGIN`, recria o banco de destino e tenta restaurar o dump novamente uma vez.
## Formatos suportados ## Formatos suportados
- `.sql`: restaurado com `psql` - `.sql`: restaurado com `psql`

View File

@ -8,3 +8,31 @@
2026-06-23T14:24:12.941459Z INFO restoredb::tui: Recriando 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.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 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
2026-06-23T14:28:58.303056Z INFO restoredb::tui: Apagando banco de dados...
2026-06-23T14:28:58.538504Z INFO restoredb::tui: Recriando banco de dados...
2026-06-23T14:28:58.593070Z INFO restoredb::tui: Restaurando dump...
2026-06-23T14:29:01.597279Z INFO restoredb::tui: Restauração finalizada.
2026-06-23T14:29:18.910337Z INFO restoredb::minio: baixando dump remoto: selecaouab/selecaouab_20260623_050003.sql
2026-06-23T14:29:37.688056Z INFO restoredb::tui: Apagando banco de dados...
2026-06-23T14:29:37.722825Z INFO restoredb::tui: Recriando banco de dados...
2026-06-23T14:29:38.021376Z INFO restoredb::tui: Restaurando dump...
2026-06-23T14:29:57.025219Z INFO restoredb::tui: Restauração finalizada.
2026-06-23T14:30:23.679378Z INFO restoredb::minio: baixando dump remoto: selecaouab/selecaouab_20260623_050003.sql
2026-06-23T14:31:01.856318Z INFO restoredb::minio: baixando dump remoto: heteroidentificacao/heteroidentificacao_20260623_050003.sql
2026-06-23T14:31:09.446320Z INFO restoredb::tui: Apagando banco de dados...
2026-06-23T14:31:09.773361Z INFO restoredb::tui: Recriando banco de dados...
2026-06-23T14:31:09.874011Z INFO restoredb::tui: Restaurando dump...
2026-06-23T14:31:13.496503Z INFO restoredb::tui: Restauração finalizada.
2026-06-23T14:32:34.465550Z INFO restoredb::minio: baixando dump remoto: seletivos/seletivos_20260623_050003.sql
2026-06-23T14:33:22.550123Z INFO restoredb::tui: Apagando banco de dados...
2026-06-23T14:33:23.067803Z INFO restoredb::tui: Recriando banco de dados...
2026-06-23T14:33:23.183970Z INFO restoredb::tui: Restaurando dump...
2026-06-23T14:33:44.828478Z INFO restoredb::tui: Restauração finalizada.
2026-06-23T14:36:10.631441Z INFO restoredb::minio: baixando dump remoto: matricula/matricula_20260623_045855.sql
2026-06-23T14:36:20.736091Z INFO restoredb::tui: Apagando banco de dados...
2026-06-23T14:36:21.235459Z INFO restoredb::tui: Recriando banco de dados...
2026-06-23T14:36:21.347228Z INFO restoredb::tui: Restaurando dump...
2026-06-23T14:36:21.392003Z INFO restoredb::tui: Criando usuário PostgreSQL ausente: dsi...
2026-06-23T14:36:21.460034Z INFO restoredb::tui: Recriando banco de dados para nova tentativa...
2026-06-23T14:36:21.574834Z INFO restoredb::tui: Restaurando dump novamente...
2026-06-23T14:36:29.895035Z INFO restoredb::tui: Restauração finalizada.

125
scripts/publish-gitea-package.sh Executable file
View File

@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -euo pipefail
GITEA_BASE_URL="${GITEA_BASE_URL:-https://gitea.solucaoti.net.br}"
GITEA_USER="${GITEA_USER:-rogeriolima}"
PACKAGE_OWNER="${PACKAGE_OWNER:-$GITEA_USER}"
PACKAGE_NAME="${PACKAGE_NAME:-restoredb}"
BINARY_NAME="${BINARY_NAME:-restoredb}"
BUILD=1
DRY_RUN=0
VERSION=""
usage() {
cat <<'EOF'
Publica o executavel release no Generic Package Registry do Gitea.
Uso:
GITEA_TOKEN=token scripts/publish-gitea-package.sh [opcoes]
Opcoes:
--version VERSION Versao do pacote. Padrao: versao do Cargo.toml.
--no-build Nao executa cargo build --release antes de empacotar.
--dry-run Mostra os comandos principais sem enviar o pacote.
-h, --help Mostra esta ajuda.
Variaveis:
GITEA_BASE_URL Padrao: https://gitea.solucaoti.net.br
GITEA_USER Padrao: rogeriolima
GITEA_TOKEN Token com permissao para publicar pacotes.
PACKAGE_OWNER Padrao: igual a GITEA_USER
PACKAGE_NAME Padrao: restoredb
BINARY_NAME Padrao: restoredb
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
VERSION="${2:-}"
if [[ -z "$VERSION" ]]; then
echo "Erro: --version exige um valor." >&2
exit 2
fi
shift 2
;;
--no-build)
BUILD=0
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Erro: opcao desconhecida: $1" >&2
usage >&2
exit 2
;;
esac
done
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Erro: comando obrigatorio nao encontrado: $1" >&2
exit 1
fi
}
require_cmd cargo
require_cmd curl
require_cmd tar
require_cmd rustc
if [[ -z "$VERSION" ]]; then
VERSION="$(cargo metadata --no-deps --format-version 1 | sed -n 's/.*"version":"\([^"]*\)".*/\1/p' | head -n 1)"
fi
if [[ -z "$VERSION" ]]; then
echo "Erro: nao foi possivel descobrir a versao do Cargo.toml." >&2
exit 1
fi
if [[ "$DRY_RUN" -eq 0 && -z "${GITEA_TOKEN:-}" ]]; then
echo "Erro: defina GITEA_TOKEN com um token do Gitea para publicar." >&2
exit 1
fi
HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')"
DIST_DIR="dist"
PACKAGE_FILE="${BINARY_NAME}-${HOST_TRIPLE}.tar.gz"
PACKAGE_PATH="${DIST_DIR}/${PACKAGE_FILE}"
UPLOAD_URL="${GITEA_BASE_URL%/}/api/packages/${PACKAGE_OWNER}/generic/${PACKAGE_NAME}/${VERSION}/${PACKAGE_FILE}"
if [[ "$BUILD" -eq 1 ]]; then
echo "Compilando release..."
cargo build --release
fi
if [[ ! -x "target/release/${BINARY_NAME}" ]]; then
echo "Erro: binario nao encontrado ou nao executavel: target/release/${BINARY_NAME}" >&2
exit 1
fi
mkdir -p "$DIST_DIR"
tar -C target/release -czf "$PACKAGE_PATH" "$BINARY_NAME"
echo "Pacote: $PACKAGE_PATH"
echo "URL: $UPLOAD_URL"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Dry-run: upload nao executado."
exit 0
fi
curl --fail --show-error \
-u "${GITEA_USER}:${GITEA_TOKEN}" \
--upload-file "$PACKAGE_PATH" \
"$UPLOAD_URL"
echo
echo "Publicado: $UPLOAD_URL"

View File

@ -197,12 +197,16 @@ mod tests {
} }
} }
pub async fn materialize_dump( pub async fn materialize_dump_with_progress<F>(
config: &Config, config: &Config,
minio: &MinioClient, minio: &MinioClient,
dump: &DumpEntry, dump: &DumpEntry,
overwrite: bool, overwrite: bool,
) -> Result<PathBuf> { progress: F,
) -> Result<PathBuf>
where
F: FnMut(u64),
{
if let Some(path) = &dump.local_path { if let Some(path) = &dump.local_path {
if path.exists() && !overwrite { if path.exists() && !overwrite {
return Ok(path.clone()); return Ok(path.clone());
@ -224,7 +228,7 @@ pub async fn materialize_dump(
return Ok(destination); return Ok(destination);
} }
minio minio
.download_dump(key, &destination, |_| {}) .download_dump(key, &destination, progress)
.await .await
.with_context(|| format!("falha ao baixar {}", dump.name))?; .with_context(|| format!("falha ao baixar {}", dump.name))?;
Ok(destination) Ok(destination)

View File

@ -15,7 +15,7 @@ use tracing::{error, info};
use crate::config::Config; use crate::config::Config;
use crate::dumps::{collect_dumps, ensure_dumps_dir, find_dump_by_name, suggest_database_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, RestoreProgress};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "restoredb")] #[command(name = "restoredb")]
@ -160,7 +160,16 @@ async fn command_restore(
println!("Aviso: existe cópia local com tamanho diferente do remoto."); println!("Aviso: existe cópia local com tamanho diferente do remoto.");
println!("A cópia local será usada. Apague o arquivo local para baixar novamente."); 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 download_total = dump.size;
let mut downloaded = 0;
let local_path = dumps::materialize_dump_with_progress(config, minio, &dump, false, |bytes| {
downloaded = bytes;
render_byte_progress("Baixando dump", bytes, download_total);
})
.await?;
if downloaded > 0 {
println!();
}
let target_database = prompt_target_database(&dump.name)?; let target_database = prompt_target_database(&dump.name)?;
let restore_config = config.with_postgres_database(target_database); let restore_config = config.with_postgres_database(target_database);
@ -192,11 +201,12 @@ async fn command_restore(
&restore_config, &restore_config,
&local_path, &local_path,
terminate_connections, terminate_connections,
|message| { |progress| {
println!("{message}"); render_restore_progress(&progress);
}, },
) )
.await; .await;
println!();
if let Err(err) = if let Err(err) =
history::record_restore(&restore_config, &local_path, result.as_ref().err()).await history::record_restore(&restore_config, &local_path, result.as_ref().err()).await
{ {
@ -282,3 +292,40 @@ fn prompt_target_database(dump_name: &str) -> Result<String> {
println!("Banco de destino não pode ficar vazio."); println!("Banco de destino não pode ficar vazio.");
} }
} }
fn render_restore_progress(progress: &RestoreProgress) {
render_progress(
&progress.message,
progress.current,
progress.total.max(progress.current),
);
}
fn render_byte_progress(label: &str, current: u64, total: Option<u64>) {
match total.filter(|total| *total > 0) {
Some(total) => render_progress(label, current.min(total), total),
None => {
print!("\r{label}: {}", bytesize::ByteSize::b(current));
let _ = std::io::stdout().flush();
}
}
}
fn render_progress(label: &str, current: u64, total: u64) {
let width = 24usize;
let filled = if total == 0 {
width
} else {
((current as f64 / total as f64) * width as f64).round() as usize
}
.min(width);
let bar = format!("{}{}", "#".repeat(filled), "-".repeat(width - filled));
let percent = if total == 0 {
100
} else {
((current as f64 / total as f64) * 100.0).round() as u64
}
.min(100);
print!("\r[{bar}] {percent:>3}% {label}");
let _ = std::io::stdout().flush();
}

View File

@ -119,19 +119,25 @@ impl MinioClient {
.await .await
.with_context(|| format!("falha ao baixar objeto {key}"))?; .with_context(|| format!("falha ao baixar objeto {key}"))?;
let bytes = collect_body(response.body).await?;
if let Some(parent) = destination.parent() { if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent).await?; tokio::fs::create_dir_all(parent).await?;
} }
let mut file = File::create(destination).await?; let mut file = File::create(destination).await?;
file.write_all(&bytes).await?; write_body(response.body, &mut file, &mut progress).await?;
file.flush().await?; file.flush().await?;
progress(bytes.len() as u64);
Ok(()) Ok(())
} }
} }
async fn collect_body(body: ByteStream) -> Result<Vec<u8>> { async fn write_body<F>(mut body: ByteStream, file: &mut File, progress: &mut F) -> Result<()>
let data = body.collect().await.context("falha ao ler stream S3")?; where
Ok(data.into_bytes().to_vec()) F: FnMut(u64),
{
let mut downloaded = 0;
while let Some(bytes) = body.try_next().await.context("falha ao ler stream S3")? {
file.write_all(&bytes).await?;
downloaded += bytes.len() as u64;
progress(downloaded);
}
Ok(())
} }

View File

@ -15,6 +15,13 @@ use tracing::debug;
use crate::{config::Config, dumps::is_supported_dump, errors::RestoreAssistantError}; use crate::{config::Config, dumps::is_supported_dump, errors::RestoreAssistantError};
#[derive(Debug, Clone)]
pub struct RestoreProgress {
pub current: u64,
pub total: u64,
pub message: String,
}
pub async fn check_tools() -> Result<()> { pub async fn check_tools() -> Result<()> {
check_tool("psql").await?; check_tool("psql").await?;
check_tool("pg_restore").await?; check_tool("pg_restore").await?;
@ -79,7 +86,7 @@ pub async fn restore_dump<F>(
mut status: F, mut status: F,
) -> Result<()> ) -> Result<()>
where where
F: FnMut(&str), F: FnMut(RestoreProgress),
{ {
if !dump_path.exists() { if !dump_path.exists() {
bail!("arquivo não existe: {}", dump_path.display()); bail!("arquivo não existe: {}", dump_path.display());
@ -92,20 +99,71 @@ where
return Err(RestoreAssistantError::UnsupportedDumpFormat(name.to_string()).into()); return Err(RestoreAssistantError::UnsupportedDumpFormat(name.to_string()).into());
} }
let mut current = 0;
let mut total = if terminate_connections { 5 } else { 4 };
if terminate_connections { if terminate_connections {
status("Encerrando conexões ativas..."); current += 1;
emit_progress(&mut status, current, total, "Encerrando conexões ativas...");
terminate_active_connections(config).await?; terminate_active_connections(config).await?;
} }
status("Apagando banco de dados..."); current += 1;
emit_progress(&mut status, current, total, "Apagando banco de dados...");
drop_database(config).await?; drop_database(config).await?;
status("Recriando banco de dados..."); current += 1;
emit_progress(&mut status, current, total, "Recriando banco de dados...");
create_database(config).await?; create_database(config).await?;
status("Restaurando dump...");
restore_contents(config, dump_path).await?; current += 1;
status("Restauração finalizada."); emit_progress(&mut status, current, total, "Restaurando dump...");
match restore_contents(config, dump_path).await {
Ok(()) => {}
Err(err) => {
let Some(role) = missing_role_name(&err.to_string()) else {
return Err(err);
};
total += 3;
current += 1;
emit_progress(
&mut status,
current,
total,
&format!("Criando usuário PostgreSQL ausente: {role}..."),
);
create_role_if_missing(config, &role).await?;
current += 1;
emit_progress(
&mut status,
current,
total,
"Recriando banco de dados para nova tentativa...",
);
drop_database(config).await?;
create_database(config).await?;
current += 1;
emit_progress(&mut status, current, total, "Restaurando dump novamente...");
restore_contents(config, dump_path).await?;
}
}
emit_progress(&mut status, total, total, "Restauração finalizada.");
Ok(()) Ok(())
} }
fn emit_progress<F>(status: &mut F, current: u64, total: u64, message: &str)
where
F: FnMut(RestoreProgress),
{
status(RestoreProgress {
current,
total,
message: message.to_string(),
});
}
async fn terminate_active_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();",
@ -130,6 +188,24 @@ async fn create_database(config: &Config) -> Result<()> {
run_psql_admin(config, &["-v", "ON_ERROR_STOP=1", "-c", &sql]).await run_psql_admin(config, &["-v", "ON_ERROR_STOP=1", "-c", &sql]).await
} }
async fn create_role_if_missing(config: &Config, role: &str) -> Result<()> {
let exists_sql = format!(
"SELECT 1 FROM pg_roles WHERE rolname = {};",
quote_literal(role)
);
let exists = run_psql_admin_output(
config,
&["-t", "-A", "-v", "ON_ERROR_STOP=1", "-c", &exists_sql],
)
.await?;
if exists.trim() == "1" {
return Ok(());
}
let create_sql = format!("CREATE ROLE {} LOGIN;", quote_ident(role));
run_psql_admin(config, &["-v", "ON_ERROR_STOP=1", "-c", &create_sql]).await
}
async fn restore_contents(config: &Config, dump_path: &Path) -> Result<()> { async fn restore_contents(config: &Config, dump_path: &Path) -> Result<()> {
let name = dump_path let name = dump_path
.file_name() .file_name()
@ -165,6 +241,7 @@ async fn restore_compressed_sql(
let mut child = base_psql_command(config, &config.postgres_database) let mut child = base_psql_command(config, &config.postgres_database)
.args(["-v", "ON_ERROR_STOP=1"]) .args(["-v", "ON_ERROR_STOP=1"])
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn() .spawn()
.context("falha ao iniciar psql para restaurar SQL comprimido")?; .context("falha ao iniciar psql para restaurar SQL comprimido")?;
let stdin = child let stdin = child
@ -176,10 +253,14 @@ async fn restore_compressed_sql(
tokio::spawn( tokio::spawn(
async move { stream_compressed_sql_to_stdin(source, compression, stdin).await }, async move { stream_compressed_sql_to_stdin(source, compression, stdin).await },
); );
let status = child.wait().await?; let output = child.wait_with_output().await?;
writer.await??; writer.await??;
if !status.success() { if !output.status.success() {
bail!("psql retornou erro ao restaurar SQL comprimido"); let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"psql retornou erro ao restaurar SQL comprimido: {}",
stderr.trim()
);
} }
Ok(()) Ok(())
} }
@ -270,7 +351,7 @@ fn decompress_pg_restore_archive_to_temp(
} }
async fn run_pg_restore(config: &Config, dump_path: &Path) -> Result<()> { async fn run_pg_restore(config: &Config, dump_path: &Path) -> Result<()> {
let status = base_pg_command( let output = base_pg_command(
Command::new("pg_restore"), Command::new("pg_restore"),
config, config,
&config.postgres_database, &config.postgres_database,
@ -278,11 +359,12 @@ async fn run_pg_restore(config: &Config, dump_path: &Path) -> Result<()> {
.args(["--clean", "--if-exists", "--no-owner", "--dbname"]) .args(["--clean", "--if-exists", "--no-owner", "--dbname"])
.arg(connection_uri(config, &config.postgres_database)) .arg(connection_uri(config, &config.postgres_database))
.arg(dump_path) .arg(dump_path)
.status() .output()
.await .await
.context("falha ao executar pg_restore")?; .context("falha ao executar pg_restore")?;
if !status.success() { if !output.status.success() {
bail!("pg_restore retornou erro"); let stderr = String::from_utf8_lossy(&output.stderr);
bail!("pg_restore retornou erro: {}", stderr.trim());
} }
Ok(()) Ok(())
} }
@ -381,3 +463,34 @@ fn url_encode(value: &str) -> String {
} }
encoded encoded
} }
fn missing_role_name(message: &str) -> Option<String> {
let marker = "role \"";
let start = message.find(marker)? + marker.len();
let rest = &message[start..];
let end = rest.find("\" does not exist")?;
let role = &rest[..end];
if role.is_empty() {
None
} else {
Some(role.to_string())
}
}
#[cfg(test)]
mod tests {
use super::missing_role_name;
#[test]
fn extracts_missing_role_name_from_postgres_error() {
assert_eq!(
missing_role_name("ERROR: role \"dsi\" does not exist"),
Some("dsi".to_string())
);
assert_eq!(
missing_role_name("psql:file.sql:47: ERROR: role \"app_user\" does not exist"),
Some("app_user".to_string())
);
assert_eq!(missing_role_name("ERROR: database does not exist"), None);
}
}

View File

@ -8,8 +8,9 @@ use crossterm::{
}; };
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Wrap},
}; };
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::{
@ -38,10 +39,27 @@ enum Mode {
count: i64, count: i64,
}, },
ConfirmDelete(DumpEntry), ConfirmDelete(DumpEntry),
Working, Working {
current: u64,
total: u64,
message: String,
},
Error(String), Error(String),
} }
enum WorkerEvent {
Progress {
current: u64,
total: u64,
message: String,
},
DumpReady {
path: PathBuf,
dump_name: String,
},
Finished(Result<(), String>),
}
struct App { struct App {
config: Config, config: Config,
minio: MinioClient, minio: MinioClient,
@ -51,6 +69,7 @@ struct App {
filter: String, filter: String,
status: String, status: String,
mode: Mode, mode: Mode,
worker_rx: Option<UnboundedReceiver<WorkerEvent>>,
} }
pub async fn run(config: Config, minio: MinioClient) -> Result<()> { pub async fn run(config: Config, minio: MinioClient) -> Result<()> {
@ -72,6 +91,7 @@ impl App {
filter: String::new(), filter: String::new(),
status: "Carregando dumps...".to_string(), status: "Carregando dumps...".to_string(),
mode: Mode::Browsing, mode: Mode::Browsing,
worker_rx: None,
}; };
app.refresh().await; app.refresh().await;
Ok(app) Ok(app)
@ -79,6 +99,7 @@ impl App {
async fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> { async fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
loop { loop {
self.poll_worker_events();
terminal.draw(|frame| self.draw(frame))?; terminal.draw(|frame| self.draw(frame))?;
if !event::poll(Duration::from_millis(120))? { if !event::poll(Duration::from_millis(120))? {
continue; continue;
@ -181,7 +202,7 @@ impl App {
} }
_ => {} _ => {}
}, },
Mode::Working => {} Mode::Working { .. } => {}
Mode::Error(_) => match key.code { Mode::Error(_) => match key.code {
KeyCode::Esc | KeyCode::Enter => self.mode = Mode::Browsing, KeyCode::Esc | KeyCode::Enter => self.mode = Mode::Browsing,
KeyCode::Char('q') => break, KeyCode::Char('q') => break,
@ -248,18 +269,39 @@ impl App {
"Usando cópia local existente. Remova o arquivo para baixar novamente.".to_string() "Usando cópia local existente. Remova o arquivo para baixar novamente.".to_string()
}; };
} }
match dumps::materialize_dump(&self.config, &self.minio, &dump, false).await { let config = self.config.clone();
Ok(path) => { let minio = self.minio.clone();
self.mode = Mode::EditingDatabase { let total = dump.size.unwrap_or(1);
path, let (tx, rx) = mpsc::unbounded_channel();
input: suggest_database_name(&dump.name), self.worker_rx = Some(rx);
}; self.mode = Mode::Working {
current: 0,
total,
message: "Preparando dump...".to_string(),
};
tokio::spawn(async move {
let dump_name = dump.name.clone();
let result =
dumps::materialize_dump_with_progress(&config, &minio, &dump, false, |bytes| {
let _ = tx.send(WorkerEvent::Progress {
current: bytes,
total: total.max(bytes).max(1),
message: "Baixando dump...".to_string(),
});
})
.await;
match result {
Ok(path) => {
let _ = tx.send(WorkerEvent::DumpReady { path, dump_name });
}
Err(err) => {
error!("{err:?}");
let _ = tx.send(WorkerEvent::Finished(Err(format!(
"Falha ao preparar dump: {err}"
))));
}
} }
Err(err) => { });
error!("{err:?}");
self.mode = Mode::Error(format!("Falha ao preparar dump: {err}"));
}
}
} }
fn prepare_delete(&mut self) { fn prepare_delete(&mut self) {
@ -307,32 +349,79 @@ impl App {
} }
async fn restore(&mut self, path: PathBuf, database: String, terminate_connections: bool) { async fn restore(&mut self, path: PathBuf, database: String, terminate_connections: bool) {
self.mode = Mode::Working;
self.status = "Iniciando restauração...".to_string();
let restore_config = self.config.with_postgres_database(database); let restore_config = self.config.with_postgres_database(database);
let result = postgres_restore::restore_dump( let (tx, rx) = mpsc::unbounded_channel();
&restore_config, self.worker_rx = Some(rx);
&path, self.mode = Mode::Working {
terminate_connections, current: 0,
|message| { total: if terminate_connections { 5 } else { 4 },
info!("{message}"); message: "Iniciando restauração...".to_string(),
}, };
) tokio::spawn(async move {
.await; let result = postgres_restore::restore_dump(
if let Err(err) = &restore_config,
history::record_restore(&restore_config, &path, result.as_ref().err()).await &path,
{ terminate_connections,
error!("{err:?}"); |progress| {
} info!("{}", progress.message);
match result { let _ = tx.send(WorkerEvent::Progress {
Ok(()) => { current: progress.current,
self.status = "Restauração concluída.".to_string(); total: progress.total.max(progress.current).max(1),
self.mode = Mode::Browsing; message: progress.message,
} });
Err(err) => { },
)
.await;
if let Err(err) =
history::record_restore(&restore_config, &path, result.as_ref().err()).await
{
error!("{err:?}"); error!("{err:?}");
self.mode = Mode::Error(format!("Falha na restauração: {err}"));
} }
let _ = tx.send(WorkerEvent::Finished(
result.map_err(|err| format!("Falha na restauração: {err}")),
));
});
}
fn poll_worker_events(&mut self) {
let mut clear_worker = false;
if let Some(rx) = &mut self.worker_rx {
while let Ok(event) = rx.try_recv() {
match event {
WorkerEvent::Progress {
current,
total,
message,
} => {
self.status = message.clone();
self.mode = Mode::Working {
current,
total,
message,
};
}
WorkerEvent::DumpReady { path, dump_name } => {
self.status = "Dump preparado.".to_string();
self.mode = Mode::EditingDatabase {
path,
input: suggest_database_name(&dump_name),
};
clear_worker = true;
}
WorkerEvent::Finished(Ok(())) => {
self.status = "Restauração concluída.".to_string();
self.mode = Mode::Browsing;
clear_worker = true;
}
WorkerEvent::Finished(Err(message)) => {
self.mode = Mode::Error(message);
clear_worker = true;
}
}
}
}
if clear_worker {
self.worker_rx = None;
} }
} }
@ -346,7 +435,7 @@ impl App {
.split(area); .split(area);
let title = format!( let title = format!(
"restoredb | PostgreSQL: {}:{}", "restoredb | PostgreSQL: {}:{} | Usar apenas em ambiente de desenvolvimento - ®IFPI-DTI-DSI",
self.config.postgres_host, self.config.postgres_port self.config.postgres_host, self.config.postgres_port
); );
frame.render_widget( frame.render_widget(
@ -415,12 +504,11 @@ impl App {
count, count,
} => self.draw_terminate_confirm(frame, area, 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 {
frame, current,
area, total,
"Restaurando", message,
"Aguarde. O banco está sendo apagado, recriado e restaurado.", } => self.draw_progress(frame, area, *current, *total, message),
),
Mode::Error(message) => self.draw_popup(frame, area, "Erro", message), Mode::Error(message) => self.draw_popup(frame, area, "Erro", message),
_ => {} _ => {}
} }
@ -435,6 +523,34 @@ impl App {
self.draw_popup(frame, area, "Banco de destino", &text); self.draw_popup(frame, area, "Banco de destino", &text);
} }
fn draw_progress(
&self,
frame: &mut Frame,
area: Rect,
current: u64,
total: u64,
message: &str,
) {
let popup = centered_rect(70, 25, area);
let percent = if total == 0 {
1.0
} else {
(current as f64 / total as f64).clamp(0.0, 1.0)
};
frame.render_widget(Clear, popup);
frame.render_widget(
Gauge::default()
.block(
Block::default()
.title(message.to_string())
.borders(Borders::ALL),
)
.gauge_style(Style::default().fg(Color::Green))
.ratio(percent),
popup,
);
}
fn draw_confirm(&self, frame: &mut Frame, area: Rect, path: &PathBuf, database: &str) { 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.",