use anyhow::{Context, Result};
use object_store::aws::AmazonS3Builder;
use std::env;
use std::process::ExitCode;
use std::sync::Arc;
use turso_backup::snapshot::{snapshot_and_upload, BackupTarget, SnapshotOutcome};
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
match run().await {
Ok(outcome) => {
println!("{}", outcome_to_json(&outcome));
ExitCode::SUCCESS
}
Err(err) => {
println!(
"{{\"outcome\":\"error\",\"message\":{}}}",
json_string(&format!("{err:#}"))
);
ExitCode::FAILURE
}
}
}
async fn run() -> Result<SnapshotOutcome> {
let db_path = env::var("DB_PATH").context("DB_PATH is required")?;
let target = backup_target_from_env()?;
snapshot_and_upload(&db_path, &target)
.await
.with_context(|| format!("snapshot_and_upload({db_path})"))
}
fn backup_target_from_env() -> Result<BackupTarget> {
let endpoint = env_or("S3_ENDPOINT", "http://minio:9000");
let bucket = env::var("S3_BUCKET").context("S3_BUCKET is required")?;
let access = env::var("S3_ACCESS_KEY").context("S3_ACCESS_KEY is required")?;
let secret = env::var("S3_SECRET_KEY").context("S3_SECRET_KEY is required")?;
let region = env_or("S3_REGION", "us-east-1");
let prefix = env_or("BACKUP_PREFIX", "snap");
let store = AmazonS3Builder::new()
.with_endpoint(&endpoint)
.with_bucket_name(&bucket)
.with_access_key_id(&access)
.with_secret_access_key(&secret)
.with_region(®ion)
.with_allow_http(true)
.with_virtual_hosted_style_request(false)
.build()
.with_context(|| format!("building S3-shaped store for {endpoint} bucket={bucket}"))?;
Ok(BackupTarget {
store: Arc::new(store),
prefix,
})
}
fn env_or(key: &str, default: &str) -> String {
env::var(key).unwrap_or_else(|_| default.to_string())
}
fn outcome_to_json(outcome: &SnapshotOutcome) -> String {
match outcome {
SnapshotOutcome::Uploaded {
key,
bytes,
snapshot_hash,
} => format!(
"{{\"outcome\":\"uploaded\",\"key\":{},\"bytes\":{bytes},\"snapshot_hash\":{}}}",
json_string(key),
json_string(snapshot_hash)
),
SnapshotOutcome::Unchanged { source_hash } => format!(
"{{\"outcome\":\"unchanged\",\"source_hash\":{}}}",
json_string(source_hash)
),
SnapshotOutcome::Deduplicated { snapshot_hash } => format!(
"{{\"outcome\":\"deduplicated\",\"snapshot_hash\":{}}}",
json_string(snapshot_hash)
),
}
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uploaded_outcome_serialises() {
let line = outcome_to_json(&SnapshotOutcome::Uploaded {
key: "snap/snapshots/snapshot-00000000001700000000000000000.db".into(),
bytes: 446 * 1024,
snapshot_hash: "abc123".into(),
});
assert!(line.starts_with("{\"outcome\":\"uploaded\""));
assert!(line.contains("\"bytes\":456704"));
assert!(line.contains("\"snapshot_hash\":\"abc123\""));
}
#[test]
fn unchanged_outcome_serialises() {
let line = outcome_to_json(&SnapshotOutcome::Unchanged {
source_hash: "deadbeef".into(),
});
assert_eq!(line, "{\"outcome\":\"unchanged\",\"source_hash\":\"deadbeef\"}");
}
#[test]
fn deduplicated_outcome_serialises() {
let line = outcome_to_json(&SnapshotOutcome::Deduplicated {
snapshot_hash: "cafef00d".into(),
});
assert_eq!(
line,
"{\"outcome\":\"deduplicated\",\"snapshot_hash\":\"cafef00d\"}"
);
}
#[test]
fn json_string_escapes_problem_characters() {
assert_eq!(json_string("simple"), "\"simple\"");
assert_eq!(json_string("with\"quote"), "\"with\\\"quote\"");
assert_eq!(json_string("back\\slash"), "\"back\\\\slash\"");
assert_eq!(json_string("new\nline"), "\"new\\nline\"");
assert_eq!(json_string("tab\there"), "\"tab\\there\"");
assert_eq!(json_string("\x01"), "\"\\u0001\"");
}
}