Adiciona publicacao Gitea e progresso na restauracao
This commit is contained in:
parent
2f4c25979d
commit
6afe610f4c
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
/target
|
||||
/dist
|
||||
/dumps
|
||||
/.env
|
||||
/restore-assistant.log
|
||||
|
||||
53
README.md
53
README.md
@ -4,14 +4,45 @@ Assistente CLI/TUI em Rust para listar dumps em MinIO/S3, baixar arquivos para u
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Rust stable
|
||||
- Ferramentas PostgreSQL no `PATH`:
|
||||
- `psql`
|
||||
- `pg_restore`
|
||||
- Acesso ao bucket MinIO/S3 com API compatível com S3
|
||||
- Rust stable, apenas para compilar a partir do código-fonte
|
||||
|
||||
## 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
|
||||
cargo build --release
|
||||
```
|
||||
@ -22,6 +53,24 @@ O binário ficará em:
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
- `.sql`: restaurado com `psql`
|
||||
|
||||
@ -8,3 +8,31 @@
|
||||
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
|
||||
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
125
scripts/publish-gitea-package.sh
Executable 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"
|
||||
10
src/dumps.rs
10
src/dumps.rs
@ -197,12 +197,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn materialize_dump(
|
||||
pub async fn materialize_dump_with_progress<F>(
|
||||
config: &Config,
|
||||
minio: &MinioClient,
|
||||
dump: &DumpEntry,
|
||||
overwrite: bool,
|
||||
) -> Result<PathBuf> {
|
||||
progress: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnMut(u64),
|
||||
{
|
||||
if let Some(path) = &dump.local_path {
|
||||
if path.exists() && !overwrite {
|
||||
return Ok(path.clone());
|
||||
@ -224,7 +228,7 @@ pub async fn materialize_dump(
|
||||
return Ok(destination);
|
||||
}
|
||||
minio
|
||||
.download_dump(key, &destination, |_| {})
|
||||
.download_dump(key, &destination, progress)
|
||||
.await
|
||||
.with_context(|| format!("falha ao baixar {}", dump.name))?;
|
||||
Ok(destination)
|
||||
|
||||
55
src/main.rs
55
src/main.rs
@ -15,7 +15,7 @@ use tracing::{error, info};
|
||||
use crate::config::Config;
|
||||
use crate::dumps::{collect_dumps, ensure_dumps_dir, find_dump_by_name, suggest_database_name};
|
||||
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)]
|
||||
#[command(name = "restoredb")]
|
||||
@ -160,7 +160,16 @@ async fn command_restore(
|
||||
println!("Aviso: existe cópia local com tamanho diferente do remoto.");
|
||||
println!("A cópia local será usada. Apague o arquivo local para baixar novamente.");
|
||||
}
|
||||
let local_path = dumps::materialize_dump(config, minio, &dump, false).await?;
|
||||
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 restore_config = config.with_postgres_database(target_database);
|
||||
|
||||
@ -192,11 +201,12 @@ async fn command_restore(
|
||||
&restore_config,
|
||||
&local_path,
|
||||
terminate_connections,
|
||||
|message| {
|
||||
println!("{message}");
|
||||
|progress| {
|
||||
render_restore_progress(&progress);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
println!();
|
||||
if let Err(err) =
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
18
src/minio.rs
18
src/minio.rs
@ -119,19 +119,25 @@ impl MinioClient {
|
||||
.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?;
|
||||
write_body(response.body, &mut file, &mut progress).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())
|
||||
async fn write_body<F>(mut body: ByteStream, file: &mut File, progress: &mut F) -> Result<()>
|
||||
where
|
||||
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(())
|
||||
}
|
||||
|
||||
@ -15,6 +15,13 @@ use tracing::debug;
|
||||
|
||||
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<()> {
|
||||
check_tool("psql").await?;
|
||||
check_tool("pg_restore").await?;
|
||||
@ -79,7 +86,7 @@ pub async fn restore_dump<F>(
|
||||
mut status: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
F: FnMut(RestoreProgress),
|
||||
{
|
||||
if !dump_path.exists() {
|
||||
bail!("arquivo não existe: {}", dump_path.display());
|
||||
@ -92,20 +99,71 @@ where
|
||||
return Err(RestoreAssistantError::UnsupportedDumpFormat(name.to_string()).into());
|
||||
}
|
||||
|
||||
let mut current = 0;
|
||||
let mut total = if terminate_connections { 5 } else { 4 };
|
||||
|
||||
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?;
|
||||
}
|
||||
status("Apagando banco de dados...");
|
||||
current += 1;
|
||||
emit_progress(&mut status, current, total, "Apagando banco de dados...");
|
||||
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?;
|
||||
status("Restaurando dump...");
|
||||
restore_contents(config, dump_path).await?;
|
||||
status("Restauração finalizada.");
|
||||
|
||||
current += 1;
|
||||
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(())
|
||||
}
|
||||
|
||||
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<()> {
|
||||
let sql = format!(
|
||||
"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
|
||||
}
|
||||
|
||||
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<()> {
|
||||
let name = dump_path
|
||||
.file_name()
|
||||
@ -165,6 +241,7 @@ async fn restore_compressed_sql(
|
||||
let mut child = base_psql_command(config, &config.postgres_database)
|
||||
.args(["-v", "ON_ERROR_STOP=1"])
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("falha ao iniciar psql para restaurar SQL comprimido")?;
|
||||
let stdin = child
|
||||
@ -176,10 +253,14 @@ async fn restore_compressed_sql(
|
||||
tokio::spawn(
|
||||
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??;
|
||||
if !status.success() {
|
||||
bail!("psql retornou erro ao restaurar SQL comprimido");
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!(
|
||||
"psql retornou erro ao restaurar SQL comprimido: {}",
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -270,7 +351,7 @@ fn decompress_pg_restore_archive_to_temp(
|
||||
}
|
||||
|
||||
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"),
|
||||
config,
|
||||
&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"])
|
||||
.arg(connection_uri(config, &config.postgres_database))
|
||||
.arg(dump_path)
|
||||
.status()
|
||||
.output()
|
||||
.await
|
||||
.context("falha ao executar pg_restore")?;
|
||||
if !status.success() {
|
||||
bail!("pg_restore retornou erro");
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("pg_restore retornou erro: {}", stderr.trim());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -381,3 +463,34 @@ fn url_encode(value: &str) -> String {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
204
src/tui.rs
204
src/tui.rs
@ -8,8 +8,9 @@ use crossterm::{
|
||||
};
|
||||
use ratatui::{
|
||||
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 crate::{
|
||||
@ -38,10 +39,27 @@ enum Mode {
|
||||
count: i64,
|
||||
},
|
||||
ConfirmDelete(DumpEntry),
|
||||
Working,
|
||||
Working {
|
||||
current: u64,
|
||||
total: u64,
|
||||
message: String,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
enum WorkerEvent {
|
||||
Progress {
|
||||
current: u64,
|
||||
total: u64,
|
||||
message: String,
|
||||
},
|
||||
DumpReady {
|
||||
path: PathBuf,
|
||||
dump_name: String,
|
||||
},
|
||||
Finished(Result<(), String>),
|
||||
}
|
||||
|
||||
struct App {
|
||||
config: Config,
|
||||
minio: MinioClient,
|
||||
@ -51,6 +69,7 @@ struct App {
|
||||
filter: String,
|
||||
status: String,
|
||||
mode: Mode,
|
||||
worker_rx: Option<UnboundedReceiver<WorkerEvent>>,
|
||||
}
|
||||
|
||||
pub async fn run(config: Config, minio: MinioClient) -> Result<()> {
|
||||
@ -72,6 +91,7 @@ impl App {
|
||||
filter: String::new(),
|
||||
status: "Carregando dumps...".to_string(),
|
||||
mode: Mode::Browsing,
|
||||
worker_rx: None,
|
||||
};
|
||||
app.refresh().await;
|
||||
Ok(app)
|
||||
@ -79,6 +99,7 @@ impl App {
|
||||
|
||||
async fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||
loop {
|
||||
self.poll_worker_events();
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
if !event::poll(Duration::from_millis(120))? {
|
||||
continue;
|
||||
@ -181,7 +202,7 @@ impl App {
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Mode::Working => {}
|
||||
Mode::Working { .. } => {}
|
||||
Mode::Error(_) => match key.code {
|
||||
KeyCode::Esc | KeyCode::Enter => self.mode = Mode::Browsing,
|
||||
KeyCode::Char('q') => break,
|
||||
@ -248,18 +269,39 @@ impl App {
|
||||
"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::EditingDatabase {
|
||||
path,
|
||||
input: suggest_database_name(&dump.name),
|
||||
};
|
||||
let config = self.config.clone();
|
||||
let minio = self.minio.clone();
|
||||
let total = dump.size.unwrap_or(1);
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
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) {
|
||||
@ -307,32 +349,79 @@ impl App {
|
||||
}
|
||||
|
||||
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 result = postgres_restore::restore_dump(
|
||||
&restore_config,
|
||||
&path,
|
||||
terminate_connections,
|
||||
|message| {
|
||||
info!("{message}");
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Err(err) =
|
||||
history::record_restore(&restore_config, &path, result.as_ref().err()).await
|
||||
{
|
||||
error!("{err:?}");
|
||||
}
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.status = "Restauração concluída.".to_string();
|
||||
self.mode = Mode::Browsing;
|
||||
}
|
||||
Err(err) => {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
self.worker_rx = Some(rx);
|
||||
self.mode = Mode::Working {
|
||||
current: 0,
|
||||
total: if terminate_connections { 5 } else { 4 },
|
||||
message: "Iniciando restauração...".to_string(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let result = postgres_restore::restore_dump(
|
||||
&restore_config,
|
||||
&path,
|
||||
terminate_connections,
|
||||
|progress| {
|
||||
info!("{}", progress.message);
|
||||
let _ = tx.send(WorkerEvent::Progress {
|
||||
current: progress.current,
|
||||
total: progress.total.max(progress.current).max(1),
|
||||
message: progress.message,
|
||||
});
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Err(err) =
|
||||
history::record_restore(&restore_config, &path, result.as_ref().err()).await
|
||||
{
|
||||
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);
|
||||
|
||||
let title = format!(
|
||||
"restoredb | PostgreSQL: {}:{}",
|
||||
"restoredb | PostgreSQL: {}:{} | Usar apenas em ambiente de desenvolvimento - ®IFPI-DTI-DSI",
|
||||
self.config.postgres_host, self.config.postgres_port
|
||||
);
|
||||
frame.render_widget(
|
||||
@ -415,12 +504,11 @@ impl App {
|
||||
count,
|
||||
} => self.draw_terminate_confirm(frame, area, path, database, *count),
|
||||
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::Working {
|
||||
current,
|
||||
total,
|
||||
message,
|
||||
} => self.draw_progress(frame, area, *current, *total, 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
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.",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user