mirror of
https://github.com/Portabase/agent.git
synced 2026-10-12 07:49:34 +00:00
5d87fb2d7d
Back up and restore plain directories, alongside the database sources. Files source and Archive (P0) - `files` type: absolute path (not /), options.exclude and options.one_file_system; walk + tar.gz with symlinks kept as links, special files skipped, unreadable entries fail the run. - Mirror restore: archive validated first, then everything not excluded is wiped (mount points and special files kept) and the archive is extracted, retrying without metadata on filesystems refusing chown/chmod. - Windows-safe platform helpers; helm extraVolumes/extraVolumeMounts. Universal rclone mapper (P1) - rclone_target(storage) for s3, blob, gcs, drive, rclone and sftp; sftp goes through it; rclone obscure reads the password from stdin. Snapshots, restic (P2) - restic in the image; repository per source and channel, password derived from the master key (HKDF); stable host, bs:<row> tags, translated excludes; exit 3 fails and forgets the snapshot. - Mirror restore with `restic restore --delete`; restore payload carries the encrypted channel; snapshot ids must be full ids. - Engine in the ping and in scheduled task metadata. One locked method per source (spec A) - databases.json declares options.method (archive, snapshot, sync), reported in the ping for local sources only. - Sync: rclone sync per channel onto <folder>/sync/<id>/current, refuses an empty source, reports transferred/deleted counters. Local channel (spec B) - Snapshots and Sync reach the dashboard's local storage through its append-only restic REST server and its WebDAV server. Security - Strict rclone config validation: one section, no duplicate keys, no command, credential or host-file keys, no quoted values, valid backend type, blocked backends. - Scheduled task metadata (decrypted storages) is no longer logged.
100 lines
3.4 KiB
Rust
100 lines
3.4 KiB
Rust
use crate::services::storage::providers::google_cloud_storage::helpers::{
|
|
StreamSource, upload_with_client,
|
|
};
|
|
use crate::tests::init_tracing_for_test;
|
|
|
|
use bytes::Bytes;
|
|
use futures::stream;
|
|
use google_cloud_storage::client::Storage;
|
|
use testcontainers::core::{IntoContainerPort, WaitFor};
|
|
use testcontainers::runners::AsyncRunner;
|
|
use testcontainers::{GenericImage, ImageExt};
|
|
|
|
const BUCKET: &str = "portabase";
|
|
|
|
pub(super) async fn start_fake_gcs() -> (testcontainers::ContainerAsync<GenericImage>, String) {
|
|
// Natural random host port (no port-80 pin). The provider forces a single-shot
|
|
// upload for custom endpoints, which issues one request to this endpoint and never
|
|
// follows a server-built `Location` — so it works on any port, unlike the resumable
|
|
// path that the SDK's Host-header port-drop bug breaks on non-443 ports.
|
|
let container = GenericImage::new("fsouza/fake-gcs-server", "latest")
|
|
.with_exposed_port(4443.tcp())
|
|
.with_wait_for(WaitFor::message_on_stderr("server started at"))
|
|
.with_cmd(["-scheme", "http", "-backend", "memory", "-port", "4443"])
|
|
.start()
|
|
.await
|
|
.unwrap();
|
|
|
|
let host = container.get_host().await.unwrap().to_string();
|
|
let port = container.get_host_port_ipv4(4443).await.unwrap();
|
|
let endpoint = format!("http://{host}:{port}");
|
|
(container, endpoint)
|
|
}
|
|
|
|
async fn anon_client(endpoint: &str) -> Storage {
|
|
let creds = google_cloud_auth::credentials::anonymous::Builder::new().build();
|
|
Storage::builder()
|
|
.with_credentials(creds)
|
|
.with_endpoint(endpoint.to_string())
|
|
.build()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn create_bucket(endpoint: &str) {
|
|
let url = format!("{endpoint}/storage/v1/b?project=test-project");
|
|
let res = reqwest::Client::new()
|
|
.post(&url)
|
|
.json(&serde_json::json!({ "name": BUCKET }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
res.status().is_success(),
|
|
"bucket create failed: {}",
|
|
res.status()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_stream_roundtrip_against_fake_gcs() {
|
|
init_tracing_for_test();
|
|
let (_container, endpoint) = start_fake_gcs().await;
|
|
create_bucket(&endpoint).await;
|
|
|
|
let object = "backups/multi.bin";
|
|
|
|
// 10 KiB fed as 1 KiB chunks -> multi-chunk streaming path.
|
|
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 source = StreamSource::from_stream(Box::pin(stream::iter(chunks)), data.len() as u64);
|
|
|
|
let client = anon_client(&endpoint).await;
|
|
|
|
// force_single_shot = true (custom endpoint). Guard with a timeout so a regression
|
|
// into the resumable path (which would hang forever on this non-443 port) fails the
|
|
// test instead of stalling it.
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(60),
|
|
upload_with_client(&client, BUCKET, object, source, true),
|
|
)
|
|
.await
|
|
.expect("upload hung (regressed to resumable path on a non-443 endpoint?)")
|
|
.unwrap();
|
|
|
|
let read_url = format!(
|
|
"{endpoint}/storage/v1/b/{BUCKET}/o/{}?alt=media",
|
|
object.replace('/', "%2F")
|
|
);
|
|
let got = reqwest::get(&read_url)
|
|
.await
|
|
.unwrap()
|
|
.bytes()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(got.as_ref(), data.as_slice());
|
|
}
|