Compare commits

...

29 Commits

Author SHA1 Message Date
Charles GTE 9bb830327e fix: refactoring 2026-09-10 22:55:47 +02:00
Charles GTE 31e55a7b4f fix(storage): block virtual and non-viable rclone backends 2026-09-10 22:38:08 +02:00
Charles GTE 576c055092 fix(storage): bound rclone timeouts, detect restore truncation, track sdd workspace 2026-09-10 22:38:08 +02:00
Charles GTE ba83b6b2b7 feat(storage): add RcloneProvider upload path 2026-09-10 22:38:07 +02:00
Charles GTE ea356ed54f fix(storage): abort truncated rclone uploads and strengthen stderr test 2026-09-10 22:37:37 +02:00
Charles GTE b222b13934 feat(storage): stream backups into rclone rcat 2026-09-10 22:37:09 +02:00
Charles GTE 9409f0ff25 feat(storage): add rclone config model and validation helpers 2026-09-10 22:37:00 +02:00
Charles GTE 59312215e9 build: exclude target/ and local artifacts from the docker context 2026-09-10 22:36:46 +02:00
Charles GTE 3ff360a971 build: install rclone 1.75.1 in agent images 2026-09-10 22:36:38 +02:00
github-actions[bot] 5f92297108 chore: release 1.20.1 2026-09-03 15:41:20 +00:00
Charles GTE d1821f7045 Merge pull request #102 from Portabase/fix/cron-crash
fix: cron crash for databases setup from dashboard
2026-09-03 17:38:53 +02:00
charles-gauthereau 0fe4d50cbd fix: docker-compose.yml 2026-09-03 17:38:35 +02:00
charles-gauthereau 1f29466a28 fix: cron crash for databases setup from dashboard 2026-09-03 17:22:03 +02:00
github-actions[bot] b74aaa0bbc chore: release 1.20.0 2026-08-28 15:42:27 +00:00
Charles GTE 7b5e5b2c78 Merge pull request #101 from Portabase/feat/retry-system
feat: retry-system
2026-08-28 17:40:20 +02:00
charles-gauthereau 99f1ef2081 fix: refactoring 2026-08-28 17:25:30 +02:00
charles-gauthereau 42c3c5945a fix: refactoring 2026-08-28 17:06:23 +02:00
charles-gauthereau db0f87c2e9 Merge branch 'main' into feat/retry-system 2026-08-28 16:54:51 +02:00
github-actions[bot] f95f0aa73a chore: release 1.19.2 2026-08-28 07:59:57 +00:00
Charles GTE 2177dfd44b Merge pull request #100 from Portabase/fix/kill-on-drop
fix: kill_on_drop on firebird, mariadb, mysql, redis, valkey
2026-08-28 09:57:51 +02:00
charles-gauthereau 0a53eec184 fix: kill_on_drop on firebird, mariadb, mysql, redis, valkey 2026-08-28 09:43:08 +02:00
charles-gauthereau 87f33af772 fix: wire retry env vars into helm configmap, dedupe terminal retry log
helm/templates/env-configmap.yaml never listed RETRY_ATTEMPTS and
RETRY_BACKOFF_MS even though values.yaml gained them, so --set
env.RETRY_ATTEMPTS=N was silently ignored by Kubernetes deployments.
Add both keys in the same explicit style as the existing entries.

src/utils/retry.rs logged its own "failed after N attempts" error on
exhaustion, on top of the terminal log each call site already writes,
producing two error entries per failure. Worse, it changed a log
level: FileLock::acquire's "backup_already_in_progress" bails through
the combinator, which now logged it as error before runner.rs got a
chance to reclassify it as the routine warn it always was. A manual
backup colliding with a scheduled one would show up as a hard error
on the dashboard instead of the harmless warn it used to be, breaking
the "fails exactly as it does today" guarantee for job records.

Drop the combinator's terminal error log and give download_backup its
own terminal error log so all three call sites (runner, uploader,
downloader) own their failure logging uniformly. Update the two tests
that asserted the removed message to assert the new behavior instead.
2026-08-27 22:46:49 +02:00
charles-gauthereau b6a120fcbf feat: retry the restore backup download
Extracts the download body to download_once and makes download_backup a
retry wrapper around it. This path had no retry at all before, so a
single dropped connection failed the whole restore job.

Retrying is safe because File::create truncates and the target filename
is derived from Content-Disposition or the URL, so it is stable across
attempts. There is no Range resume: a download that fails at 90% starts
over.
2026-08-27 19:01:10 +02:00
charles-gauthereau 5298d82576 feat: retry storage uploads
Wraps provider.upload in the retry combinator. No provider changes are
needed: each one builds its upload stream from the file on disk inside
upload(), so every attempt gets a fresh handle and a fresh nonce.

Result<UploadResult, UploadResult> is collapsed with an or-pattern so
the last attempt's error and metadata survive into the existing failure
branch. A missing backup file short-circuits into Err rather than
returning early, which skips the retry without skipping the
backup_upload_status(failed) call that closes the server-side record.

backup_upload_init and backup_upload_status are left unwrapped; they
are control-plane calls, not storage uploads.
2026-08-27 18:53:06 +02:00
charles-gauthereau b0da2e40a3 feat: retry the database backup
Each attempt now dumps into its own tmp_path/attempt-{n} directory,
which is removed when the attempt fails. Without the per-attempt
directory pg_dump -Fd would refuse every retry, because it will not
write into a directory a previous attempt left behind; removing it on
failure keeps peak disk at one attempt's artifacts rather than five.

A backup blocked by a concurrent job is retried before surfacing the
same backup_already_in_progress code, since FileLock reports it as an
ordinary error and the combinator cannot tell it apart.

Reshapes retry()'s bound from the native AsyncFnMut sugar to the
classic F: FnMut(u32) -> Fut, Fut: Future<Output = Result<T, E>> + Send
shape (the pattern tokio-retry and backoff both use). AsyncFnMut's
produced future is a lifetime-quantified associated type
(F::CallRefFuture<'_>) that cannot be named or bounded as Send on
stable Rust, so wiring a retried call through it into a future that
eventually gets polled inside tokio::spawn (dispatcher.rs, via
execute_backup) made rustc's opaque-type Send inference fail with
"implementation of Send is not general enough" at the spawn site,
several call layers away from the actual retry call. Naming Fut as its
own type parameter lets Send be asserted on it directly instead, which
resolves cleanly. The combinator's control flow, log messages, and
formats are unchanged; only the bound and the call sites' closure
shape (async |x| { } becomes |x| async { }, with shared references
bound outside a move closure so the inner async move block only moves
Copy references, not the originals) are affected.
2026-08-27 18:39:41 +02:00
charles-gauthereau 55e20d48e7 feat: configurable retry policy and combinator
Adds RETRY_ATTEMPTS (3..=5, default 3) and RETRY_BACKOFF_MS
(100..=30000, default 1000) to Settings, validated with the same
panic-on-invalid contract as POOLING and CHUNK_SIZE_MB.

The combinator logs every failed attempt and any late success through
the JobLogger it borrows, so retries reach the server on the existing
job-log path with no API change. It borrows rather than clones the Arc
so Arc::try_unwrap in the backup executor keeps working. Backoff is
exponential with equal jitter, because the uploader retries storages
concurrently and would otherwise retry them in lockstep.
2026-08-27 18:13:17 +02:00
github-actions[bot] f7f5f7e141 chore: release 1.19.1 2026-08-21 17:09:15 +00:00
Charles GTE 2170f96a72 Merge pull request #99 from Portabase/fix/config-file
fix: config directory creation
2026-08-21 19:06:48 +02:00
Charles GTE 298d46ba81 fix: config directory creation 2026-08-21 13:50:40 +02:00
37 changed files with 1390 additions and 37 deletions
+3 -5
View File
@@ -1,11 +1,9 @@
# Git
.git
.gitignore
# MD files
CHANGELOG.md
README.md
RELEASE.md
#IDE configurations
.idea
target
dump.rdb
.superpowers
+1
View File
@@ -8,3 +8,4 @@
.claude
/docs
.superpowers
+1 -1
View File
@@ -27,5 +27,5 @@ keywords:
- self-hosted
- portabase
license: Apache-2.0
version: 1.19.0
version: 1.20.1
date-released: '2026-02-24'
Generated
+1 -1
View File
@@ -3503,7 +3503,7 @@ dependencies = [
[[package]]
name = "portabase-agent"
version = "1.19.0"
version = "1.20.1"
dependencies = [
"aes",
"aes-gcm",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "portabase-agent"
version = "1.19.0"
version = "1.20.1"
edition = "2024"
[dependencies]
@@ -19,7 +19,7 @@ log = "0.4.29"
toml = "0.9.10"
reqwest = { version = "0.13.1", features = ["json", "blocking", "multipart", "stream", "query"] }
anyhow = "1.0.100"
tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros", "fs"] }
tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros", "fs", "process", "io-util"] }
async-trait = "0.1.89"
tempfile = "3.24.0"
openssl = "0.10.75"
+3 -1
View File
@@ -21,9 +21,11 @@ services:
LOG: debug
TZ: "Europe/Paris"
# TMPDIR: /scratch
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiZDY4MzU2MTQtNzE2NC00OTQ4LWJlZjMtMTlkZDc5NGQzYmRhIiwibWFzdGVyS2V5QjY0IjoiV2NiM0pQQkVTaFBjRjg5UXZwRVJuamU4NGZmak1kNm4vS2dJOUpjMCtmVT0ifQ=="
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMWNhYTY2ZjEtMWJjNi00MzQzLThiMmItNGEwZDFmM2UzMWI5IiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
#CHUNK_SIZE_MB: "1"
#POOLING: 1
#RETRY_ATTEMPTS: 3
#RETRY_BACKOFF_MS: 1000
#DATABASES_CONFIG_FILE: "config.toml"
extra_hosts:
- "localhost:host-gateway"
+17
View File
@@ -44,6 +44,15 @@ RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \
| tar -xjf - -C /usr/local/bin sqlcmd \
&& chmod +x /usr/local/bin/sqlcmd
# =========================
# rclone (all storage backends)
# =========================
ARG RCLONE_VERSION=1.75.1
RUN ARCH=$(dpkg --print-architecture) \
&& curl -fsSL -o /tmp/rclone.deb "https://downloads.rclone.org/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-${ARCH}.deb" \
&& dpkg -i /tmp/rclone.deb \
&& rm /tmp/rclone.deb
ARG TARGETARCH
# =========================
@@ -134,6 +143,12 @@ RUN apt-get update && apt-get install -y \
firebird3.0-utils \
&& rm -rf /var/lib/apt/lists/*
ARG RCLONE_VERSION=1.75.1
RUN ARCH=$(dpkg --print-architecture) \
&& curl -fsSL -o /tmp/rclone.deb "https://downloads.rclone.org/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-${ARCH}.deb" \
&& dpkg -i /tmp/rclone.deb \
&& rm /tmp/rclone.deb
ENV DOTNET_ROOT=/usr/local/dotnet
RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
&& chmod +x /tmp/dotnet-install.sh \
@@ -142,6 +157,8 @@ RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
WORKDIR /app
RUN mkdir -p /config
COPY --from=builder /app/target/release/app /usr/local/bin/app
COPY --from=builder /app/version.env /app/version.env
COPY entrypoint.sh /entrypoint.sh
+3 -1
View File
@@ -7,4 +7,6 @@ data:
TZ: {{ .Values.env.TZ | quote }}
POLLING: {{ .Values.env.POLLING | quote }}
APP_ENV: {{ .Values.env.APP_ENV | quote }}
LOG: {{ .Values.env.LOG | quote }}
LOG: {{ .Values.env.LOG | quote }}
RETRY_ATTEMPTS: {{ .Values.env.RETRY_ATTEMPTS | quote }}
RETRY_BACKOFF_MS: {{ .Values.env.RETRY_BACKOFF_MS | quote }}
+2
View File
@@ -11,6 +11,8 @@ env:
POLLING: "5"
APP_ENV: "production"
LOG: "info"
RETRY_ATTEMPTS: "3"
RETRY_BACKOFF_MS: "1000"
resources:
limits:
+1
View File
@@ -20,6 +20,7 @@ pub async fn run(cfg: DatabaseConfig) -> anyhow::Result<bool> {
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let query = b"SELECT 1 FROM RDB$DATABASE;\nQUIT;\n";
+2 -1
View File
@@ -12,7 +12,8 @@ pub async fn run(cfg: DatabaseConfig, env: HashMap<String, String>) -> anyhow::R
.arg("--user")
.arg(cfg.username)
.arg("ping")
.envs(env);
.envs(env)
.kill_on_drop(true);
let result = timeout(Duration::from_secs(10), cmd.output()).await;
+2 -1
View File
@@ -12,7 +12,8 @@ pub async fn run(cfg: DatabaseConfig, env: HashMap<String, String>) -> anyhow::R
.arg("--user")
.arg(cfg.username)
.arg("ping")
.envs(env);
.envs(env)
.kill_on_drop(true);
let result = timeout(Duration::from_secs(10), cmd.output()).await;
+2
View File
@@ -21,6 +21,8 @@ pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
cmd.arg("PING");
cmd.kill_on_drop(true);
debug!("Command Ping Redis: {:?}", cmd);
let result = timeout(Duration::from_secs(10), cmd.output()).await;
+1
View File
@@ -20,6 +20,7 @@ pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
}
cmd.arg("PING");
cmd.kill_on_drop(true);
debug!("Command Ping Valkey: {:?}", cmd);
+7
View File
@@ -1,6 +1,7 @@
#![allow(dead_code)]
use crate::services::config::DbType;
use std::fmt::{self, Display, Formatter};
use std::path::PathBuf;
#[derive(Debug, Clone)]
@@ -20,3 +21,9 @@ pub struct UploadResult {
pub remote_file_path: Option<String>,
pub total_size: Option<u64>,
}
impl Display for UploadResult {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.error.as_deref().unwrap_or("unknown error"))
}
}
+26 -1
View File
@@ -4,6 +4,7 @@ use super::service::BackupService;
use crate::domain::factory::DatabaseFactory;
use crate::services::config::DatabaseConfig;
use crate::utils::retry::{RetryPolicy, retry};
use anyhow::Result;
use std::path::Path;
@@ -39,7 +40,31 @@ impl BackupService {
});
}
match db.backup(tmp_path, Arc::clone(&logger)).await {
let policy = RetryPolicy::default();
let db_ref = &db;
let logger_ref = &logger;
let outcome = retry("Database backup", &logger, &policy, move |attempt| {
let dir = tmp_path.join(format!("attempt-{attempt}"));
async move {
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
return Err(anyhow::Error::from(e));
}
match db_ref.backup(&dir, Arc::clone(logger_ref)).await {
Ok(f) => Ok(f),
Err(e) => {
let _ = tokio::fs::remove_dir_all(&dir).await;
Err(e)
}
}
}
})
.await;
match outcome {
Ok(file) => Ok(BackupResult {
generated_id,
db_type,
+38 -11
View File
@@ -4,6 +4,7 @@ use super::service::BackupService;
use crate::services::api::models::agent::status::DatabaseStorage;
use crate::services::storage;
use crate::utils::common::BackupMethod;
use crate::utils::retry::{RetryPolicy, retry};
use anyhow::{Result, bail};
use futures::future::join_all;
use std::sync::Arc;
@@ -97,16 +98,44 @@ impl BackupService {
/*
STORAGE UPLOAD
*/
let upload_result = provider
.upload(
ctx_clone.clone(),
result_clone,
method,
&storage,
Some(encrypt),
&backup_storage_id,
let policy = RetryPolicy::default();
let attempt_result = if result_clone.backup_file.is_none() {
logger_clone.log("error", format!("Missing backup file for storage {}", storage_id));
Err(UploadResult {
storage_id: storage_id.clone(),
success: false,
error: Some("Missing backup file path".into()),
remote_file_path: None,
total_size: None,
})
} else {
retry(
&format!("Upload to storage {storage_id}"),
&logger_clone,
&policy,
|_| async {
let r = provider
.upload(
ctx_clone.clone(),
result_clone.clone(),
method,
&storage,
Some(encrypt),
&backup_storage_id,
)
.await;
if r.success { Ok(r) } else { Err(r) }
},
)
.await;
.await
};
let upload_result = match attempt_result {
Ok(r) | Err(r) => r,
};
let status = if upload_result.success { "success" } else { "failed" };
@@ -117,8 +146,6 @@ impl BackupService {
upload_result.error.as_deref().unwrap_or("unknown error")
));
// `backup_upload_init` opened a per-storage record; close it as "failed"
// so the server is notified of the failure (no path/size on this path).
if let Err(err) = ctx_clone
.api
.backup_upload_status(
+22 -7
View File
@@ -207,16 +207,19 @@ impl ConfigService {
ConfigService { ctx }
}
pub fn load(&self, file_path: Option<&str>) -> Result<DatabasesConfig, String> {
let path: String = if let Some(fp) = file_path {
fp.to_string()
} else {
format!(
fn resolve_path(file_path: Option<&str>) -> String {
match file_path {
Some(fp) => fp.to_string(),
None => format!(
"{}/{}",
crate::settings::CONFIG.data_path,
crate::settings::CONFIG.databases_config_file
)
};
),
}
}
pub fn load(&self, file_path: Option<&str>) -> Result<DatabasesConfig, String> {
let path = Self::resolve_path(file_path);
info!("Loading databases config from: {}", path);
@@ -260,6 +263,18 @@ impl ConfigService {
}
pub fn load_optional(&self, file_path: Option<&str>) -> DatabasesConfig {
let path = Self::resolve_path(file_path);
if !Path::new(&path).exists() {
info!(
"No local databases config at {}; using dashboard-defined databases only",
path
);
return DatabasesConfig {
databases: Vec::new(),
};
}
self.load(file_path).unwrap_or_else(|e| {
tracing::warn!(
"Local databases config unavailable ({e}); continuing with dashboard-defined databases only"
+3
View File
@@ -46,6 +46,9 @@ pub fn persist_cache(path: &Path, databases: &[DatabaseConfig]) -> std::io::Resu
};
let json = serde_json::to_string_pretty(&wrapper)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)?;
+43 -2
View File
@@ -1,5 +1,7 @@
use super::service::RestoreService;
use crate::services::backup::logger::JobLogger;
use crate::utils::retry::{RetryPolicy, retry};
use anyhow::Result;
use futures::StreamExt;
use reqwest::{Client, Url};
@@ -7,7 +9,6 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use tokio::io::AsyncWriteExt;
use crate::services::backup::logger::JobLogger;
fn human_size(bytes: u64) -> String {
if bytes >= 1024 * 1024 {
@@ -26,6 +27,34 @@ impl RestoreService {
tmp_path: &Path,
logger: Arc<JobLogger>,
expected_size: Option<String>,
) -> Result<PathBuf> {
let policy = RetryPolicy::default();
let logger_ref = &logger;
let outcome = retry("Backup download", &logger, &policy, move |_| {
let expected = expected_size.clone();
async move {
self.download_once(file_url, tmp_path, Arc::clone(logger_ref), expected)
.await
}
})
.await;
if let Err(e) = &outcome {
logger.log("error", format!("Download failed: {e}"));
}
outcome
}
pub async fn download_once(
&self,
file_url: &str,
tmp_path: &Path,
logger: Arc<JobLogger>,
expected_size: Option<String>,
) -> Result<PathBuf> {
logger.log("info", "Start downloading backup archive".to_string());
@@ -69,7 +98,9 @@ impl RestoreService {
format!(
"Downloading backup '{}' ({})",
filename,
total.map(human_size).unwrap_or_else(|| "unknown size".to_string())
total
.map(human_size)
.unwrap_or_else(|| "unknown size".to_string())
),
);
@@ -109,6 +140,16 @@ impl RestoreService {
);
}
if let Some(total) = total
&& downloaded < total
{
anyhow::bail!(
"Downloaded {} bytes but expected at least {} - backup appears truncated",
downloaded,
total
);
}
logger.log(
"info",
format!(
+2 -1
View File
@@ -9,6 +9,7 @@ use providers::azure_blob;
use providers::google_cloud_storage;
use providers::google_drive;
use providers::local;
use providers::rclone;
use providers::s3;
use std::sync::Arc;
use tracing::{error, info};
@@ -26,7 +27,6 @@ pub trait StorageProvider: Send + Sync {
) -> UploadResult;
}
/// Factory to create provider instance from storage config
pub fn get_provider(storage: &DatabaseStorage) -> Option<Box<dyn StorageProvider>> {
info!("Getting provider");
info!("{:#?}", storage.provider.as_str());
@@ -39,6 +39,7 @@ pub fn get_provider(storage: &DatabaseStorage) -> Option<Box<dyn StorageProvider
"google-cloud-storage" => Some(Box::new(
google_cloud_storage::GoogleCloudStorageProvider {},
)),
"rclone" => Some(Box::new(rclone::RcloneProvider {})),
_ => {
error!("Unknown storage provider: {}", storage.provider);
None
+1
View File
@@ -2,4 +2,5 @@ pub mod azure_blob;
pub mod google_cloud_storage;
pub mod google_drive;
pub mod local;
pub mod rclone;
pub mod s3;
@@ -0,0 +1,168 @@
use anyhow::{Context, Result, bail};
use bytes::Bytes;
use futures::{Stream, StreamExt};
use std::io::Write;
use std::path::Path;
use std::pin::Pin;
use std::process::Stdio;
use tempfile::NamedTempFile;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
use tracing::info;
const BLOCKED_BACKEND_TYPES: [&str; 13] = [
"local",
"alias",
"crypt",
"chunker",
"compress",
"union",
"combine",
"hasher",
"archive",
"cache",
"memory",
"http",
"googlephotos",
];
fn sections(config_text: &str) -> Vec<(String, Option<String>)> {
let mut out: Vec<(String, Option<String>)> = Vec::new();
for line in config_text.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') && line.len() > 2 {
out.push((line[1..line.len() - 1].trim().to_string(), None));
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
if key.trim().eq_ignore_ascii_case("type")
&& let Some(current) = out.last_mut()
&& current.1.is_none()
{
current.1 = Some(value.trim().to_ascii_lowercase());
}
}
out
}
pub fn validate_config(config_text: &str, remote_name: &str) -> Result<()> {
let sections = sections(config_text);
if sections.is_empty() {
bail!("rclone config contains no remote sections");
}
for (name, backend) in &sections {
let Some(backend) = backend else { continue };
if BLOCKED_BACKEND_TYPES.contains(&backend.as_str()) {
bail!("rclone backend type '{backend}' is not allowed (remote '{name}')");
}
}
if !sections.iter().any(|(name, _)| name == remote_name) {
let available: Vec<&str> = sections.iter().map(|(name, _)| name.as_str()).collect();
bail!(
"remote '{remote_name}' is not defined in the rclone config (available: {})",
available.join(", ")
);
}
Ok(())
}
/// `<remote>:<remote_path>/<remote_file_path>`
pub fn remote_target(remote_name: &str, remote_path: &str, remote_file_path: &str) -> String {
let base = remote_path.trim().trim_matches('/');
if base.is_empty() {
format!("{remote_name}:{remote_file_path}")
} else {
format!("{remote_name}:{base}/{remote_file_path}")
}
}
pub type RcloneStream = Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>;
pub fn write_config(config_text: &str) -> Result<NamedTempFile> {
let mut file = NamedTempFile::new().context("failed to create rclone config temp file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o600))
.context("failed to restrict rclone config permissions")?;
}
file.write_all(config_text.as_bytes())
.context("failed to write rclone config")?;
file.flush().context("failed to flush rclone config")?;
Ok(file)
}
pub async fn rcat(config_path: &Path, target: &str, mut stream: RcloneStream) -> Result<()> {
info!("rclone rcat -> {}", target);
let mut child = Command::new("rclone")
.arg("--config")
.arg(config_path)
.arg("--contimeout")
.arg("30s")
.arg("--timeout")
.arg("5m")
.arg("--retries")
.arg("1")
.arg("--low-level-retries")
.arg("3")
.arg("rcat")
.arg(target)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn rclone (is the binary installed in this image?)")?;
let mut stderr_pipe = child.stderr.take().context("rclone stderr unavailable")?;
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
let _ = stderr_pipe.read_to_string(&mut buf).await;
buf
});
let mut stdin = child.stdin.take().context("rclone stdin unavailable")?;
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(e).context("backup stream failed");
}
};
if stdin.write_all(&chunk).await.is_err() {
break;
}
}
let _ = stdin.flush().await;
drop(stdin);
let status = child.wait().await.context("failed to wait for rclone")?;
let stderr = stderr_task.await.unwrap_or_default();
if !status.success() {
bail!("rclone rcat failed ({status}): {}", stderr.trim());
}
Ok(())
}
@@ -0,0 +1,112 @@
pub mod helpers;
pub mod models;
use crate::core::context::Context;
use crate::services::api::models::agent::status::DatabaseStorage;
use crate::services::backup::models::{BackupResult, UploadResult};
use crate::services::storage::StorageProvider;
use crate::services::storage::providers::rclone::helpers::{
rcat, remote_target, validate_config, write_config,
};
use crate::services::storage::providers::rclone::models::RcloneProviderConfig;
use crate::utils::common::BackupMethod;
use crate::utils::file::{full_file_name, full_file_path};
use crate::utils::stream::build_stream;
use async_trait::async_trait;
use std::sync::Arc;
use tokio::fs;
use tracing::{error, info};
pub struct RcloneProvider {}
fn failed(storage_id: &str, error: impl ToString, total_size: Option<u64>) -> UploadResult {
UploadResult {
storage_id: storage_id.to_string(),
success: false,
error: Some(error.to_string()),
remote_file_path: None,
total_size,
}
}
#[async_trait]
impl StorageProvider for RcloneProvider {
async fn upload(
&self,
ctx: Arc<Context>,
result: BackupResult,
_method: BackupMethod,
storage: &DatabaseStorage,
encrypt: Option<bool>,
_backup_storage_id: &str,
) -> UploadResult {
let storage_id = storage.id.clone();
let Some(file_path) = result.backup_file else {
return failed(&storage_id, "Missing backup file path", None);
};
let total_size = match fs::metadata(&file_path).await {
Ok(meta) => meta.len(),
Err(e) => {
error!("Failed to get file size: {}", e);
return failed(&storage_id, e, None);
}
};
let config: RcloneProviderConfig = match storage.clone().config.try_into() {
Ok(c) => c,
Err(e) => {
error!("rclone config deserialization failed: {}", e);
return failed(&storage_id, e, Some(total_size));
}
};
if let Err(e) = validate_config(&config.config_text, &config.remote_name) {
error!("rclone config rejected: {}", e);
return failed(&storage_id, e, Some(total_size));
}
let encrypt = encrypt.unwrap_or(false);
let upload = match build_stream(&file_path, encrypt, &ctx.edge_key.master_key_b64).await {
Ok(u) => u,
Err(e) => {
error!("Stream build failed: {}", e);
return failed(&storage_id, e, Some(total_size));
}
};
let file_name = full_file_name(encrypt);
let remote_file_path = full_file_path(&file_name, storage.folder_name.as_deref());
let config_file = match write_config(&config.config_text) {
Ok(f) => f,
Err(e) => {
error!("rclone config write failed: {}", e);
return failed(&storage_id, e, Some(total_size));
}
};
let target = remote_target(&config.remote_name, &config.remote_path, &remote_file_path);
info!("Starting rclone upload to {}", target);
match rcat(config_file.path(), &target, upload.stream).await {
Ok(()) => {
info!("rclone upload successful: {}", remote_file_path);
UploadResult {
storage_id,
success: true,
error: None,
remote_file_path: Some(remote_file_path),
total_size: Some(total_size),
}
}
Err(e) => {
error!("rclone upload failed: {:?}", e);
failed(&storage_id, e, Some(total_size))
}
}
}
}
@@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct RcloneProviderConfig {
pub config_text: String,
pub remote_name: String,
pub remote_path: String,
}
+23 -1
View File
@@ -16,6 +16,8 @@ pub struct Settings {
pub timezone: String,
pub log: String,
pub chunk_size: usize, // bytes
pub retry_attempts: u32,
pub retry_backoff_ms: u64,
}
impl Settings {
@@ -49,6 +51,24 @@ impl Settings {
let chunk_size = chunk_size_mb * 1024 * 1024;
let retry_attempts = env::var("RETRY_ATTEMPTS")
.unwrap_or_else(|_| "3".to_string())
.parse::<u32>()
.expect("RETRY_ATTEMPTS must be a valid positive integer");
if retry_attempts < 3 || retry_attempts > 5 {
panic!("RETRY_ATTEMPTS must be between 3 and 5");
}
let retry_backoff_ms = env::var("RETRY_BACKOFF_MS")
.unwrap_or_else(|_| "1000".to_string())
.parse::<u64>()
.expect("RETRY_BACKOFF_MS must be a valid positive integer");
if retry_backoff_ms < 100 || retry_backoff_ms > 30_000 {
panic!("RETRY_BACKOFF_MS must be between 100 and 30000 milliseconds");
}
let tz = env::var("TZ").unwrap_or_else(|_| "UTC".to_string());
Self {
@@ -64,7 +84,9 @@ impl Settings {
pooling: pooling_seconds,
timezone: tz,
log: env::var("LOG").unwrap_or_else(|_| "info".into()),
chunk_size
chunk_size,
retry_attempts,
retry_backoff_ms,
}
}
}
+68
View File
@@ -0,0 +1,68 @@
use crate::services::backup::BackupService;
use crate::services::backup::logger::JobLogger;
use crate::services::config::{DatabaseConfig, DbType};
use crate::tests::init_tracing_for_test;
use std::collections::HashMap;
use std::sync::Arc;
use tempfile::TempDir;
fn sqlite_config(path: &str) -> DatabaseConfig {
DatabaseConfig {
name: "retry-test".to_string(),
database: String::new(),
db_type: DbType::Sqlite,
username: String::new(),
password: String::new(),
port: 0,
host: String::new(),
generated_id: "retry-test-gen".to_string(),
path: path.to_string(),
max_packet_size: String::new(),
volume_name: String::new(),
container_name: None,
options: HashMap::new(),
}
}
#[tokio::test]
async fn a_failing_backup_is_retried_and_leaves_no_attempt_directory() {
init_tracing_for_test();
let temp_dir = TempDir::new().unwrap();
let tmp_path = temp_dir.path();
let logger = Arc::new(JobLogger::new());
let cfg = sqlite_config("/nonexistent/definitely-not-here.sqlite");
let result = BackupService::run(cfg, tmp_path, Arc::clone(&logger))
.await
.unwrap();
assert_eq!(result.status, "failed");
assert!(result.backup_file.is_none());
let entries = Arc::try_unwrap(logger).unwrap().into_entries();
assert_eq!(
entries.iter().filter(|e| e.level == "warn").count(),
2,
"expected one warn per non-final failed attempt"
);
assert!(
entries
.iter()
.any(|e| e.level == "error" && e.message.starts_with("Backup failed:")),
"expected a single terminal error from the runner"
);
let leftovers: Vec<_> = std::fs::read_dir(tmp_path)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("attempt-"))
.collect();
assert!(
leftovers.is_empty(),
"failed attempt directories must be cleaned up, found {:?}",
leftovers.iter().map(|e| e.file_name()).collect::<Vec<_>>()
);
}
+110
View File
@@ -14,7 +14,9 @@ use crate::utils::common::BackupMethod;
use crate::utils::edge_key::EdgeKey;
use serde_json::json;
use std::io::Write;
use std::sync::Arc;
use tempfile::NamedTempFile;
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -93,3 +95,111 @@ async fn failed_upload_reports_failed_status_to_server() {
// MockServer drop verifies both `.expect(1)` mounts were hit — including the "failed" PATCH.
}
#[tokio::test]
async fn a_failing_upload_is_retried_until_it_succeeds() {
init_tracing_for_test();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/agent/agent-1/backup/upload/init"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"message": "ok",
"backupStorage": { "id": "bs-1" }
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/tus/files"))
.respond_with(ResponseTemplate::new(500))
.up_to_n_times(2)
.with_priority(1)
.expect(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/tus/files"))
.respond_with(
ResponseTemplate::new(201)
.insert_header("Location", format!("{}/tus/files/upload-1", server.uri()).as_str()),
)
.with_priority(2)
.expect(1)
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/tus/files/upload-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/agent/agent-1/backup/upload/status"))
.and(body_partial_json(json!({ "status": "success" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"message": "ok",
"backupStorage": { "id": "bs-1" }
})))
.expect(1)
.mount(&server)
.await;
let mut backup_file = NamedTempFile::new().unwrap();
backup_file.write_all(b"portabase-retry-test-payload").unwrap();
backup_file.flush().unwrap();
let ctx = Context {
edge_key: EdgeKey {
server_url: server.uri(),
agent_id: "agent-1".to_string(),
master_key_b64: String::new(),
},
api: ApiClient::new(server.uri()),
};
let service = BackupService::new(Arc::new(ctx));
let result = BackupResult {
generated_id: "gen-1".to_string(),
db_type: DbType::Postgresql,
status: "success".to_string(),
backup_file: Some(backup_file.path().to_path_buf()),
code: None,
};
let storage: DatabaseStorage = serde_json::from_value(json!({
"id": "storage-1",
"provider": "local",
"config": {}
}))
.unwrap();
let backup_id = "backup-1".to_string();
let logger = Arc::new(JobLogger::new());
let results = service
.upload(
result,
BackupMethod::Manual,
vec![storage],
false,
&backup_id,
Arc::clone(&logger),
)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].success);
let entries = Arc::try_unwrap(logger).unwrap().into_entries();
assert_eq!(entries.iter().filter(|e| e.level == "warn").count(), 2);
assert!(
entries.iter().any(|e| e.message
== "Upload to storage storage-1 succeeded on attempt 3/3")
);
}
+2
View File
@@ -1,4 +1,6 @@
mod api_models_tests;
mod backup_runner_tests;
mod backup_uploader_tests;
mod config_tests;
mod dashboard_config_tests;
mod restore_downloader_tests;
@@ -0,0 +1,64 @@
use crate::core::context::Context;
use crate::services::api::ApiClient;
use crate::services::backup::logger::JobLogger;
use crate::services::restore::RestoreService;
use crate::tests::init_tracing_for_test;
use crate::utils::edge_key::EdgeKey;
use std::sync::Arc;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn a_failing_download_is_retried_until_it_succeeds() {
init_tracing_for_test();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backups/archive.tar.gz"))
.respond_with(ResponseTemplate::new(503))
.up_to_n_times(2)
.with_priority(1)
.expect(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backups/archive.tar.gz"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"portabase-archive".to_vec()))
.with_priority(2)
.expect(1)
.mount(&server)
.await;
let ctx = Context {
edge_key: EdgeKey {
server_url: server.uri(),
agent_id: "agent-1".to_string(),
master_key_b64: String::new(),
},
api: ApiClient::new(server.uri()),
};
let service = RestoreService::new(Arc::new(ctx));
let temp_dir = TempDir::new().unwrap();
let logger = Arc::new(JobLogger::new());
let url = format!("{}/backups/archive.tar.gz", server.uri());
let downloaded = service
.download_backup(&url, temp_dir.path(), Arc::clone(&logger), None)
.await
.unwrap();
assert_eq!(std::fs::read(&downloaded).unwrap(), b"portabase-archive");
let entries = Arc::try_unwrap(logger).unwrap().into_entries();
assert_eq!(entries.iter().filter(|e| e.level == "warn").count(), 2);
assert!(
entries
.iter()
.any(|e| e.message == "Backup download succeeded on attempt 3/3")
);
}
+1
View File
@@ -1,2 +1,3 @@
mod azure_blob;
mod google_cloud_storage;
mod rclone;
+433
View File
@@ -0,0 +1,433 @@
use crate::services::api::models::agent::status::DatabaseStorage;
use crate::services::storage::providers::rclone::helpers::{remote_target, validate_config};
use crate::services::storage::providers::rclone::models::RcloneProviderConfig;
use crate::tests::init_tracing_for_test;
use crate::utils::file::full_file_path;
const OVH_CONFIG: &str = "[ovhcloud-rbx]\n\
type = s3\n\
provider = OVHcloud\n\
access_key_id = my_access\n\
secret_access_key = my_secret\n\
region = rbx\n\
endpoint = s3.rbx.io.cloud.ovh.net\n\
acl = private\n";
#[test]
fn config_deserializes_from_dashboard_camel_case() {
init_tracing_for_test();
let storage: DatabaseStorage = serde_json::from_value(serde_json::json!({
"id": "storage-1",
"provider": "rclone",
"folderName": "backups",
"config": {
"configText": OVH_CONFIG,
"remoteName": "ovhcloud-rbx",
"remotePath": "my-bucket",
}
}))
.unwrap();
let config: RcloneProviderConfig = storage.config.try_into().unwrap();
assert_eq!(config.remote_name, "ovhcloud-rbx");
assert_eq!(config.remote_path, "my-bucket");
assert!(config.config_text.contains("type = s3"));
}
#[test]
fn validate_config_accepts_the_target_remote() {
assert!(validate_config(OVH_CONFIG, "ovhcloud-rbx").is_ok());
}
#[test]
fn validate_config_rejects_an_unknown_remote_name() {
let err = validate_config(OVH_CONFIG, "typo").unwrap_err().to_string();
assert!(err.contains("typo"), "unexpected error: {err}");
assert!(err.contains("ovhcloud-rbx"), "error should list the available remotes: {err}");
}
#[test]
fn validate_config_rejects_local_backend() {
let cfg = "[disk]\ntype = local\n";
let err = validate_config(cfg, "disk").unwrap_err().to_string();
assert!(err.contains("local"), "unexpected error: {err}");
}
#[test]
fn validate_config_rejects_alias_backend() {
let cfg = "[shortcut]\ntype = alias\nremote = other:path\n";
let err = validate_config(cfg, "shortcut").unwrap_err().to_string();
assert!(err.contains("alias"), "unexpected error: {err}");
}
#[test]
fn validate_config_rejects_a_blocked_backend_in_a_chained_section() {
let cfg = "[secret]\ntype = crypt\nremote = disk:vault\n\n[disk]\ntype = local\n";
let err = validate_config(cfg, "secret").unwrap_err().to_string();
assert!(err.contains("crypt"), "unexpected error: {err}");
assert!(err.contains("secret"), "error should name the offending remote: {err}");
let cfg = "[outer]\ntype = s3\nprovider = Minio\n\n[disk]\ntype = local\n";
let err = validate_config(cfg, "outer").unwrap_err().to_string();
assert!(err.contains("local"), "unexpected error: {err}");
assert!(err.contains("disk"), "error should name the offending remote: {err}");
}
#[test]
fn validate_config_rejects_crypt_even_over_an_allowed_remote() {
let cfg = format!("[secret]\ntype = crypt\nremote = ovhcloud-rbx:bucket\n\n{OVH_CONFIG}");
let err = validate_config(&cfg, "secret").unwrap_err().to_string();
assert!(err.contains("crypt"), "unexpected error: {err}");
}
#[test]
fn validate_config_rejects_a_wrapping_backend_pointing_at_a_bare_local_path() {
for backend in ["crypt", "chunker", "compress", "union", "combine", "hasher"] {
let cfg = format!("[sneaky]\ntype = {backend}\nremote = /etc\n");
let err = validate_config(&cfg, "sneaky")
.unwrap_err()
.to_string();
assert!(err.contains(backend), "{backend} must be rejected: {err}");
}
}
#[test]
fn validate_config_rejects_backends_that_cannot_hold_a_backup() {
for backend in ["memory", "http", "googlephotos"] {
let cfg = format!("[nope]\ntype = {backend}\n");
let err = validate_config(&cfg, "nope").unwrap_err().to_string();
assert!(err.contains(backend), "{backend} must be rejected: {err}");
}
}
#[test]
fn remote_path_is_a_prefix_ahead_of_the_backup_folder() {
assert_eq!(
remote_target("ovhcloud-rbx", "my-bucket", "backups/2026-09-09/x.tar.gz"),
"ovhcloud-rbx:my-bucket/backups/2026-09-09/x.tar.gz"
);
// Deeper prefixes nest the same way.
assert_eq!(
remote_target("ovhcloud-rbx", "my-bucket/portabase", "backups/2026-09-09/x.tar.gz"),
"ovhcloud-rbx:my-bucket/portabase/backups/2026-09-09/x.tar.gz"
);
}
#[test]
fn remote_target_trims_surrounding_slashes_and_whitespace() {
assert_eq!(
remote_target("r", " /my-bucket/ ", "a/b.bin"),
"r:my-bucket/a/b.bin"
);
}
#[test]
fn remote_target_handles_an_empty_remote_path() {
assert_eq!(remote_target("r", "", "a/b.bin"), "r:a/b.bin");
assert_eq!(remote_target("r", " ", "a/b.bin"), "r:a/b.bin");
}
#[test]
fn an_empty_remote_path_falls_back_to_the_global_backup_folder() {
let remote_file_path = full_file_path(&"x.tar.gz".to_string(), None);
assert!(remote_file_path.starts_with("backups/"));
assert_eq!(
remote_target("ovhcloud-rbx", "", &remote_file_path),
format!("ovhcloud-rbx:{remote_file_path}")
);
assert_eq!(
remote_target("ovhcloud-rbx", "my-bucket", &remote_file_path),
format!("ovhcloud-rbx:my-bucket/{remote_file_path}")
);
}
use crate::services::storage::providers::rclone::helpers::{rcat, write_config};
use bytes::Bytes;
use futures::stream;
use std::process::Command;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::AsyncRunner;
use testcontainers::{GenericImage, ImageExt};
const BUCKET: &str = "portabase";
async fn start_minio() -> (testcontainers::ContainerAsync<GenericImage>, String) {
let container = GenericImage::new("minio/minio", "latest")
.with_exposed_port(9000.tcp())
.with_wait_for(WaitFor::message_on_stderr("API:"))
.with_env_var("MINIO_ROOT_USER", "minioadmin")
.with_env_var("MINIO_ROOT_PASSWORD", "minioadmin")
.with_cmd(["server", "/data"])
.start()
.await
.unwrap();
let host = container.get_host().await.unwrap().to_string();
let port = container.get_host_port_ipv4(9000).await.unwrap();
(container, format!("http://{host}:{port}"))
}
fn minio_config(endpoint: &str) -> String {
format!(
"[minio]\n\
type = s3\n\
provider = Minio\n\
access_key_id = minioadmin\n\
secret_access_key = minioadmin\n\
endpoint = {endpoint}\n\
region = us-east-1\n\
force_path_style = true\n"
)
}
fn rclone_ok(config_path: &std::path::Path, args: &[&str]) -> Vec<u8> {
let out = Command::new("rclone")
.arg("--config")
.arg(config_path)
.args(args)
.output()
.expect("rclone binary not found — is it installed in this image?");
assert!(
out.status.success(),
"rclone {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
out.stdout
}
#[test]
fn write_config_creates_an_owner_only_file_with_the_exact_text() {
use std::os::unix::fs::PermissionsExt;
let file = write_config(OVH_CONFIG).unwrap();
let mode = std::fs::metadata(file.path()).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "config file must not be group/world readable");
assert_eq!(std::fs::read_to_string(file.path()).unwrap(), OVH_CONFIG);
}
#[tokio::test]
async fn rcat_streams_a_multi_chunk_body_to_minio() {
init_tracing_for_test();
let (_container, endpoint) = start_minio().await;
let config = write_config(&minio_config(&endpoint)).unwrap();
rclone_ok(config.path(), &["mkdir", &format!("minio:{BUCKET}")]);
let data = vec![7u8; 10 * 1024];
let chunks: Vec<Result<Bytes, std::io::Error>> = data
.chunks(1024)
.map(|c| Ok(Bytes::copy_from_slice(c)))
.collect();
let target = remote_target("minio", BUCKET, "backups/2026-09-09/test.bin");
rcat(config.path(), &target, Box::pin(stream::iter(chunks)))
.await
.unwrap();
let got = rclone_ok(config.path(), &["cat", &target]);
assert_eq!(got, data);
}
#[tokio::test]
async fn rcat_reports_rclone_stderr_when_the_remote_is_unreachable() {
init_tracing_for_test();
let config = write_config(&minio_config("http://127.0.0.1:1")).unwrap();
let chunks: Vec<Result<Bytes, std::io::Error>> =
vec![Ok(Bytes::from_static(&[0u8; 4096]))];
let err = rcat(
config.path(),
"minio:portabase/x.bin",
Box::pin(stream::iter(chunks)),
)
.await
.expect_err("upload to an unreachable endpoint must fail");
let msg = err.to_string();
assert!(
msg.contains("rclone rcat failed"),
"the broken stdin pipe must not mask rclone's own error: {msg}"
);
let (_, stderr_part) = msg
.rsplit_once(": ")
.expect("bail message must carry rclone stderr after the exit status");
assert!(
!stderr_part.trim().is_empty(),
"rclone stderr must be included: {msg}"
);
}
#[tokio::test]
async fn rcat_aborts_the_upload_when_the_stream_fails() {
init_tracing_for_test();
let (_container, endpoint) = start_minio().await;
let config = write_config(&minio_config(&endpoint)).unwrap();
rclone_ok(config.path(), &["mkdir", &format!("minio:{BUCKET}")]);
let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
Ok(Bytes::from_static(&[1u8; 1024])),
Err(std::io::Error::other("injected stream failure")),
];
let target = remote_target("minio", BUCKET, "backups/2026-09-09/aborted.bin");
let err = rcat(config.path(), &target, Box::pin(stream::iter(chunks)))
.await
.expect_err("a stream error must fail the upload");
assert!(
err.to_string().contains("backup stream failed"),
"unexpected error: {err}"
);
let stat_out = rclone_ok(config.path(), &["lsjson", "--stat", &target]);
let stat: serde_json::Value = serde_json::from_slice(&stat_out).unwrap();
assert_eq!(
stat["Name"], "",
"rclone must not have finalized the truncated object: {stat}"
);
assert_eq!(stat["IsDir"], true, "a miss reports IsDir: true: {stat}");
}
use crate::core::context::Context;
use crate::services::api::ApiClient;
use crate::services::backup::models::BackupResult;
use crate::services::config::DbType;
use crate::services::storage::providers::rclone::RcloneProvider;
use crate::services::storage::{StorageProvider, get_provider};
use crate::utils::common::BackupMethod;
use crate::utils::edge_key::EdgeKey;
use std::io::Write as _;
use std::sync::Arc;
use tempfile::NamedTempFile;
fn test_context() -> Arc<Context> {
Arc::new(Context {
edge_key: EdgeKey {
server_url: String::new(),
agent_id: "agent-1".to_string(),
master_key_b64: String::new(),
},
api: ApiClient::new(String::new()),
})
}
fn storage_for(config_text: &str, remote_path: &str) -> DatabaseStorage {
serde_json::from_value(serde_json::json!({
"id": "storage-1",
"provider": "rclone",
"folderName": "backups",
"config": {
"configText": config_text,
"remoteName": "minio",
"remotePath": remote_path,
}
}))
.unwrap()
}
#[test]
fn factory_resolves_the_rclone_provider_key() {
let storage = storage_for(OVH_CONFIG, "bucket");
assert!(
get_provider(&storage).is_some(),
"get_provider must recognise the \"rclone\" key"
);
}
#[tokio::test]
async fn provider_uploads_an_unencrypted_backup_to_minio() {
init_tracing_for_test();
let (_container, endpoint) = start_minio().await;
let config_text = minio_config(&endpoint);
let bootstrap = write_config(&config_text).unwrap();
rclone_ok(bootstrap.path(), &["mkdir", &format!("minio:{BUCKET}")]);
let payload = vec![42u8; 64 * 1024];
let mut backup_file = NamedTempFile::new().unwrap();
backup_file.write_all(&payload).unwrap();
backup_file.flush().unwrap();
let storage = storage_for(&config_text, BUCKET);
let result = RcloneProvider {}
.upload(
test_context(),
BackupResult {
generated_id: "db-1".to_string(),
db_type: DbType::Postgresql,
status: "success".to_string(),
backup_file: Some(backup_file.path().to_path_buf()),
code: None,
},
BackupMethod::Automatic,
&storage,
Some(false),
"backup-storage-1",
)
.await;
assert!(result.success, "upload failed: {:?}", result.error);
assert_eq!(result.total_size, Some(payload.len() as u64));
let remote_file_path = result.remote_file_path.expect("remote path must be reported");
assert!(
remote_file_path.starts_with("backups/"),
"folder_name must prefix the path: {remote_file_path}"
);
let target = remote_target("minio", BUCKET, &remote_file_path);
assert_eq!(rclone_ok(bootstrap.path(), &["cat", &target]), payload);
}
#[tokio::test]
async fn provider_refuses_a_blocked_backend_without_spawning_rclone() {
init_tracing_for_test();
let mut backup_file = NamedTempFile::new().unwrap();
backup_file.write_all(b"payload").unwrap();
backup_file.flush().unwrap();
let storage = storage_for("[minio]\ntype = local\n", "bucket");
let result = RcloneProvider {}
.upload(
test_context(),
BackupResult {
generated_id: "db-1".to_string(),
db_type: DbType::Postgresql,
status: "success".to_string(),
backup_file: Some(backup_file.path().to_path_buf()),
code: None,
},
BackupMethod::Automatic,
&storage,
Some(false),
"backup-storage-1",
)
.await;
assert!(!result.success);
assert!(
result.error.unwrap_or_default().contains("local"),
"the error must name the rejected backend type"
);
}
+1
View File
@@ -4,4 +4,5 @@ mod deserializer;
mod edge_key_tests;
mod file_tests;
mod normalize_cron_tests;
mod retry_tests;
mod stream_tests;
+135
View File
@@ -0,0 +1,135 @@
use crate::services::backup::logger::JobLogger;
use crate::tests::init_tracing_for_test;
use crate::utils::retry::{RetryPolicy, retry};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
fn fast_policy(attempts: u32) -> RetryPolicy {
RetryPolicy {
attempts,
base_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(4),
}
}
#[test]
fn delay_grows_with_the_attempt_number() {
let policy = RetryPolicy {
attempts: 5,
base_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(30),
};
assert!(policy.delay(1) >= Duration::from_millis(50));
assert!(policy.delay(1) <= Duration::from_millis(100));
assert!(policy.delay(2) >= Duration::from_millis(100));
assert!(policy.delay(2) <= Duration::from_millis(200));
assert!(policy.delay(3) >= Duration::from_millis(200));
assert!(policy.delay(3) <= Duration::from_millis(400));
}
#[test]
fn delay_never_exceeds_max_backoff() {
let policy = RetryPolicy {
attempts: 5,
base_backoff: Duration::from_millis(1000),
max_backoff: Duration::from_millis(2000),
};
for attempt in 1..=5 {
assert!(policy.delay(attempt) <= Duration::from_millis(2000));
}
}
#[tokio::test]
async fn first_attempt_success_logs_nothing() {
init_tracing_for_test();
let logger = JobLogger::new();
let result: Result<u32, anyhow::Error> =
retry("Test op", &logger, &fast_policy(3), |_| async { Ok(7) }).await;
assert_eq!(result.unwrap(), 7);
assert!(logger.into_entries().is_empty());
}
#[tokio::test]
async fn retries_until_success_and_logs_each_attempt() {
init_tracing_for_test();
let logger = JobLogger::new();
let calls = AtomicU32::new(0);
let result: Result<u32, anyhow::Error> =
retry("Test op", &logger, &fast_policy(3), |_| async {
let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
if n < 3 {
Err(anyhow::anyhow!("boom {n}"))
} else {
Ok(n)
}
})
.await;
assert_eq!(result.unwrap(), 3);
assert_eq!(calls.load(Ordering::SeqCst), 3);
let entries = logger.into_entries();
let warns: Vec<_> = entries.iter().filter(|e| e.level == "warn").collect();
assert_eq!(warns.len(), 2);
assert!(warns[0].message.starts_with("Test op attempt 1/3 failed: boom 1"));
assert!(warns[1].message.starts_with("Test op attempt 2/3 failed: boom 2"));
let infos: Vec<_> = entries.iter().filter(|e| e.level == "info").collect();
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].message, "Test op succeeded on attempt 3/3");
}
#[tokio::test]
async fn exhausts_attempts_and_logs_no_terminal_error() {
init_tracing_for_test();
let logger = JobLogger::new();
let calls = AtomicU32::new(0);
let result: Result<(), anyhow::Error> =
retry("Test op", &logger, &fast_policy(3), |_| async {
calls.fetch_add(1, Ordering::SeqCst);
Err(anyhow::anyhow!("always"))
})
.await;
assert!(result.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 3);
let entries = logger.into_entries();
assert_eq!(entries.iter().filter(|e| e.level == "warn").count(), 2);
assert_eq!(entries.iter().filter(|e| e.level == "error").count(), 0);
}
#[tokio::test]
async fn closure_receives_the_attempt_number() {
init_tracing_for_test();
let logger = JobLogger::new();
let seen = Mutex::new(Vec::new());
let seen_ref = &seen;
let result: Result<(), anyhow::Error> =
retry("Test op", &logger, &fast_policy(3), move |attempt| async move {
seen_ref.lock().unwrap().push(attempt);
Err(anyhow::anyhow!("nope"))
})
.await;
assert!(result.is_err());
assert_eq!(*seen.lock().unwrap(), vec![1, 2, 3]);
}
#[test]
fn config_defaults_are_within_the_documented_range() {
let policy = RetryPolicy::default();
assert!(policy.attempts >= 3 && policy.attempts <= 5);
assert!(policy.base_backoff >= Duration::from_millis(100));
assert!(policy.base_backoff <= Duration::from_millis(30_000));
}
+1
View File
@@ -6,6 +6,7 @@ pub mod file;
pub mod locks;
pub mod logging;
pub mod redis_client;
pub mod retry;
pub mod stream;
pub mod task_manager;
pub mod text;
+75
View File
@@ -0,0 +1,75 @@
use crate::services::backup::logger::JobLogger;
use crate::settings::CONFIG;
use rand::Rng;
use std::fmt::Display;
use std::future::Future;
use std::time::Duration;
pub struct RetryPolicy {
pub attempts: u32,
pub base_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
attempts: CONFIG.retry_attempts,
base_backoff: Duration::from_millis(CONFIG.retry_backoff_ms),
max_backoff: Duration::from_secs(30),
}
}
}
impl RetryPolicy {
pub(crate) fn delay(&self, attempt: u32) -> Duration {
let exp = self.base_backoff.saturating_mul(1u32 << (attempt - 1).min(16));
let capped = exp.min(self.max_backoff);
let half = capped / 2;
let jitter = rand::rng().random_range(0..=half.as_millis() as u64);
half + Duration::from_millis(jitter)
}
}
pub async fn retry<T, E, F, Fut>(
op: &str,
logger: &JobLogger,
policy: &RetryPolicy,
mut f: F,
) -> Result<T, E>
where
F: FnMut(u32) -> Fut,
Fut: Future<Output = Result<T, E>> + Send,
T: Send,
E: Display + Send,
{
let total = policy.attempts;
let mut attempt = 1;
loop {
match f(attempt).await {
Ok(v) => {
if attempt > 1 {
logger.log("info", format!("{op} succeeded on attempt {attempt}/{total}"));
}
return Ok(v);
}
Err(e) if attempt < total => {
let delay = policy.delay(attempt);
logger.log(
"warn",
format!(
"{op} attempt {attempt}/{total} failed: {e} - retrying in {}ms",
delay.as_millis()
),
);
tokio::time::sleep(delay).await;
attempt += 1;
}
Err(e) => {
return Err(e);
}
}
}
}
+6 -1
View File
@@ -79,7 +79,12 @@ pub async fn execute_task(
let ctx = Arc::new(Context::new());
let config_service = ConfigService::new(ctx.clone());
let backup_service = BackupService::new(ctx.clone());
let config = config_service.load(None).unwrap();
let local = config_service.load_optional(None);
let cache_path = std::path::PathBuf::from(&crate::settings::CONFIG.data_path)
.join("dashboard_databases.json");
let dashboard = crate::services::dashboard_config::load_cache(&cache_path);
let config = crate::services::dashboard_config::merge(&local.databases, &dashboard);
let metadata_obj = metadata
.into_iter()