| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | |
|
| 13 | |
|
| 14 | |
|
| 15 | |
|
| 16 | | use crate::db::{self, Pool}; |
| 17 | | use crate::repos::RepoName; |
| 18 | | use crate::Shared; |
| 19 | | use anyhow::{bail, Context, Result}; |
| 20 | | use serde::{Deserialize, Serialize}; |
| 21 | | use std::collections::HashMap; |
| 22 | | use std::path::{Path, PathBuf}; |
| 23 | |
|
| 24 | |
|
| 25 | | pub const EVERY: &[(&str, &str)] = &[("off", "Off"), ("hourly", "Every hour"), ("daily", "Every day")]; |
| 26 | |
|
| 27 | | pub const KEEP: &[i64] = &[7, 30, 90]; |
| 28 | |
|
| 29 | |
|
| 30 | | #[derive(Clone, Debug, sqlx::FromRow, Serialize)] |
| 31 | | pub struct Setting { |
| 32 | | pub owner: String, |
| 33 | | pub kind: String, |
| 34 | | pub every: String, |
| 35 | | pub destination: String, |
| 36 | | pub target: String, |
| 37 | | pub prefix: String, |
| 38 | | pub provider: String, |
| 39 | | pub keep_days: i64, |
| 40 | | pub updated_ms: i64, |
| 41 | | |
| 42 | | #[sqlx(default)] |
| 43 | | pub region: String, |
| 44 | | } |
| 45 | |
|
| 46 | | impl Setting { |
| 47 | 18× | pub fn off(owner: &str, kind: &str) -> Setting { |
| 48 | 18× | Setting { |
| 49 | 18× | owner: owner.into(), |
| 50 | 18× | kind: kind.into(), |
| 51 | 18× | every: "off".into(), |
| 52 | 18× | destination: "dir".into(), |
| 53 | 18× | target: String::new(), |
| 54 | 18× | prefix: String::new(), |
| 55 | 18× | provider: String::new(), |
| 56 | 18× | keep_days: 30, |
| 57 | 18× | updated_ms: 0, |
| 58 | 18× | region: String::new(), |
| 59 | 18× | } |
| 60 | 18× | } |
| 61 | |
|
| 62 | | |
| 63 | 6× | pub fn period_s(&self) -> Option<i64> { |
| 64 | 6× | match self.every.as_str() { |
| 65 | 6× | "hourly" => Some(3600), |
| 66 | 4× | "daily" => Some(86_400), |
| 67 | 2× | _ => None, |
| 68 | | } |
| 69 | 6× | } |
| 70 | | } |
| 71 | |
|
| 72 | |
|
| 73 | | #[derive(Clone, Debug, sqlx::FromRow, Serialize)] |
| 74 | | pub struct Run { |
| 75 | | pub id: i64, |
| 76 | | pub owner: String, |
| 77 | | pub started_ms: i64, |
| 78 | | pub finished_ms: Option<i64>, |
| 79 | | pub status: String, |
| 80 | | pub at: String, |
| 81 | | pub repos: i64, |
| 82 | | pub files: i64, |
| 83 | | pub sent: i64, |
| 84 | | pub bytes: i64, |
| 85 | | pub detail: String, |
| 86 | | pub trigger: String, |
| 87 | | } |
| 88 | |
|
| 89 | 165× | pub async fn setting(pool: &Pool, owner: &str) -> sqlx::Result<Option<Setting>> { |
| 90 | 165× | sqlx::query_as("SELECT * FROM backup_settings WHERE owner = $1").bind(owner).fetch_optional(pool).await |
| 91 | 165× | } |
| 92 | |
|
| 93 | 17× | pub async fn save(pool: &Pool, s: &Setting) -> sqlx::Result<()> { |
| 94 | 17× | sqlx::query( |
| 95 | 17× | "INSERT INTO backup_settings (owner, kind, every, destination, target, prefix, provider, keep_days, updated_ms, region) \ |
| 96 | 17× | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT(owner) DO UPDATE SET kind = excluded.kind, every = excluded.every, \ |
| 97 | 17× | destination = excluded.destination, target = excluded.target, prefix = excluded.prefix, provider = excluded.provider, \ |
| 98 | 17× | keep_days = excluded.keep_days, updated_ms = excluded.updated_ms, region = excluded.region", |
| 99 | 17× | ) |
| 100 | 17× | .bind(&s.owner) |
| 101 | 17× | .bind(&s.kind) |
| 102 | 17× | .bind(&s.every) |
| 103 | 17× | .bind(&s.destination) |
| 104 | 17× | .bind(&s.target) |
| 105 | 17× | .bind(&s.prefix) |
| 106 | 17× | .bind(&s.provider) |
| 107 | 17× | .bind(s.keep_days) |
| 108 | 17× | .bind(db::now_ms()) |
| 109 | 17× | .bind(&s.region) |
| 110 | 17× | .execute(pool) |
| 111 | 17× | .await?; |
| 112 | 17× | Ok(()) |
| 113 | 17× | } |
| 114 | |
|
| 115 | 17× | pub async fn runs(pool: &Pool, owner: &str, limit: i64) -> sqlx::Result<Vec<Run>> { |
| 116 | 17× | sqlx::query_as("SELECT * FROM backup_runs WHERE owner = $1 ORDER BY id DESC LIMIT $2").bind(owner).bind(limit).fetch_all(pool).await |
| 117 | 17× | } |
| 118 | |
|
| 119 | |
|
| 120 | |
|
| 121 | | pub const REPO_CHOICES: &[(&str, &str)] = |
| 122 | | &[("", "With its owner's backup"), ("hourly", "At least every hour"), ("daily", "At least every day"), ("off", "Left out")]; |
| 123 | |
|
| 124 | |
|
| 125 | |
|
| 126 | |
|
| 127 | 25× | pub fn included(s: &Setting, row: &db::repos::RepoRow, by_hand: bool) -> bool { |
| 128 | 25× | match row.backup.as_str() { |
| 129 | 25× | "off" => false, |
| 130 | 21× | "hourly" | "daily" => true, |
| 131 | 18× | _ => by_hand || s.every != "off", |
| 132 | | } |
| 133 | 25× | } |
| 134 | |
|
| 135 | |
|
| 136 | |
|
| 137 | 6× | pub fn period_with(s: &Setting, repos: &[db::repos::RepoRow]) -> Option<i64> { |
| 138 | 9× | let own = repos.iter().filter_map(|r| match r.backup.as_str() { |
| 139 | 9× | "hourly" => Some(3600), |
| 140 | 7× | "daily" => Some(86_400), |
| 141 | 7× | _ => None, |
| 142 | 9× | }); |
| 143 | 6× | s.period_s().into_iter().chain(own).min() |
| 144 | 6× | } |
| 145 | |
|
| 146 | |
|
| 147 | |
|
| 148 | |
|
| 149 | | #[derive(Clone, Debug, Serialize)] |
| 150 | | pub struct Overview { |
| 151 | | pub setting: Setting, |
| 152 | | pub last: Option<Run>, |
| 153 | | pub failed_day: i64, |
| 154 | | |
| 155 | | pub repos: i64, |
| 156 | | } |
| 157 | |
|
| 158 | | impl Overview { |
| 159 | | |
| 160 | 1× | pub fn place(&self) -> String { |
| 161 | 1× | place(&self.setting) |
| 162 | 1× | } |
| 163 | | } |
| 164 | |
|
| 165 | |
|
| 166 | 8× | pub fn place(s: &Setting) -> String { |
| 167 | 8× | match s.destination.as_str() { |
| 168 | 8× | "gcs" if s.prefix.is_empty() => format!("gs://{}", s.target), |
| 169 | 0× | "gcs" => format!("gs://{}/{}", s.target, s.prefix), |
| 170 | 7× | "s3" if s.prefix.is_empty() => format!("s3://{} ({})", s.target, s.region), |
| 171 | 0× | "s3" => format!("s3://{}/{} ({})", s.target, s.prefix, s.region), |
| 172 | 7× | _ => format!("{}/{}", s.target, s.owner), |
| 173 | | } |
| 174 | 8× | } |
| 175 | |
|
| 176 | |
|
| 177 | |
|
| 178 | 4× | pub async fn overview(pool: &Pool) -> sqlx::Result<(Vec<Overview>, Vec<(String, i64)>)> { |
| 179 | 4× | let settings: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings ORDER BY owner").fetch_all(pool).await?; |
| 180 | 4× | let counts: Vec<(String, i64)> = |
| 181 | 4× | sqlx::query_as("SELECT owner, COUNT(*) FROM repositories GROUP BY owner ORDER BY owner").fetch_all(pool).await?; |
| 182 | 4× | let day_ago = db::now_ms() - 86_400_000; |
| 183 | 4× | let mut out = Vec::new(); |
| 184 | 4× | for s in settings { |
| 185 | 3× | let last = runs(pool, &s.owner, 1).await?.into_iter().next(); |
| 186 | 3× | let failed_day: i64 = |
| 187 | 3× | sqlx::query_scalar("SELECT COUNT(*) FROM backup_runs WHERE owner = $1 AND status = 'failed' AND started_ms >= $2") |
| 188 | 3× | .bind(&s.owner) |
| 189 | 3× | .bind(day_ago) |
| 190 | 3× | .fetch_one(pool) |
| 191 | 3× | .await?; |
| 192 | 6× | let repos = db::repos::list_with_drops(pool).await?.iter().filter(|r| r.owner == s.owner && included(&s, r, false)).count() as i64; |
| 193 | 3× | out.push(Overview { setting: s, last, failed_day, repos }); |
| 194 | | } |
| 195 | 4× | let uncovered = counts.into_iter().filter(|(owner, _)| !out.iter().any(|o| o.setting.owner == *owner && o.repos > 0)).collect(); |
| 196 | 4× | Ok((out, uncovered)) |
| 197 | 4× | } |
| 198 | |
|
| 199 | |
|
| 200 | |
|
| 201 | |
|
| 202 | | pub trait Destination: Send + Sync { |
| 203 | | fn exists(&self, key: &str) -> Result<bool>; |
| 204 | | |
| 205 | | fn put(&self, key: &str, bytes: &[u8]) -> Result<()>; |
| 206 | | fn get(&self, key: &str) -> Result<Vec<u8>>; |
| 207 | | |
| 208 | | fn list(&self, prefix: &str) -> Result<Vec<String>>; |
| 209 | | |
| 210 | | fn remove(&self, key: &str) -> Result<()>; |
| 211 | | } |
| 212 | |
|
| 213 | |
|
| 214 | | pub struct Directory { |
| 215 | | pub root: PathBuf, |
| 216 | | } |
| 217 | |
|
| 218 | | impl Directory { |
| 219 | 303× | fn path(&self, key: &str) -> Result<PathBuf> { |
| 220 | 868× | if key.split('/').any(|p| p == ".." || p == ".") || key.starts_with('/') { |
| 221 | 2× | bail!("not a clean key: {key}"); |
| 222 | 301× | } |
| 223 | 301× | Ok(self.root.join(key)) |
| 224 | 303× | } |
| 225 | | } |
| 226 | |
|
| 227 | | impl Destination for Directory { |
| 228 | 98× | fn exists(&self, key: &str) -> Result<bool> { |
| 229 | 98× | Ok(self.path(key)?.exists()) |
| 230 | 98× | } |
| 231 | 107× | fn put(&self, key: &str, bytes: &[u8]) -> Result<()> { |
| 232 | 107× | let p = self.path(key)?; |
| 233 | 105× | std::fs::create_dir_all(p.parent().context("no parent")?)?; |
| 234 | 105× | let tmp = p.with_extension(format!("part-{}", std::process::id())); |
| 235 | 105× | std::fs::write(&tmp, bytes)?; |
| 236 | 105× | std::fs::rename(&tmp, &p)?; |
| 237 | 105× | Ok(()) |
| 238 | 107× | } |
| 239 | 70× | fn get(&self, key: &str) -> Result<Vec<u8>> { |
| 240 | 70× | let p = self.path(key)?; |
| 241 | 70× | std::fs::read(&p).with_context(|| format!("read {}", p.display())) |
| 242 | 70× | } |
| 243 | 26× | fn list(&self, prefix: &str) -> Result<Vec<String>> { |
| 244 | 26× | let p = self.path(prefix)?; |
| 245 | 26× | let mut out: Vec<String> = match std::fs::read_dir(&p) { |
| 246 | 37× | Ok(d) => d.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(), |
| 247 | 1× | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), |
| 248 | 0× | Err(e) => return Err(e.into()), |
| 249 | | }; |
| 250 | 26× | out.sort(); |
| 251 | 26× | Ok(out) |
| 252 | 26× | } |
| 253 | 2× | fn remove(&self, key: &str) -> Result<()> { |
| 254 | 2× | let p = self.path(key)?; |
| 255 | 2× | if p.is_dir() { |
| 256 | 1× | std::fs::remove_dir_all(p)?; |
| 257 | 1× | } else if p.exists() { |
| 258 | 1× | std::fs::remove_file(p)?; |
| 259 | 0× | } |
| 260 | 2× | Ok(()) |
| 261 | 2× | } |
| 262 | | } |
| 263 | |
|
| 264 | |
|
| 265 | |
|
| 266 | |
|
| 267 | |
|
| 268 | |
|
| 269 | | pub struct Gcs { |
| 270 | | bucket: String, |
| 271 | | |
| 272 | | prefix: String, |
| 273 | | storage: String, |
| 274 | | sts: String, |
| 275 | | |
| 276 | | provider: String, |
| 277 | | subject: String, |
| 278 | | app: Shared, |
| 279 | | client: reqwest::Client, |
| 280 | | rt: tokio::runtime::Handle, |
| 281 | | token: std::sync::Mutex<Option<(String, i64)>>, |
| 282 | | } |
| 283 | |
|
| 284 | | impl Gcs { |
| 285 | 5× | pub fn new(app: &Shared, s: &Setting) -> Gcs { |
| 286 | 5× | Gcs { |
| 287 | 5× | bucket: s.target.clone(), |
| 288 | 5× | prefix: s.prefix.trim_matches('/').to_string(), |
| 289 | 5× | storage: app.config.backups.storage_url.trim_end_matches('/').to_string(), |
| 290 | 5× | sts: app.config.backups.sts_url.trim_end_matches('/').to_string(), |
| 291 | 5× | provider: s.provider.clone(), |
| 292 | 5× | subject: format!("backup:{}", s.owner), |
| 293 | 5× | app: app.clone(), |
| 294 | 5× | client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(), |
| 295 | 5× | rt: tokio::runtime::Handle::current(), |
| 296 | 5× | token: std::sync::Mutex::new(None), |
| 297 | 5× | } |
| 298 | 5× | } |
| 299 | |
|
| 300 | 66× | fn name(&self, key: &str) -> String { |
| 301 | 66× | if self.prefix.is_empty() { |
| 302 | 66× | key.to_string() |
| 303 | | } else { |
| 304 | 0× | format!("{}/{key}", self.prefix) |
| 305 | | } |
| 306 | 66× | } |
| 307 | |
|
| 308 | | |
| 309 | | |
| 310 | 66× | fn access(&self) -> Result<String> { |
| 311 | 66× | let now = db::now_ms(); |
| 312 | 66× | if let Some((t, until)) = self.token.lock().unwrap().as_ref() { |
| 313 | 61× | if *until - now > 300_000 { |
| 314 | 61× | return Ok(t.clone()); |
| 315 | 0× | } |
| 316 | 5× | } |
| 317 | 5× | let (token, expires_in) = self.rt.block_on(async { |
| 318 | 5× | let issuer = self.app.oidc().await?; |
| 319 | | |
| 320 | 5× | let audience = format!("https:{}", self.provider); |
| 321 | 5× | let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, &audience, db::now_ms(), 600); |
| 322 | 5× | let res = self |
| 323 | 5× | .client |
| 324 | 5× | .post(format!("{}/v1/token", self.sts)) |
| 325 | 5× | .json(&serde_json::json!({ |
| 326 | 5× | "grantType": "urn:ietf:params:oauth:grant-type:token-exchange", |
| 327 | 5× | "audience": self.provider, |
| 328 | 5× | "scope": "https://www.googleapis.com/auth/devstorage.read_write", |
| 329 | 5× | "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token", |
| 330 | 5× | "subjectToken": jwt, |
| 331 | 5× | "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt", |
| 332 | 5× | })) |
| 333 | 5× | .send() |
| 334 | 5× | .await?; |
| 335 | 5× | let status = res.status(); |
| 336 | 5× | let body: serde_json::Value = res.json().await.unwrap_or_default(); |
| 337 | 5× | if !status.is_success() { |
| 338 | 0× | bail!( |
| 339 | | "Google's token exchange refused {}: {} (check the provider's issuer is this hub's address and its attribute condition admits backup:)", |
| 340 | | self.subject, |
| 341 | 0× | body["error_description"].as_str().or(body["error"].as_str()).unwrap_or("no reason given") |
| 342 | | ); |
| 343 | 5× | } |
| 344 | 5× | let token = body["access_token"].as_str().context("the token exchange gave no access token")?.to_string(); |
| 345 | 5× | anyhow::Ok((token, body["expires_in"].as_i64().unwrap_or(3600))) |
| 346 | 5× | })?; |
| 347 | 5× | *self.token.lock().unwrap() = Some((token.clone(), now + expires_in * 1000)); |
| 348 | 5× | Ok(token) |
| 349 | 66× | } |
| 350 | |
|
| 351 | 37× | fn object_url(&self, key: &str) -> Result<reqwest::Url> { |
| 352 | 37× | let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 353 | 37× | u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&self.name(key)); |
| 354 | 37× | Ok(u) |
| 355 | 37× | } |
| 356 | |
|
| 357 | 3× | fn refused(&self, what: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error { |
| 358 | 3× | let hint = if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::UNAUTHORIZED { |
| 359 | 2× | format!(" (grant roles/storage.objectAdmin on gs://{} to the principal …/subject/{})", self.bucket, self.subject) |
| 360 | | } else { |
| 361 | 1× | String::new() |
| 362 | | }; |
| 363 | 3× | anyhow::anyhow!("GCS {what}: {status}{hint}: {}", body.chars().take(200).collect::<String>()) |
| 364 | 3× | } |
| 365 | |
|
| 366 | | |
| 367 | 1× | fn all_under(&self, full: &str) -> Result<Vec<String>> { |
| 368 | 1× | let mut out = Vec::new(); |
| 369 | 1× | let mut page: Option<String> = None; |
| 370 | | loop { |
| 371 | 1× | let token = self.access()?; |
| 372 | 1× | let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 373 | 1× | u.query_pairs_mut().append_pair("prefix", full).append_pair("fields", "items(name),nextPageToken"); |
| 374 | 1× | if let Some(p) = &page { |
| 375 | 0× | u.query_pairs_mut().append_pair("pageToken", p); |
| 376 | 1× | } |
| 377 | 1× | let body: serde_json::Value = self.rt.block_on(async { |
| 378 | 1× | let res = self.client.get(u).bearer_auth(&token).send().await?; |
| 379 | 1× | let status = res.status(); |
| 380 | 1× | let text = res.text().await?; |
| 381 | 1× | if !status.is_success() { |
| 382 | 0× | return Err(self.refused("list", status, &text)); |
| 383 | 1× | } |
| 384 | 1× | anyhow::Ok(serde_json::from_str(&text)?) |
| 385 | 1× | })?; |
| 386 | 1× | out.extend(body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str().map(String::from))); |
| 387 | 1× | match body["nextPageToken"].as_str() { |
| 388 | 0× | Some(p) => page = Some(p.to_string()), |
| 389 | 1× | None => return Ok(out), |
| 390 | | } |
| 391 | | } |
| 392 | 1× | } |
| 393 | | } |
| 394 | |
|
| 395 | |
|
| 396 | |
|
| 397 | |
|
| 398 | |
|
| 399 | | pub struct S3 { |
| 400 | | bucket: String, |
| 401 | | prefix: String, |
| 402 | | region: String, |
| 403 | | |
| 404 | | base: reqwest::Url, |
| 405 | | sts: String, |
| 406 | | |
| 407 | | role: String, |
| 408 | | subject: String, |
| 409 | | app: Shared, |
| 410 | | client: reqwest::Client, |
| 411 | | rt: tokio::runtime::Handle, |
| 412 | | creds: std::sync::Mutex<Option<Creds>>, |
| 413 | | } |
| 414 | |
|
| 415 | | #[derive(Clone)] |
| 416 | | struct Creds { |
| 417 | | id: String, |
| 418 | | secret: String, |
| 419 | | token: String, |
| 420 | | until: i64, |
| 421 | | } |
| 422 | |
|
| 423 | |
|
| 424 | | pub const AWS_AUDIENCE: &str = "sts.amazonaws.com"; |
| 425 | |
|
| 426 | |
|
| 427 | |
|
| 428 | 164× | pub fn aws_encode(s: &str, keep_slash: bool) -> String { |
| 429 | 164× | let mut out = String::new(); |
| 430 | 4746× | for b in s.bytes() { |
| 431 | 238× | match b { |
| 432 | 4428× | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), |
| 433 | 168× | b'/' if keep_slash => out.push('/'), |
| 434 | 150× | _ => out.push_str(&format!("%{b:02X}")), |
| 435 | | } |
| 436 | | } |
| 437 | 164× | out |
| 438 | 164× | } |
| 439 | |
|
| 440 | 665× | fn hmac(key: &[u8], data: &str) -> Vec<u8> { |
| 441 | | use hmac::Mac; |
| 442 | 665× | let mut m = hmac::Hmac::<sha2::Sha256>::new_from_slice(key).expect("any key length"); |
| 443 | 665× | m.update(data.as_bytes()); |
| 444 | 665× | m.finalize().into_bytes().to_vec() |
| 445 | 665× | } |
| 446 | |
|
| 447 | 199× | fn sha256_hex(data: &[u8]) -> String { |
| 448 | | use sha2::Digest; |
| 449 | 199× | hex::encode(sha2::Sha256::digest(data)) |
| 450 | 199× | } |
| 451 | |
|
| 452 | |
|
| 453 | |
|
| 454 | |
|
| 455 | |
|
| 456 | | #[allow(clippy::too_many_arguments)] |
| 457 | 133× | pub fn sigv4( |
| 458 | 133× | method: &str, |
| 459 | 133× | uri: &str, |
| 460 | 133× | query: &[(String, String)], |
| 461 | 133× | headers: &[(String, String)], |
| 462 | 133× | payload: &str, |
| 463 | 133× | (id, secret): (&str, &str), |
| 464 | 133× | region: &str, |
| 465 | 133× | service: &str, |
| 466 | 133× | amz_date: &str, |
| 467 | 133× | ) -> String { |
| 468 | 133× | let mut q: Vec<(String, String)> = query.iter().map(|(k, v)| (aws_encode(k, false), aws_encode(v, false))).collect(); |
| 469 | 133× | q.sort(); |
| 470 | 133× | let query = q.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&"); |
| 471 | 532× | let mut h: Vec<(String, String)> = headers.iter().map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string())).collect(); |
| 472 | 133× | h.sort(); |
| 473 | 532× | let canonical_headers: String = h.iter().map(|(k, v)| format!("{k}:{v}\n")).collect(); |
| 474 | 532× | let signed = h.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>().join(";"); |
| 475 | 133× | let request = format!("{method}\n{uri}\n{query}\n{canonical_headers}\n{signed}\n{payload}"); |
| 476 | 133× | let date = &amz_date[..8]; |
| 477 | 133× | let scope = format!("{date}/{region}/{service}/aws4_request"); |
| 478 | 133× | let to_sign = format!("AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", sha256_hex(request.as_bytes())); |
| 479 | 133× | let key = hmac(&hmac(&hmac(&hmac(format!("AWS4{secret}").as_bytes(), date), region), service), "aws4_request"); |
| 480 | 133× | let signature = hex::encode(hmac(&key, &to_sign)); |
| 481 | 133× | format!("AWS4-HMAC-SHA256 Credential={id}/{scope}, SignedHeaders={signed}, Signature={signature}") |
| 482 | 133× | } |
| 483 | |
|
| 484 | |
|
| 485 | 37× | fn xml_text(xml: &str, tag: &str) -> Option<String> { |
| 486 | 37× | xml_blocks(xml, tag).into_iter().next().map(xml_unescape) |
| 487 | 37× | } |
| 488 | |
|
| 489 | |
|
| 490 | 49× | fn xml_blocks<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> { |
| 491 | 49× | let (open, close) = (format!("<{tag}>"), format!("</{tag}>")); |
| 492 | 49× | let mut out = Vec::new(); |
| 493 | 49× | let mut rest = xml; |
| 494 | 85× | while let Some(i) = rest.find(&open) { |
| 495 | 36× | let after = &rest[i + open.len()..]; |
| 496 | 36× | let Some(j) = after.find(&close) else { break }; |
| 497 | 36× | out.push(&after[..j]); |
| 498 | 36× | rest = &after[j + close.len()..]; |
| 499 | | } |
| 500 | 49× | out |
| 501 | 49× | } |
| 502 | |
|
| 503 | 30× | fn xml_unescape(s: &str) -> String { |
| 504 | 30× | s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") |
| 505 | 30× | } |
| 506 | |
|
| 507 | 66× | fn amz_date(ms: i64) -> String { |
| 508 | 66× | let t = time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
| 509 | 66× | format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second()) |
| 510 | 66× | } |
| 511 | |
|
| 512 | | impl S3 { |
| 513 | 5× | pub fn new(app: &Shared, s: &Setting) -> S3 { |
| 514 | 5× | let url = app.config.backups.s3_url.replace("{bucket}", &s.target).replace("{region}", &s.region); |
| 515 | | S3 { |
| 516 | 5× | bucket: s.target.clone(), |
| 517 | 5× | prefix: s.prefix.trim_matches('/').to_string(), |
| 518 | 5× | region: s.region.clone(), |
| 519 | 5× | base: reqwest::Url::parse(url.trim_end_matches('/')).unwrap_or_else(|_| reqwest::Url::parse("https://invalid.").unwrap()), |
| 520 | 5× | sts: app.config.backups.aws_sts_url.trim_end_matches('/').to_string(), |
| 521 | 5× | role: s.provider.clone(), |
| 522 | 5× | subject: format!("backup:{}", s.owner), |
| 523 | 5× | app: app.clone(), |
| 524 | 5× | client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(), |
| 525 | 5× | rt: tokio::runtime::Handle::current(), |
| 526 | 5× | creds: std::sync::Mutex::new(None), |
| 527 | | } |
| 528 | 5× | } |
| 529 | |
|
| 530 | 66× | fn name(&self, key: &str) -> String { |
| 531 | 66× | if self.prefix.is_empty() { |
| 532 | 0× | key.to_string() |
| 533 | | } else { |
| 534 | 66× | format!("{}/{key}", self.prefix) |
| 535 | | } |
| 536 | 66× | } |
| 537 | |
|
| 538 | | |
| 539 | | |
| 540 | 66× | fn creds(&self) -> Result<Creds> { |
| 541 | 66× | let now = db::now_ms(); |
| 542 | 66× | if let Some(c) = self.creds.lock().unwrap().as_ref() { |
| 543 | 61× | if c.until - now > 300_000 { |
| 544 | 61× | return Ok(c.clone()); |
| 545 | 0× | } |
| 546 | 5× | } |
| 547 | 5× | let session: String = format!("oxygenhub-{}", self.subject.trim_start_matches("backup:")) |
| 548 | 5× | .chars() |
| 549 | 69× | .filter(|c| c.is_ascii_alphanumeric() || "=,.@-_".contains(*c)) |
| 550 | 5× | .take(64) |
| 551 | 5× | .collect(); |
| 552 | 5× | let c = self.rt.block_on(async { |
| 553 | 5× | let issuer = self.app.oidc().await?; |
| 554 | 5× | let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, AWS_AUDIENCE, db::now_ms(), 600); |
| 555 | 5× | let res = self |
| 556 | 5× | .client |
| 557 | 5× | .post(format!("{}/", self.sts)) |
| 558 | 5× | .form(&[ |
| 559 | 5× | ("Action", "AssumeRoleWithWebIdentity"), |
| 560 | 5× | ("Version", "2011-06-15"), |
| 561 | 5× | ("RoleArn", self.role.as_str()), |
| 562 | 5× | ("RoleSessionName", session.as_str()), |
| 563 | 5× | ("WebIdentityToken", jwt.as_str()), |
| 564 | 5× | ("DurationSeconds", "3600"), |
| 565 | 5× | ]) |
| 566 | 5× | .send() |
| 567 | 5× | .await?; |
| 568 | 5× | let status = res.status(); |
| 569 | 5× | let text = res.text().await.unwrap_or_default(); |
| 570 | 5× | if !status.is_success() { |
| 571 | 0× | bail!( |
| 572 | | "AWS STS refused {} for {}: {} (check the role's trust policy: this hub's address as its OIDC provider, audience {AWS_AUDIENCE}, and sub {})", |
| 573 | | self.subject, |
| 574 | | self.role, |
| 575 | 0× | xml_text(&text, "Message").unwrap_or_else(|| status.to_string()), |
| 576 | | self.subject |
| 577 | | ); |
| 578 | 5× | } |
| 579 | 15× | let get = |t: &str| xml_text(&text, t).with_context(|| format!("AWS STS gave no {t}")); |
| 580 | 5× | anyhow::Ok(Creds { id: get("AccessKeyId")?, secret: get("SecretAccessKey")?, token: get("SessionToken")?, until: now + 3_600_000 }) |
| 581 | 5× | })?; |
| 582 | 5× | *self.creds.lock().unwrap() = Some(c.clone()); |
| 583 | 5× | Ok(c) |
| 584 | 66× | } |
| 585 | |
|
| 586 | | |
| 587 | 66× | fn send( |
| 588 | 66× | &self, |
| 589 | 66× | method: reqwest::Method, |
| 590 | 66× | key: Option<&str>, |
| 591 | 66× | query: &[(String, String)], |
| 592 | 66× | body: Vec<u8>, |
| 593 | 66× | ) -> Result<(reqwest::StatusCode, Vec<u8>)> { |
| 594 | 66× | let c = self.creds()?; |
| 595 | 66× | let base_path = self.base.path().trim_end_matches('/'); |
| 596 | 66× | let uri = match key { |
| 597 | 60× | Some(k) => format!("{base_path}/{}", aws_encode(&self.name(k), true)), |
| 598 | 6× | None => format!("{base_path}/"), |
| 599 | | }; |
| 600 | 66× | let host = match self.base.port() { |
| 601 | 66× | Some(p) => format!("{}:{p}", self.base.host_str().unwrap_or_default()), |
| 602 | 0× | None => self.base.host_str().unwrap_or_default().to_string(), |
| 603 | | }; |
| 604 | 66× | let payload = sha256_hex(&body); |
| 605 | 66× | let date = amz_date(db::now_ms()); |
| 606 | 66× | let headers = vec![ |
| 607 | 66× | ("host".to_string(), host.clone()), |
| 608 | 66× | ("x-amz-content-sha256".to_string(), payload.clone()), |
| 609 | 66× | ("x-amz-date".to_string(), date.clone()), |
| 610 | 66× | ("x-amz-security-token".to_string(), c.token.clone()), |
| 611 | | ]; |
| 612 | 66× | let auth = sigv4(method.as_str(), &uri, query, &headers, &payload, (&c.id, &c.secret), &self.region, "s3", &date); |
| 613 | 66× | let mut q: Vec<(String, String)> = query.iter().map(|(k, v)| (aws_encode(k, false), aws_encode(v, false))).collect(); |
| 614 | 66× | q.sort(); |
| 615 | 66× | let qs = q.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&"); |
| 616 | 66× | let url = format!("{}://{host}{uri}{}{qs}", self.base.scheme(), if qs.is_empty() { "" } else { "?" }); |
| 617 | 66× | let method_has_body = method == reqwest::Method::PUT; |
| 618 | 66× | self.rt.block_on(async { |
| 619 | 66× | let mut req = self |
| 620 | 66× | .client |
| 621 | 66× | .request(method, &url) |
| 622 | 66× | .header("authorization", auth) |
| 623 | 66× | .header("x-amz-content-sha256", &payload) |
| 624 | 66× | .header("x-amz-date", &date) |
| 625 | 66× | .header("x-amz-security-token", &c.token); |
| 626 | 66× | if !body.is_empty() { |
| 627 | 21× | req = req.header("content-type", "application/octet-stream"); |
| 628 | 45× | } |
| 629 | | |
| 630 | | |
| 631 | 66× | if method_has_body { |
| 632 | 22× | req = req.header("content-length", body.len().to_string()).body(body); |
| 633 | 44× | } |
| 634 | 66× | let res = req.send().await?; |
| 635 | 66× | let status = res.status(); |
| 636 | 66× | anyhow::Ok((status, res.bytes().await?.to_vec())) |
| 637 | 66× | }) |
| 638 | 66× | } |
| 639 | |
|
| 640 | 3× | fn refused(&self, what: &str, status: reqwest::StatusCode, body: &[u8]) -> anyhow::Error { |
| 641 | 3× | let text = String::from_utf8_lossy(body); |
| 642 | 3× | let hint = if status == reqwest::StatusCode::FORBIDDEN { |
| 643 | 2× | format!(" (the role {} needs s3:GetObject, s3:PutObject, s3:DeleteObject and s3:ListBucket on {})", self.role, self.bucket) |
| 644 | | } else { |
| 645 | 1× | String::new() |
| 646 | | }; |
| 647 | 3× | anyhow::anyhow!("S3 {what}: {status}{hint}: {}", xml_text(&text, "Message").unwrap_or_else(|| text.chars().take(200).collect())) |
| 648 | 3× | } |
| 649 | |
|
| 650 | | |
| 651 | 6× | fn listing(&self, full: &str, delimited: bool) -> Result<(Vec<String>, Vec<String>)> { |
| 652 | 6× | let (mut keys, mut prefixes) = (Vec::new(), Vec::new()); |
| 653 | 6× | let mut token: Option<String> = None; |
| 654 | | loop { |
| 655 | 6× | let mut q = vec![("list-type".to_string(), "2".to_string()), ("prefix".to_string(), full.to_string())]; |
| 656 | 6× | if delimited { |
| 657 | 5× | q.push(("delimiter".to_string(), "/".to_string())); |
| 658 | 5× | } |
| 659 | 6× | if let Some(t) = &token { |
| 660 | 0× | q.push(("continuation-token".to_string(), t.clone())); |
| 661 | 6× | } |
| 662 | 6× | let (status, body) = self.send(reqwest::Method::GET, None, &q, Vec::new())?; |
| 663 | 6× | if !status.is_success() { |
| 664 | 0× | return Err(self.refused("list", status, &body)); |
| 665 | 6× | } |
| 666 | 6× | let text = String::from_utf8_lossy(&body).into_owned(); |
| 667 | 6× | for c in xml_blocks(&text, "Contents") { |
| 668 | 3× | keys.extend(xml_text(c, "Key")); |
| 669 | 3× | } |
| 670 | 6× | for c in xml_blocks(&text, "CommonPrefixes") { |
| 671 | 3× | prefixes.extend(xml_text(c, "Prefix")); |
| 672 | 3× | } |
| 673 | 6× | match (xml_text(&text, "IsTruncated").as_deref(), xml_text(&text, "NextContinuationToken")) { |
| 674 | 0× | (Some("true"), Some(t)) => token = Some(t), |
| 675 | 6× | _ => return Ok((keys, prefixes)), |
| 676 | | } |
| 677 | | } |
| 678 | 6× | } |
| 679 | | } |
| 680 | |
|
| 681 | | impl Destination for S3 { |
| 682 | 16× | fn exists(&self, key: &str) -> Result<bool> { |
| 683 | 16× | let (status, body) = self.send(reqwest::Method::HEAD, Some(key), &[], Vec::new())?; |
| 684 | 10× | match status { |
| 685 | 16× | s if s.is_success() => Ok(true), |
| 686 | 10× | reqwest::StatusCode::NOT_FOUND => Ok(false), |
| 687 | 0× | s => Err(self.refused("read", s, &body)), |
| 688 | | } |
| 689 | 16× | } |
| 690 | 22× | fn put(&self, key: &str, bytes: &[u8]) -> Result<()> { |
| 691 | 22× | let (status, body) = self.send(reqwest::Method::PUT, Some(key), &[], bytes.to_vec())?; |
| 692 | 22× | if !status.is_success() { |
| 693 | 1× | return Err(self.refused("write", status, &body)); |
| 694 | 21× | } |
| 695 | 21× | Ok(()) |
| 696 | 22× | } |
| 697 | 21× | fn get(&self, key: &str) -> Result<Vec<u8>> { |
| 698 | 21× | let (status, body) = self.send(reqwest::Method::GET, Some(key), &[], Vec::new())?; |
| 699 | 21× | if !status.is_success() { |
| 700 | 2× | return Err(self.refused(&format!("read {key}"), status, &body)); |
| 701 | 19× | } |
| 702 | 19× | Ok(body) |
| 703 | 21× | } |
| 704 | 5× | fn list(&self, prefix: &str) -> Result<Vec<String>> { |
| 705 | 5× | let full = format!("{}/", self.name(prefix.trim_end_matches('/'))); |
| 706 | 5× | let (keys, prefixes) = self.listing(&full, true)?; |
| 707 | 5× | let mut out: std::collections::BTreeSet<String> = keys.iter().map(|k| k.trim_start_matches(&full).to_string()).collect(); |
| 708 | 5× | out.extend(prefixes.iter().map(|p| p.trim_start_matches(&full).trim_end_matches('/').to_string())); |
| 709 | 6× | Ok(out.into_iter().filter(|n| !n.is_empty()).collect()) |
| 710 | 5× | } |
| 711 | 1× | fn remove(&self, key: &str) -> Result<()> { |
| 712 | 1× | let full = self.name(key); |
| 713 | 1× | let (mut keys, _) = self.listing(&format!("{full}/"), false)?; |
| 714 | 1× | keys.push(full); |
| 715 | 1× | for k in keys { |
| 716 | 1× | let rel = if self.prefix.is_empty() { k.clone() } else { k.trim_start_matches(&format!("{}/", self.prefix)).to_string() }; |
| 717 | 1× | let (status, body) = self.send(reqwest::Method::DELETE, Some(&rel), &[], Vec::new())?; |
| 718 | 1× | if !status.is_success() && status != reqwest::StatusCode::NOT_FOUND { |
| 719 | 0× | return Err(self.refused("remove", status, &body)); |
| 720 | 1× | } |
| 721 | | } |
| 722 | 1× | Ok(()) |
| 723 | 1× | } |
| 724 | | } |
| 725 | |
|
| 726 | | impl Destination for Gcs { |
| 727 | 16× | fn exists(&self, key: &str) -> Result<bool> { |
| 728 | 16× | let token = self.access()?; |
| 729 | 16× | let mut u = self.object_url(key)?; |
| 730 | 16× | u.query_pairs_mut().append_pair("fields", "name"); |
| 731 | 16× | self.rt.block_on(async { |
| 732 | 16× | let res = self.client.get(u).bearer_auth(&token).send().await?; |
| 733 | 16× | match res.status() { |
| 734 | 16× | s if s.is_success() => Ok(true), |
| 735 | 10× | reqwest::StatusCode::NOT_FOUND => Ok(false), |
| 736 | 0× | s => Err(self.refused("read", s, &res.text().await.unwrap_or_default())), |
| 737 | | } |
| 738 | 16× | }) |
| 739 | 16× | } |
| 740 | 22× | fn put(&self, key: &str, bytes: &[u8]) -> Result<()> { |
| 741 | 22× | let token = self.access()?; |
| 742 | 22× | let mut u = reqwest::Url::parse(&format!("{}/upload/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 743 | 22× | u.query_pairs_mut().append_pair("uploadType", "media").append_pair("name", &self.name(key)); |
| 744 | 22× | let body = bytes.to_vec(); |
| 745 | 22× | self.rt.block_on(async { |
| 746 | | |
| 747 | | |
| 748 | 22× | let res = self |
| 749 | 22× | .client |
| 750 | 22× | .post(u) |
| 751 | 22× | .bearer_auth(&token) |
| 752 | 22× | .header("content-type", "application/octet-stream") |
| 753 | 22× | .header("content-length", body.len().to_string()) |
| 754 | 22× | .body(body) |
| 755 | 22× | .send() |
| 756 | 22× | .await?; |
| 757 | 22× | let status = res.status(); |
| 758 | 22× | if !status.is_success() { |
| 759 | 1× | return Err(self.refused("write", status, &res.text().await.unwrap_or_default())); |
| 760 | 21× | } |
| 761 | 21× | Ok(()) |
| 762 | 22× | }) |
| 763 | 22× | } |
| 764 | 21× | fn get(&self, key: &str) -> Result<Vec<u8>> { |
| 765 | 21× | let token = self.access()?; |
| 766 | 21× | let mut u = self.object_url(key)?; |
| 767 | 21× | u.query_pairs_mut().append_pair("alt", "media"); |
| 768 | 21× | self.rt.block_on(async { |
| 769 | 21× | let res = self.client.get(u).bearer_auth(&token).send().await?; |
| 770 | 21× | let status = res.status(); |
| 771 | 21× | if !status.is_success() { |
| 772 | 2× | return Err(self.refused(&format!("read {key}"), status, &res.text().await.unwrap_or_default())); |
| 773 | 19× | } |
| 774 | 19× | Ok(res.bytes().await?.to_vec()) |
| 775 | 21× | }) |
| 776 | 21× | } |
| 777 | 5× | fn list(&self, prefix: &str) -> Result<Vec<String>> { |
| 778 | 5× | let full = format!("{}/", self.name(prefix.trim_end_matches('/'))); |
| 779 | 5× | let mut out = std::collections::BTreeSet::new(); |
| 780 | 5× | let mut page: Option<String> = None; |
| 781 | | loop { |
| 782 | 5× | let token = self.access()?; |
| 783 | 5× | let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 784 | 5× | u.query_pairs_mut() |
| 785 | 5× | .append_pair("prefix", &full) |
| 786 | 5× | .append_pair("delimiter", "/") |
| 787 | 5× | .append_pair("fields", "items(name),prefixes,nextPageToken"); |
| 788 | 5× | if let Some(p) = &page { |
| 789 | 0× | u.query_pairs_mut().append_pair("pageToken", p); |
| 790 | 5× | } |
| 791 | 5× | let body: serde_json::Value = self.rt.block_on(async { |
| 792 | 5× | let res = self.client.get(u).bearer_auth(&token).send().await?; |
| 793 | 5× | let status = res.status(); |
| 794 | 5× | let text = res.text().await?; |
| 795 | 5× | if !status.is_success() { |
| 796 | 0× | return Err(self.refused("list", status, &text)); |
| 797 | 5× | } |
| 798 | 5× | anyhow::Ok(serde_json::from_str(&text)?) |
| 799 | 5× | })?; |
| 800 | 5× | for n in body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str()) { |
| 801 | 3× | out.insert(n.trim_start_matches(&full).to_string()); |
| 802 | 3× | } |
| 803 | 5× | for p in body["prefixes"].as_array().into_iter().flatten().filter_map(|p| p.as_str()) { |
| 804 | 3× | out.insert(p.trim_start_matches(&full).trim_end_matches('/').to_string()); |
| 805 | 3× | } |
| 806 | 5× | match body["nextPageToken"].as_str() { |
| 807 | 0× | Some(p) => page = Some(p.to_string()), |
| 808 | 6× | None => return Ok(out.into_iter().filter(|n| !n.is_empty()).collect()), |
| 809 | | } |
| 810 | | } |
| 811 | 5× | } |
| 812 | 1× | fn remove(&self, key: &str) -> Result<()> { |
| 813 | 1× | let mut names = self.all_under(&format!("{}/", self.name(key)))?; |
| 814 | 1× | names.push(self.name(key)); |
| 815 | 1× | for n in names { |
| 816 | 1× | let token = self.access()?; |
| 817 | 1× | let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 818 | 1× | u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&n); |
| 819 | 1× | self.rt.block_on(async { |
| 820 | 1× | let res = self.client.delete(u).bearer_auth(&token).send().await?; |
| 821 | 1× | match res.status() { |
| 822 | 1× | s if s.is_success() || s == reqwest::StatusCode::NOT_FOUND => Ok(()), |
| 823 | 0× | s => Err(self.refused("delete", s, &res.text().await.unwrap_or_default())), |
| 824 | | } |
| 825 | 1× | })?; |
| 826 | | } |
| 827 | 1× | Ok(()) |
| 828 | 1× | } |
| 829 | | } |
| 830 | |
|
| 831 | |
|
| 832 | 3× | pub fn probe(dest: &dyn Destination) -> Result<()> { |
| 833 | 3× | let marker = format!("probe {}", db::now_ms()); |
| 834 | 3× | dest.put(".oxyhub-probe", marker.as_bytes())?; |
| 835 | 3× | if dest.get(".oxyhub-probe")? != marker.as_bytes() { |
| 836 | 0× | bail!("the probe read back differently"); |
| 837 | 3× | } |
| 838 | 3× | dest.remove(".oxyhub-probe") |
| 839 | 3× | } |
| 840 | |
|
| 841 | |
|
| 842 | 21× | pub fn destination(app: &Shared, s: &Setting) -> Result<Box<dyn Destination>> { |
| 843 | 21× | match s.destination.as_str() { |
| 844 | 21× | "dir" => { |
| 845 | 11× | let allowed = &app.config.backups.directories; |
| 846 | 11× | let target = PathBuf::from(&s.target); |
| 847 | 11× | if !allowed.contains(&target) { |
| 848 | 1× | bail!("{} is not a backup directory this hub allows (hub.toml [backups] directories)", s.target); |
| 849 | 10× | } |
| 850 | 10× | let mut root = target.join(&s.owner); |
| 851 | 10× | if !s.prefix.is_empty() { |
| 852 | 0× | root = root.join(s.prefix.trim_matches('/')); |
| 853 | 10× | } |
| 854 | 10× | Ok(Box::new(Directory { root })) |
| 855 | | } |
| 856 | 10× | "gcs" => { |
| 857 | 5× | if s.target.is_empty() || s.provider.is_empty() { |
| 858 | 0× | bail!("a GCS destination needs its bucket and its workload identity provider"); |
| 859 | 5× | } |
| 860 | 5× | Ok(Box::new(Gcs::new(app, s))) |
| 861 | | } |
| 862 | 5× | "s3" => { |
| 863 | 5× | if s.target.is_empty() || s.provider.is_empty() || s.region.is_empty() { |
| 864 | 0× | bail!("an S3 destination needs its bucket, its region and the IAM role the hub assumes"); |
| 865 | 5× | } |
| 866 | 5× | Ok(Box::new(S3::new(app, s))) |
| 867 | | } |
| 868 | 0× | other => bail!("unknown destination {other:?}"), |
| 869 | | } |
| 870 | 21× | } |
| 871 | |
|
| 872 | |
|
| 873 | |
|
| 874 | |
|
| 875 | | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] |
| 876 | | pub struct File { |
| 877 | | pub path: String, |
| 878 | | pub size: u64, |
| 879 | | |
| 880 | | |
| 881 | | pub mtime: i128, |
| 882 | | pub hash: String, |
| 883 | | } |
| 884 | |
|
| 885 | |
|
| 886 | | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 887 | | pub struct Manifest { |
| 888 | | pub owner: String, |
| 889 | | pub name: String, |
| 890 | | |
| 891 | | pub kind: String, |
| 892 | | pub files: Vec<File>, |
| 893 | | } |
| 894 | |
|
| 895 | |
|
| 896 | |
|
| 897 | |
|
| 898 | 32× | fn key_name(name: &str) -> String { |
| 899 | 32× | name.replace('/', "~") |
| 900 | 32× | } |
| 901 | |
|
| 902 | 165× | fn blob_key(hash: &str) -> String { |
| 903 | 165× | format!("blobs/{}/{hash}", &hash[..2]) |
| 904 | 165× | } |
| 905 | |
|
| 906 | |
|
| 907 | 166× | fn immutable(path: &str) -> bool { |
| 908 | 166× | path.starts_with(".oxy/objects/") && (path.contains("/pack-") || path.contains("/loose/")) |
| 909 | 166× | } |
| 910 | |
|
| 911 | | #[derive(Default)] |
| 912 | | struct Sent { |
| 913 | | files: i64, |
| 914 | | sent: i64, |
| 915 | | bytes: i64, |
| 916 | | } |
| 917 | |
|
| 918 | |
|
| 919 | |
|
| 920 | 35× | fn copy_dir(dest: &dyn Destination, dir: &Path, known: &HashMap<String, File>, only_immutable: bool, sent: &mut Sent) -> Result<Vec<File>> { |
| 921 | 35× | let mut out = Vec::new(); |
| 922 | 35× | let mut stack = vec![dir.to_path_buf()]; |
| 923 | 343× | while let Some(d) = stack.pop() { |
| 924 | 308× | let entries = match std::fs::read_dir(&d) { |
| 925 | 308× | Ok(e) => e, |
| 926 | 0× | Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, |
| 927 | 0× | Err(e) => return Err(e.into()), |
| 928 | | }; |
| 929 | 607× | for e in entries.flatten() { |
| 930 | 607× | let p = e.path(); |
| 931 | 607× | let ft = e.file_type()?; |
| 932 | 607× | if ft.is_dir() { |
| 933 | 273× | stack.push(p); |
| 934 | 273× | continue; |
| 935 | 334× | } |
| 936 | 334× | if !ft.is_file() { |
| 937 | 0× | continue; |
| 938 | 334× | } |
| 939 | 334× | let rel = p.strip_prefix(dir).unwrap_or(&p).to_string_lossy().replace('\\', "/"); |
| 940 | 334× | if only_immutable && !immutable(&rel) { |
| 941 | 97× | continue; |
| 942 | 237× | } |
| 943 | | |
| 944 | 237× | let Ok(md) = std::fs::metadata(&p) else { continue }; |
| 945 | 237× | let mtime = |
| 946 | 237× | md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_nanos() as i128).unwrap_or(0); |
| 947 | 237× | let size = md.len(); |
| 948 | 237× | sent.files += 1; |
| 949 | 237× | if let Some(k) = known.get(&rel).filter(|k| k.size == size && k.mtime == mtime) { |
| 950 | 134× | out.push(k.clone()); |
| 951 | 134× | continue; |
| 952 | 103× | } |
| 953 | 103× | let Ok(bytes) = std::fs::read(&p) else { continue }; |
| 954 | 103× | let hash = blake3::hash(&bytes).to_hex().to_string(); |
| 955 | 103× | let key = blob_key(&hash); |
| 956 | 103× | if !dest.exists(&key)? { |
| 957 | 71× | dest.put(&key, &bytes)?; |
| 958 | 71× | sent.sent += 1; |
| 959 | 71× | sent.bytes += bytes.len() as i64; |
| 960 | 32× | } |
| 961 | 103× | out.push(File { path: rel, size, mtime, hash }); |
| 962 | | } |
| 963 | | } |
| 964 | 468× | out.sort_by(|a, b| a.path.cmp(&b.path)); |
| 965 | 35× | Ok(out) |
| 966 | 35× | } |
| 967 | |
|
| 968 | |
|
| 969 | |
|
| 970 | |
|
| 971 | 3700× | fn private_table(t: &str) -> bool { |
| 972 | 24679× | ["secret", "token", "session", "password", "totp", "recovery", "sqlx"].iter().any(|w| t.contains(w)) |
| 973 | 3700× | } |
| 974 | |
|
| 975 | 8535× | fn private_column(c: &str) -> bool { |
| 976 | 59344× | ["secret", "token", "password", "salt", "totp", "sealed", "hash_key"].iter().any(|w| c.contains(w)) |
| 977 | 8535× | } |
| 978 | |
|
| 979 | 37× | async fn tables(pool: &Pool) -> Result<Vec<String>> { |
| 980 | 37× | let mut t = catalog::tables(pool).await?; |
| 981 | 3700× | t.retain(|t| !private_table(t)); |
| 982 | 37× | t.sort(); |
| 983 | 37× | Ok(t) |
| 984 | 37× | } |
| 985 | |
|
| 986 | 2883× | async fn columns(pool: &Pool, table: &str) -> Result<Vec<String>> { |
| 987 | 2883× | Ok(table_cols(pool, table).await?.into_iter().map(|c| c.name).collect()) |
| 988 | 2883× | } |
| 989 | |
|
| 990 | |
|
| 991 | |
|
| 992 | |
|
| 993 | 991× | async fn rows(pool: &Pool, table: &str, cond: &str) -> Result<serde_json::Value> { |
| 994 | 8535× | let cols: Vec<Col> = table_cols(pool, table).await?.into_iter().filter(|c| !private_column(&c.name)).collect(); |
| 995 | 991× | if cols.is_empty() { |
| 996 | 0× | return Ok(serde_json::json!([])); |
| 997 | 991× | } |
| 998 | 991× | let text: String = sqlx::query_scalar(&catalog::rows_sql(table, &cols, cond)).fetch_one(pool).await?; |
| 999 | 991× | Ok(serde_json::from_str(&text)?) |
| 1000 | 991× | } |
| 1001 | |
|
| 1002 | |
|
| 1003 | |
|
| 1004 | |
|
| 1005 | | #[cfg(not(feature = "postgres"))] |
| 1006 | | mod catalog { |
| 1007 | | use super::{Col, Pool, Result}; |
| 1008 | |
|
| 1009 | 37× | pub async fn tables(pool: &Pool) -> Result<Vec<String>> { |
| 1010 | 37× | Ok(sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").fetch_all(pool).await?) |
| 1011 | 37× | } |
| 1012 | |
|
| 1013 | 3897× | pub async fn cols(pool: &Pool, table: &str) -> Result<Vec<Col>> { |
| 1014 | 3897× | let rows: Vec<(String, String, i64, Option<String>, i64)> = |
| 1015 | 3897× | sqlx::query_as(&format!("SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('{table}')")) |
| 1016 | 3897× | .fetch_all(pool) |
| 1017 | 3897× | .await?; |
| 1018 | 31874× | Ok(rows.into_iter().map(|(name, ty, notnull, default, pk)| Col { name, ty, notnull: notnull != 0, default, pk }).collect()) |
| 1019 | 3897× | } |
| 1020 | |
|
| 1021 | | |
| 1022 | 3839× | pub async fn fks(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> { |
| 1023 | 3839× | Ok(sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{table}')")).fetch_all(pool).await?) |
| 1024 | 3839× | } |
| 1025 | |
|
| 1026 | 991× | pub fn rows_sql(table: &str, cols: &[Col], cond: &str) -> String { |
| 1027 | 991× | let fields: Vec<String> = |
| 1028 | 8458× | cols.iter().map(|c| format!("'{0}', CASE WHEN typeof(\"{0}\") = 'blob' THEN hex(\"{0}\") ELSE \"{0}\" END", c.name)).collect(); |
| 1029 | 991× | format!("SELECT json_group_array(json_object({})) FROM \"{table}\" WHERE {cond}", fields.join(", ")) |
| 1030 | 991× | } |
| 1031 | |
|
| 1032 | | |
| 1033 | 20× | pub fn insert(verb: super::Verb, _cols: &[String]) -> (&'static str, String) { |
| 1034 | 20× | match verb { |
| 1035 | 1× | super::Verb::Replace => ("INSERT OR REPLACE", String::new()), |
| 1036 | 17× | super::Verb::New => ("INSERT", String::new()), |
| 1037 | 2× | super::Verb::Ignore => ("INSERT OR IGNORE", String::new()), |
| 1038 | | } |
| 1039 | 20× | } |
| 1040 | | } |
| 1041 | |
|
| 1042 | | #[cfg(feature = "postgres")] |
| 1043 | | mod catalog { |
| 1044 | | use super::{Col, Pool, Result}; |
| 1045 | |
|
| 1046 | | pub async fn tables(pool: &Pool) -> Result<Vec<String>> { |
| 1047 | | Ok(sqlx::query_scalar( |
| 1048 | | "SELECT CAST(relname AS TEXT) FROM pg_class WHERE relkind = 'r' AND relnamespace = CAST(current_schema() AS regnamespace)", |
| 1049 | | ) |
| 1050 | | .fetch_all(pool) |
| 1051 | | .await?) |
| 1052 | | } |
| 1053 | |
|
| 1054 | | |
| 1055 | | |
| 1056 | | pub async fn cols(pool: &Pool, table: &str) -> Result<Vec<Col>> { |
| 1057 | | |
| 1058 | | let rows: Vec<(String, String, bool, Option<String>, i64)> = sqlx::query_as( |
| 1059 | | "SELECT CAST(a.attname AS TEXT), format_type(a.atttypid, a.atttypmod), a.attnotnull, pg_get_expr(d.adbin, d.adrelid), \ |
| 1060 | | CAST(COALESCE((SELECT array_position(k.conkey, a.attnum) FROM pg_constraint k WHERE k.conrelid = t.oid AND k.contype = 'p'), 0) AS BIGINT) \ |
| 1061 | | FROM pg_attribute a JOIN pg_class t ON t.oid = a.attrelid \ |
| 1062 | | LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum \ |
| 1063 | | WHERE t.relname = $1 AND t.relnamespace = CAST(current_schema() AS regnamespace) \ |
| 1064 | | AND a.attnum > 0 AND NOT a.attisdropped AND a.attgenerated = '' ORDER BY a.attnum", |
| 1065 | | ) |
| 1066 | | .bind(table) |
| 1067 | | .fetch_all(pool) |
| 1068 | | .await?; |
| 1069 | | Ok(rows |
| 1070 | | .into_iter() |
| 1071 | | .map(|(name, ty, notnull, default, pk)| { |
| 1072 | | let ty = match ty.as_str() { |
| 1073 | | "bigint" | "integer" | "smallint" => "INTEGER", |
| 1074 | | "bytea" => "BLOB", |
| 1075 | | "boolean" => "BOOLEAN", |
| 1076 | | "double precision" | "real" => "REAL", |
| 1077 | | _ => "TEXT", |
| 1078 | | }; |
| 1079 | | |
| 1080 | | |
| 1081 | | let default = default.filter(|d| !d.starts_with("nextval(")); |
| 1082 | | Col { name, ty: ty.to_string(), notnull, default, pk } |
| 1083 | | }) |
| 1084 | | .collect()) |
| 1085 | | } |
| 1086 | |
|
| 1087 | | |
| 1088 | | pub async fn fks(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> { |
| 1089 | | Ok(sqlx::query_as( |
| 1090 | | "SELECT CAST(p.relname AS TEXT), CAST(a.attname AS TEXT), CAST(pa.attname AS TEXT) FROM pg_constraint k \ |
| 1091 | | JOIN pg_class t ON t.oid = k.conrelid JOIN pg_class p ON p.oid = k.confrelid \ |
| 1092 | | JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = k.conkey[1] \ |
| 1093 | | JOIN pg_attribute pa ON pa.attrelid = k.confrelid AND pa.attnum = k.confkey[1] \ |
| 1094 | | WHERE k.contype = 'f' AND t.relname = $1 AND t.relnamespace = CAST(current_schema() AS regnamespace)", |
| 1095 | | ) |
| 1096 | | .bind(table) |
| 1097 | | .fetch_all(pool) |
| 1098 | | .await?) |
| 1099 | | } |
| 1100 | |
|
| 1101 | | pub fn rows_sql(table: &str, cols: &[Col], cond: &str) -> String { |
| 1102 | | let fields: Vec<String> = cols |
| 1103 | | .iter() |
| 1104 | | .map(|c| match c.ty.as_str() { |
| 1105 | | "BLOB" => format!("upper(encode(\"{0}\", 'hex')) AS \"{0}\"", c.name), |
| 1106 | | "BOOLEAN" => format!("CASE WHEN \"{0}\" THEN 1 ELSE 0 END AS \"{0}\"", c.name), |
| 1107 | | _ => format!("\"{0}\"", c.name), |
| 1108 | | }) |
| 1109 | | .collect(); |
| 1110 | | format!("SELECT CAST(COALESCE(json_agg(r), '[]') AS TEXT) FROM (SELECT {} FROM \"{table}\" WHERE {cond}) r", fields.join(", ")) |
| 1111 | | } |
| 1112 | |
|
| 1113 | | |
| 1114 | | pub fn insert(verb: super::Verb, cols: &[String]) -> (&'static str, String) { |
| 1115 | | match verb { |
| 1116 | | super::Verb::Replace => { |
| 1117 | | let set: Vec<String> = cols.iter().filter(|c| *c != "repo_id").map(|c| format!("\"{c}\" = excluded.\"{c}\"")).collect(); |
| 1118 | | let tail = if set.is_empty() { |
| 1119 | | " ON CONFLICT (repo_id) DO NOTHING".to_string() |
| 1120 | | } else { |
| 1121 | | format!(" ON CONFLICT (repo_id) DO UPDATE SET {}", set.join(", ")) |
| 1122 | | }; |
| 1123 | | ("INSERT", tail) |
| 1124 | | } |
| 1125 | | super::Verb::New => ("INSERT", String::new()), |
| 1126 | | super::Verb::Ignore => ("INSERT", " ON CONFLICT DO NOTHING".to_string()), |
| 1127 | | } |
| 1128 | | } |
| 1129 | | } |
| 1130 | |
|
| 1131 | |
|
| 1132 | | #[derive(Clone, Copy)] |
| 1133 | | enum Verb { |
| 1134 | | |
| 1135 | | Replace, |
| 1136 | | |
| 1137 | | New, |
| 1138 | | |
| 1139 | | Ignore, |
| 1140 | | } |
| 1141 | |
|
| 1142 | |
|
| 1143 | |
|
| 1144 | |
|
| 1145 | |
|
| 1146 | 31× | async fn records(pool: &Pool, key: &str, id: i64) -> Result<serde_json::Value> { |
| 1147 | 31× | let all = tables(pool).await?; |
| 1148 | 31× | let mut conds: Vec<(String, String)> = Vec::new(); |
| 1149 | 2883× | for t in &all { |
| 1150 | 19123× | if columns(pool, t).await?.iter().any(|c| c == key) { |
| 1151 | 671× | conds.push((t.clone(), format!("\"{key}\" = {id}"))); |
| 1152 | 2212× | } |
| 1153 | | } |
| 1154 | 31× | for _ in 0..3 { |
| 1155 | 63× | let mut more = Vec::new(); |
| 1156 | 5859× | for t in &all { |
| 1157 | 155003× | if conds.iter().any(|(c, _)| c == t) || more.iter().any(|(c, _): &(String, String)| c == t) { |
| 1158 | 2063× | continue; |
| 1159 | 3796× | } |
| 1160 | 3796× | let fks = catalog::fks(pool, t).await?; |
| 1161 | 3796× | if let Some((parent, from, to, pcond)) = fks |
| 1162 | 3796× | .iter() |
| 1163 | 74203× | .find_map(|(p, f, to)| conds.iter().find(|(c, _)| c == p).map(|(_, pc)| (p.clone(), f.clone(), to.clone(), pc.clone()))) |
| 1164 | 304× | { |
| 1165 | 304× | more.push((t.clone(), format!("\"{from}\" IN (SELECT \"{to}\" FROM \"{parent}\" WHERE {pcond})"))); |
| 1166 | 3492× | } |
| 1167 | | } |
| 1168 | 63× | if more.is_empty() { |
| 1169 | 31× | break; |
| 1170 | 32× | } |
| 1171 | 32× | conds.extend(more); |
| 1172 | | } |
| 1173 | 31× | let mut out = serde_json::Map::new(); |
| 1174 | 975× | for (t, cond) in conds { |
| 1175 | 975× | let r = rows(pool, &t, &cond).await?; |
| 1176 | 975× | if r.as_array().is_some_and(|a| !a.is_empty()) { |
| 1177 | 29× | out.insert(t, r); |
| 1178 | 946× | } |
| 1179 | | } |
| 1180 | 31× | Ok(serde_json::Value::Object(out)) |
| 1181 | 31× | } |
| 1182 | |
|
| 1183 | |
|
| 1184 | |
|
| 1185 | | const NAMES: &str = "_names"; |
| 1186 | |
|
| 1187 | |
|
| 1188 | |
|
| 1189 | |
|
| 1190 | | const NOT_RESTORED: [&str; 5] = ["search", "notifications", "deliveries", "flow_declines", NAMES]; |
| 1191 | |
|
| 1192 | 43× | async fn fk_list(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> { |
| 1193 | 43× | catalog::fks(pool, table).await |
| 1194 | 43× | } |
| 1195 | |
|
| 1196 | |
|
| 1197 | |
|
| 1198 | 16× | async fn names_of(pool: &Pool, rec: &serde_json::Value) -> Result<serde_json::Value> { |
| 1199 | 16× | let mut ids: HashMap<String, std::collections::BTreeSet<i64>> = HashMap::new(); |
| 1200 | 26× | for (table, rows) in rec.as_object().into_iter().flatten() { |
| 1201 | 26× | let Some(rows) = rows.as_array() else { continue }; |
| 1202 | 48× | for (parent, from, _) in fk_list(pool, table).await? { |
| 1203 | 48× | if ["accounts", "teams", "organisations"].contains(&parent.as_str()) { |
| 1204 | 20× | ids.entry(parent).or_default().extend(rows.iter().filter_map(|r| r[&from].as_i64())); |
| 1205 | 29× | } |
| 1206 | | } |
| 1207 | | } |
| 1208 | 16× | let mut out = serde_json::Map::new(); |
| 1209 | 48× | for (kind, sql) in [ |
| 1210 | 16× | ("accounts", "SELECT id, username FROM accounts WHERE id = $1"), |
| 1211 | 16× | ("organisations", "SELECT id, name FROM organisations WHERE id = $1"), |
| 1212 | 16× | ("teams", "SELECT t.id, o.name || '/' || t.name FROM teams t JOIN organisations o ON o.id = t.org_id WHERE t.id = $1"), |
| 1213 | 16× | ] { |
| 1214 | 48× | let mut m = serde_json::Map::new(); |
| 1215 | 48× | for id in ids.get(kind).into_iter().flatten() { |
| 1216 | 2× | if let Some((id, name)) = sqlx::query_as::<_, (i64, String)>(sql).bind(id).fetch_optional(pool).await? { |
| 1217 | 2× | m.insert(id.to_string(), name.into()); |
| 1218 | 2× | } |
| 1219 | | } |
| 1220 | 48× | out.insert(kind.into(), m.into()); |
| 1221 | | } |
| 1222 | 16× | Ok(out.into()) |
| 1223 | 16× | } |
| 1224 | |
|
| 1225 | |
|
| 1226 | 2× | async fn id_by_name(pool: &Pool, kind: &str, name: &str) -> Result<Option<i64>> { |
| 1227 | 2× | Ok(match kind { |
| 1228 | 2× | "accounts" => sqlx::query_scalar("SELECT id FROM accounts WHERE username = $1").bind(name).fetch_optional(pool).await?, |
| 1229 | 0× | "organisations" => sqlx::query_scalar("SELECT id FROM organisations WHERE name = $1").bind(name).fetch_optional(pool).await?, |
| 1230 | 0× | "teams" => match name.split_once('/') { |
| 1231 | 0× | Some((org, team)) => { |
| 1232 | 0× | sqlx::query_scalar("SELECT t.id FROM teams t JOIN organisations o ON o.id = t.org_id WHERE o.name = $1 AND t.name = $2") |
| 1233 | 0× | .bind(org) |
| 1234 | 0× | .bind(team) |
| 1235 | 0× | .fetch_optional(pool) |
| 1236 | 0× | .await? |
| 1237 | | } |
| 1238 | 0× | None => None, |
| 1239 | | }, |
| 1240 | 0× | _ => None, |
| 1241 | | }) |
| 1242 | 2× | } |
| 1243 | |
|
| 1244 | |
|
| 1245 | | struct Col { |
| 1246 | | name: String, |
| 1247 | | ty: String, |
| 1248 | | notnull: bool, |
| 1249 | | default: Option<String>, |
| 1250 | | pk: i64, |
| 1251 | | } |
| 1252 | |
|
| 1253 | 3897× | async fn table_cols(pool: &Pool, table: &str) -> Result<Vec<Col>> { |
| 1254 | 3897× | catalog::cols(pool, table).await |
| 1255 | 3897× | } |
| 1256 | |
|
| 1257 | |
|
| 1258 | |
|
| 1259 | 299× | fn bind_json<'q>( |
| 1260 | 299× | q: sqlx::query::Query<'q, crate::db::Db, <crate::db::Db as sqlx::Database>::Arguments<'q>>, |
| 1261 | 299× | v: serde_json::Value, |
| 1262 | 299× | ty: &str, |
| 1263 | 299× | ) -> sqlx::query::Query<'q, crate::db::Db, <crate::db::Db as sqlx::Database>::Arguments<'q>> { |
| 1264 | 299× | let blob = ty.eq_ignore_ascii_case("BLOB"); |
| 1265 | 299× | if ty == "BOOLEAN" { |
| 1266 | 0× | return match v { |
| 1267 | 0× | serde_json::Value::Null => q.bind(None::<bool>), |
| 1268 | 0× | serde_json::Value::Bool(b) => q.bind(b), |
| 1269 | 0× | serde_json::Value::Number(n) => q.bind(n.as_f64().unwrap_or(0.0) != 0.0), |
| 1270 | 0× | other => q.bind(matches!(other.as_str(), Some("1" | "true"))), |
| 1271 | | }; |
| 1272 | 299× | } |
| 1273 | 169× | match v { |
| 1274 | 0× | serde_json::Value::Null => q.bind(None::<i64>), |
| 1275 | 0× | serde_json::Value::Bool(b) => q.bind(b as i64), |
| 1276 | 130× | serde_json::Value::Number(n) => match n.as_i64() { |
| 1277 | 130× | Some(i) => q.bind(i), |
| 1278 | 0× | None => q.bind(n.as_f64().unwrap_or(0.0)), |
| 1279 | | }, |
| 1280 | 0× | serde_json::Value::String(s) if blob => q.bind(hex::decode(&s).unwrap_or_else(|_| s.into_bytes())), |
| 1281 | 169× | serde_json::Value::String(s) => q.bind(s), |
| 1282 | 0× | other => q.bind(other.to_string()), |
| 1283 | | } |
| 1284 | 299× | } |
| 1285 | |
|
| 1286 | |
|
| 1287 | |
|
| 1288 | |
|
| 1289 | |
|
| 1290 | |
|
| 1291 | |
|
| 1292 | |
|
| 1293 | |
|
| 1294 | |
|
| 1295 | 6× | async fn restore_records( |
| 1296 | 6× | app: &Shared, |
| 1297 | 6× | dest: &std::sync::Arc<dyn Destination>, |
| 1298 | 6× | rec: &serde_json::Value, |
| 1299 | 6× | old: i64, |
| 1300 | 6× | repo: i64, |
| 1301 | 6× | ) -> Result<Vec<String>> { |
| 1302 | 6× | let pool = &app.db; |
| 1303 | 6× | let obj = rec.as_object().context("the records are not an object")?; |
| 1304 | 6× | let mut by_name: HashMap<(String, i64), Option<i64>> = HashMap::new(); |
| 1305 | 18× | for (kind, m) in obj.get(NAMES).and_then(|n| n.as_object()).into_iter().flatten() { |
| 1306 | 18× | for (id, name) in m.as_object().into_iter().flatten() { |
| 1307 | 2× | let (Ok(id), Some(name)) = (id.parse::<i64>(), name.as_str()) else { continue }; |
| 1308 | 2× | by_name.insert((kind.clone(), id), id_by_name(pool, kind, name).await?); |
| 1309 | | } |
| 1310 | | } |
| 1311 | 6× | let here: std::collections::HashSet<String> = tables(pool).await?.into_iter().collect(); |
| 1312 | 6× | let mut notes = Vec::new(); |
| 1313 | 6× | let mut todo: Vec<String> = Vec::new(); |
| 1314 | 30× | for t in obj.keys().filter(|t| t.as_str() != "repositories" && !NOT_RESTORED.contains(&t.as_str())) { |
| 1315 | 17× | if here.contains(t) { |
| 1316 | 17× | todo.push(t.clone()); |
| 1317 | 17× | } else { |
| 1318 | 0× | notes.push(format!("{t}: not a table on this hub, left out")); |
| 1319 | 0× | } |
| 1320 | | } |
| 1321 | 6× | let mut fks: HashMap<String, Vec<(String, String, String)>> = HashMap::new(); |
| 1322 | 17× | for t in &todo { |
| 1323 | 17× | fks.insert(t.clone(), fk_list(pool, t).await?); |
| 1324 | | } |
| 1325 | | |
| 1326 | 6× | let mut order = Vec::new(); |
| 1327 | 12× | while !todo.is_empty() { |
| 1328 | 6× | let mut ready: Vec<String> = |
| 1329 | 47× | todo.iter().filter(|t| fks[*t].iter().all(|(p, _, _)| p == *t || !todo.contains(p))).cloned().collect(); |
| 1330 | 6× | if ready.is_empty() { |
| 1331 | 0× | ready.push(todo[0].clone()); |
| 1332 | 6× | } |
| 1333 | 27× | todo.retain(|t| !ready.contains(t)); |
| 1334 | 6× | order.extend(ready); |
| 1335 | | } |
| 1336 | 6× | let mut maps: HashMap<String, HashMap<i64, i64>> = HashMap::new(); |
| 1337 | 6× | maps.insert("repositories".into(), [(old, repo)].into()); |
| 1338 | 6× | let mut left_out: std::collections::BTreeMap<String, usize> = Default::default(); |
| 1339 | 6× | let mut tx = pool.begin().await?; |
| 1340 | 17× | for table in &order { |
| 1341 | 17× | let cols = table_cols(pool, table).await?; |
| 1342 | 157× | let keys: Vec<&str> = cols.iter().filter(|c| c.pk > 0).map(|c| c.name.as_str()).collect(); |
| 1343 | 17× | let by_id = keys == ["id"] && cols.iter().any(|c| c.name == "id" && c.ty.eq_ignore_ascii_case("INTEGER")); |
| 1344 | 17× | let verb = if keys == ["repo_id"] { |
| 1345 | 1× | Verb::Replace |
| 1346 | 16× | } else if by_id { |
| 1347 | 14× | Verb::New |
| 1348 | | } else { |
| 1349 | 2× | Verb::Ignore |
| 1350 | | }; |
| 1351 | 17× | let mut rows: Vec<&serde_json::Map<String, serde_json::Value>> = |
| 1352 | 21× | obj[table].as_array().into_iter().flatten().filter_map(|r| r.as_object()).collect(); |
| 1353 | 17× | rows.sort_by_key(|r| r.get("id").and_then(|v| v.as_i64()).unwrap_or(0)); |
| 1354 | 21× | 'row: for row in rows { |
| 1355 | 21× | let (mut names, mut values) = (Vec::new(), Vec::new()); |
| 1356 | 174× | for c in &cols { |
| 1357 | 174× | if by_id && c.name == "id" { |
| 1358 | 17× | continue; |
| 1359 | 157× | } |
| 1360 | 157× | let mut v = row.get(&c.name).cloned().filter(|v| !v.is_null()); |
| 1361 | 301× | if let Some((parent, _, _)) = fks[table].iter().find(|(_, from, _)| *from == c.name) { |
| 1362 | 36× | if let Some(was) = v.as_ref().and_then(|v| v.as_i64()) { |
| 1363 | 31× | let now = match parent.as_str() { |
| 1364 | 31× | "accounts" | "teams" | "organisations" => by_name.get(&(parent.clone(), was)).copied().flatten(), |
| 1365 | 24× | p => maps.get(p).and_then(|m| m.get(&was)).copied(), |
| 1366 | | }; |
| 1367 | 31× | if now.is_none() && c.notnull { |
| 1368 | 1× | *left_out.entry(table.clone()).or_default() += 1; |
| 1369 | 1× | continue 'row; |
| 1370 | 30× | } |
| 1371 | 30× | v = now.map(serde_json::Value::from); |
| 1372 | 5× | } |
| 1373 | 121× | } |
| 1374 | 12× | match v { |
| 1375 | 144× | Some(v) => { |
| 1376 | 144× | names.push(c.name.clone()); |
| 1377 | 144× | values.push((v, c.ty.as_str())); |
| 1378 | 144× | } |
| 1379 | | |
| 1380 | 12× | None if c.notnull && c.default.is_none() => { |
| 1381 | 0× | names.push(c.name.clone()); |
| 1382 | 0× | let blank = if c.ty.to_ascii_uppercase().contains("INT") || c.ty == "BOOLEAN" { |
| 1383 | 0× | serde_json::json!(0) |
| 1384 | | } else { |
| 1385 | 0× | serde_json::json!("") |
| 1386 | | }; |
| 1387 | 0× | values.push((blank, c.ty.as_str())); |
| 1388 | | } |
| 1389 | 12× | None => {} |
| 1390 | | } |
| 1391 | | } |
| 1392 | | |
| 1393 | 20× | let (insert, tail) = catalog::insert(verb, &names); |
| 1394 | 20× | let sql = format!( |
| 1395 | | "{insert} INTO \"{table}\" ({}) VALUES ({}){tail}{}", |
| 1396 | 143× | names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", "), |
| 1397 | 143× | (1..=names.len()).map(|i| format!("${i}")).collect::<Vec<_>>().join(", "), |
| 1398 | 20× | if by_id { " RETURNING id" } else { "" } |
| 1399 | | ); |
| 1400 | 20× | let mut q = sqlx::query(&sql); |
| 1401 | 143× | for (v, ty) in values { |
| 1402 | 143× | q = bind_json(q, v, ty); |
| 1403 | 143× | } |
| 1404 | 20× | let context = || format!("restore a row of {table}"); |
| 1405 | 20× | if by_id { |
| 1406 | 17× | let made: i64 = sqlx::Row::try_get(&q.fetch_one(&mut *tx).await.with_context(context)?, 0)?; |
| 1407 | 17× | if let Some(was) = row.get("id").and_then(|v| v.as_i64()) { |
| 1408 | 17× | maps.entry(table.clone()).or_default().insert(was, made); |
| 1409 | 17× | } |
| 1410 | | } else { |
| 1411 | 3× | q.execute(&mut *tx).await.with_context(context)?; |
| 1412 | | } |
| 1413 | | } |
| 1414 | | } |
| 1415 | 6× | tx.commit().await?; |
| 1416 | 6× | for (table, n) in left_out { |
| 1417 | 1× | notes.push(format!("{table}: {n} row(s) left out: they name accounts or rows this hub lacks")); |
| 1418 | 1× | } |
| 1419 | | |
| 1420 | 12× | for (kind, table) in [("issue", "issues"), ("proposal", "proposals")] { |
| 1421 | 12× | let found: Vec<(i64, i64, String, String)> = |
| 1422 | 12× | sqlx::query_as(&format!("SELECT id, number, title, body FROM {table} WHERE repo_id = $1")).bind(repo).fetch_all(pool).await?; |
| 1423 | 12× | for (id, number, title, body) in found { |
| 1424 | 2× | db::notify::index(pool, kind, repo, id, number, &title, &body).await?; |
| 1425 | | } |
| 1426 | | } |
| 1427 | | |
| 1428 | 6× | let hooks: Vec<String> = |
| 1429 | 6× | sqlx::query_scalar("SELECT url FROM hooks WHERE repo_id = $1 AND secret = ''").bind(repo).fetch_all(pool).await?; |
| 1430 | 6× | for url in hooks { |
| 1431 | 1× | notes.push(format!("webhook {url}: enter its secret again (Settings › Webhooks)")); |
| 1432 | 1× | } |
| 1433 | | |
| 1434 | 6× | for hash in obj.get("release_assets").and_then(|a| a.as_array()).into_iter().flatten().filter_map(|a| a["hash"].as_str()) { |
| 1435 | 64× | if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 1436 | 0× | continue; |
| 1437 | 1× | } |
| 1438 | 1× | let path = crate::web::releases::blob_path(app, hash); |
| 1439 | 1× | if path.exists() { |
| 1440 | 0× | continue; |
| 1441 | 1× | } |
| 1442 | 1× | let got = tokio::task::block_in_place(|| dest.get(&blob_key(hash))); |
| 1443 | 1× | match got { |
| 1444 | 1× | Ok(bytes) if blake3::hash(&bytes).to_hex().as_str() == hash => { |
| 1445 | 1× | std::fs::create_dir_all(path.parent().context("no parent")?)?; |
| 1446 | 1× | std::fs::write(&path, &bytes)?; |
| 1447 | | } |
| 1448 | 0× | Ok(_) => notes.push(format!("release asset {}: the copy does not match its hash", &hash[..12])), |
| 1449 | 0× | Err(_) => notes.push(format!("release asset {}: not in the backup", &hash[..12])), |
| 1450 | | } |
| 1451 | | } |
| 1452 | 6× | Ok(notes) |
| 1453 | 6× | } |
| 1454 | |
|
| 1455 | |
|
| 1456 | |
|
| 1457 | 32× | fn run_name(ms: i64) -> String { |
| 1458 | 32× | let t = time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
| 1459 | 32× | format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second()) |
| 1460 | 32× | } |
| 1461 | |
|
| 1462 | |
|
| 1463 | 15× | fn last_manifests(dest: &dyn Destination) -> HashMap<String, HashMap<String, File>> { |
| 1464 | 15× | let mut out = HashMap::new(); |
| 1465 | 15× | let Ok(at) = dest.get("LATEST").map(|b| String::from_utf8_lossy(&b).trim().to_string()) else { return out }; |
| 1466 | 7× | for name in dest.list(&format!("runs/{at}/repos")).unwrap_or_default() { |
| 1467 | 7× | if let Ok(m) = dest.get(&format!("runs/{at}/repos/{name}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) { |
| 1468 | 75× | out.insert(format!("{}/{}", m.kind, m.name), m.files.into_iter().map(|f| (f.path.clone(), f)).collect()); |
| 1469 | 0× | } |
| 1470 | | } |
| 1471 | 6× | out |
| 1472 | 15× | } |
| 1473 | |
|
| 1474 | |
|
| 1475 | |
|
| 1476 | 16× | pub async fn run(app: &Shared, owner: &str, trigger: &str) -> Result<Run> { |
| 1477 | 16× | let s = setting(&app.db, owner).await?.with_context(|| format!("{owner} has no backup set up"))?; |
| 1478 | 16× | let started = db::now_ms(); |
| 1479 | 16× | let at = run_name(started); |
| 1480 | 16× | let id: i64 = sqlx::query_scalar( |
| 1481 | 16× | "INSERT INTO backup_runs (owner, started_ms, status, at, trigger) VALUES ($1, $2, 'running', $3, $4) RETURNING id", |
| 1482 | 16× | ) |
| 1483 | 16× | .bind(owner) |
| 1484 | 16× | .bind(started) |
| 1485 | 16× | .bind(&at) |
| 1486 | 16× | .bind(trigger) |
| 1487 | 16× | .fetch_one(&app.db) |
| 1488 | 16× | .await?; |
| 1489 | 16× | let outcome = run_into(app, &s, &at, trigger == "now").await; |
| 1490 | 16× | let (status, detail, sent) = match outcome { |
| 1491 | 13× | Ok((sent, problems)) if problems.is_empty() => ("ok", String::new(), sent), |
| 1492 | 0× | Ok((sent, problems)) => ("partial", problems.join("; "), sent), |
| 1493 | 3× | Err(e) => ("failed", format!("{e:#}"), (0, Sent::default())), |
| 1494 | | }; |
| 1495 | 16× | sqlx::query( |
| 1496 | 16× | "UPDATE backup_runs SET finished_ms = $1, status = $2, detail = $3, repos = $4, files = $5, sent = $6, bytes = $7 WHERE id = $8", |
| 1497 | 16× | ) |
| 1498 | 16× | .bind(db::now_ms()) |
| 1499 | 16× | .bind(status) |
| 1500 | 16× | .bind(&detail) |
| 1501 | 16× | .bind(sent.0) |
| 1502 | 16× | .bind(sent.1.files) |
| 1503 | 16× | .bind(sent.1.sent) |
| 1504 | 16× | .bind(sent.1.bytes) |
| 1505 | 16× | .bind(id) |
| 1506 | 16× | .execute(&app.db) |
| 1507 | 16× | .await?; |
| 1508 | 16× | let r: Run = sqlx::query_as("SELECT * FROM backup_runs WHERE id = $1").bind(id).fetch_one(&app.db).await?; |
| 1509 | 16× | if status != "ok" { |
| 1510 | 3× | notify_failure(app, &s, status, &detail).await; |
| 1511 | | } else { |
| 1512 | 13× | notify_recovered(app, &s, &r).await; |
| 1513 | | } |
| 1514 | 16× | Ok(r) |
| 1515 | 16× | } |
| 1516 | |
|
| 1517 | 16× | async fn run_into(app: &Shared, s: &Setting, at: &str, by_hand: bool) -> Result<((i64, Sent), Vec<String>)> { |
| 1518 | 16× | let dest: std::sync::Arc<dyn Destination> = destination(app, s)?.into(); |
| 1519 | 15× | let known = { |
| 1520 | 15× | let d = dest.clone(); |
| 1521 | 15× | tokio::task::spawn_blocking(move || last_manifests(d.as_ref())).await? |
| 1522 | | }; |
| 1523 | | |
| 1524 | 15× | let mine: Vec<db::repos::RepoRow> = |
| 1525 | 26× | db::repos::list_with_drops(&app.db).await?.into_iter().filter(|r| r.owner == s.owner && included(s, r, by_hand)).collect(); |
| 1526 | 15× | let mut problems = Vec::new(); |
| 1527 | 15× | let mut sent = Sent::default(); |
| 1528 | 15× | let mut repos = 0i64; |
| 1529 | 16× | for row in &mine { |
| 1530 | 16× | let Some(name) = RepoName::new(&row.owner, &row.name) else { continue }; |
| 1531 | 32× | for (kind, store) in [("repos", &app.repos), ("wikis", &app.wikis)] { |
| 1532 | 32× | if !store.exists(&name) { |
| 1533 | 16× | continue; |
| 1534 | 16× | } |
| 1535 | 16× | let dir = store.dir(&name); |
| 1536 | 16× | let k = known.get(&format!("{kind}/{}", row.name)).cloned().unwrap_or_default(); |
| 1537 | | |
| 1538 | | |
| 1539 | 16× | let (d1, dir1, k1) = (dest.clone(), dir.clone(), k.clone()); |
| 1540 | 16× | let first = tokio::task::spawn_blocking(move || { |
| 1541 | 16× | let mut s = Sent::default(); |
| 1542 | 16× | copy_dir(d1.as_ref(), &dir1, &k1, true, &mut s).map(|f| (f, s)) |
| 1543 | 16× | }) |
| 1544 | 16× | .await?; |
| 1545 | 16× | let mut k2 = k; |
| 1546 | 16× | if let Ok((files, _)) = &first { |
| 1547 | 68× | for f in files { |
| 1548 | 68× | k2.insert(f.path.clone(), f.clone()); |
| 1549 | 68× | } |
| 1550 | 0× | } |
| 1551 | | |
| 1552 | | |
| 1553 | 16× | let guard = store.write_guard(&name).await; |
| 1554 | 16× | let (d2, dir2) = (dest.clone(), dir.clone()); |
| 1555 | 16× | let second = tokio::task::spawn_blocking(move || { |
| 1556 | 16× | let _guard = guard; |
| 1557 | 16× | let mut s = Sent::default(); |
| 1558 | 16× | copy_dir(d2.as_ref(), &dir2, &k2, false, &mut s).map(|f| (f, s)) |
| 1559 | 16× | }) |
| 1560 | 16× | .await?; |
| 1561 | 16× | match second { |
| 1562 | 16× | Ok((files, s2)) => { |
| 1563 | 16× | let s1 = first.map(|(_, s)| s).unwrap_or_default(); |
| 1564 | 16× | sent.files += s2.files; |
| 1565 | 16× | sent.sent += s1.sent + s2.sent; |
| 1566 | 16× | sent.bytes += s1.bytes + s2.bytes; |
| 1567 | 16× | let m = Manifest { owner: row.owner.clone(), name: row.name.clone(), kind: kind.into(), files }; |
| 1568 | 16× | let key = format!("runs/{at}/repos/{kind}-{}.json", key_name(&row.name)); |
| 1569 | 16× | tokio::task::block_in_place(|| dest.put(&key, &serde_json::to_vec(&m)?))?; |
| 1570 | 16× | if kind == "repos" { |
| 1571 | 16× | repos += 1; |
| 1572 | 16× | } |
| 1573 | | } |
| 1574 | 0× | Err(e) => problems.push(format!("{}/{} ({kind}): {e:#}", row.owner, row.name)), |
| 1575 | | } |
| 1576 | | } |
| 1577 | 16× | let rec = async { |
| 1578 | 16× | let mut r = records(&app.db, "repo_id", row.id).await?; |
| 1579 | | |
| 1580 | | |
| 1581 | | |
| 1582 | 16× | let own = rows(&app.db, "repositories", &format!("id = {}", row.id)).await?; |
| 1583 | 16× | let names = names_of(&app.db, &r).await?; |
| 1584 | 16× | if let Some(m) = r.as_object_mut() { |
| 1585 | 16× | m.insert("repositories".into(), own); |
| 1586 | 16× | m.insert(NAMES.into(), names); |
| 1587 | 16× | } |
| 1588 | 16× | anyhow::Ok(r) |
| 1589 | 16× | }; |
| 1590 | 16× | match rec.await { |
| 1591 | 16× | Ok(r) => { |
| 1592 | 16× | tokio::task::block_in_place(|| { |
| 1593 | 16× | dest.put(&format!("runs/{at}/records/{}.json", key_name(&row.name)), &serde_json::to_vec(&r)?) |
| 1594 | 16× | })?; |
| 1595 | | |
| 1596 | | |
| 1597 | 16× | for hash in r["release_assets"].as_array().into_iter().flatten().filter_map(|a| a["hash"].as_str()) { |
| 1598 | 64× | if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 1599 | 0× | continue; |
| 1600 | 1× | } |
| 1601 | 1× | let path = crate::web::releases::blob_path(app, hash); |
| 1602 | 1× | let key = blob_key(hash); |
| 1603 | 1× | let put = tokio::task::block_in_place(|| -> Result<Option<i64>> { |
| 1604 | 1× | if dest.exists(&key)? { |
| 1605 | 0× | return Ok(None); |
| 1606 | 1× | } |
| 1607 | 1× | let bytes = std::fs::read(&path).with_context(|| format!("read release asset {}", &hash[..12]))?; |
| 1608 | 1× | dest.put(&key, &bytes)?; |
| 1609 | 1× | Ok(Some(bytes.len() as i64)) |
| 1610 | 1× | }); |
| 1611 | 1× | match put { |
| 1612 | 1× | Ok(Some(n)) => { |
| 1613 | 1× | sent.files += 1; |
| 1614 | 1× | sent.sent += 1; |
| 1615 | 1× | sent.bytes += n; |
| 1616 | 1× | } |
| 1617 | 0× | Ok(None) => sent.files += 1, |
| 1618 | 0× | Err(e) => problems.push(format!("{}/{} release asset: {e:#}", row.owner, row.name)), |
| 1619 | | } |
| 1620 | | } |
| 1621 | | } |
| 1622 | 0× | Err(e) => problems.push(format!("{}/{} records: {e:#}", row.owner, row.name)), |
| 1623 | | } |
| 1624 | | } |
| 1625 | | |
| 1626 | 15× | let owner_rows = if s.kind == "org" { |
| 1627 | 0× | match db::accounts::org_by_name(&app.db, &s.owner).await? { |
| 1628 | 0× | Some(o) => records(&app.db, "org_id", o.id).await?, |
| 1629 | 0× | None => serde_json::json!({}), |
| 1630 | | } |
| 1631 | | } else { |
| 1632 | 15× | match db::accounts::account_by_name(&app.db, &s.owner).await? { |
| 1633 | 15× | Some(a) => records(&app.db, "account_id", a.id).await?, |
| 1634 | 0× | None => serde_json::json!({}), |
| 1635 | | } |
| 1636 | | }; |
| 1637 | | |
| 1638 | | |
| 1639 | 15× | tokio::task::block_in_place(|| -> Result<()> { |
| 1640 | 15× | dest.put(&format!("runs/{at}/owner.json"), &serde_json::to_vec(&owner_rows)?)?; |
| 1641 | 13× | dest.put(&format!("runs/{at}/DONE"), at.as_bytes())?; |
| 1642 | 13× | dest.put("LATEST", at.as_bytes())?; |
| 1643 | 13× | prune(dest.as_ref(), s.keep_days) |
| 1644 | 15× | })?; |
| 1645 | 13× | Ok(((repos, sent), problems)) |
| 1646 | 16× | } |
| 1647 | |
|
| 1648 | |
|
| 1649 | |
|
| 1650 | 13× | pub fn prune(dest: &dyn Destination, keep_days: i64) -> Result<()> { |
| 1651 | 13× | let cutoff = run_name(db::now_ms() - keep_days.max(1) * 86_400_000); |
| 1652 | 13× | let runs = dest.list("runs")?; |
| 1653 | 20× | let whole: Vec<&String> = runs.iter().filter(|r| dest.exists(&format!("runs/{r}/DONE")).unwrap_or(false)).collect(); |
| 1654 | 13× | let newest = whole.last().map(|s| s.to_string()); |
| 1655 | 13× | let mut removed = false; |
| 1656 | 20× | for r in &runs { |
| 1657 | 20× | if r.as_str() < cutoff.as_str() && Some(r.to_string()) != newest { |
| 1658 | 0× | dest.remove(&format!("runs/{r}"))?; |
| 1659 | 0× | removed = true; |
| 1660 | 20× | } |
| 1661 | | } |
| 1662 | 13× | if !removed { |
| 1663 | 13× | return Ok(()); |
| 1664 | 0× | } |
| 1665 | 0× | let mut wanted = std::collections::HashSet::new(); |
| 1666 | 0× | for r in dest.list("runs")? { |
| 1667 | 0× | for m in dest.list(&format!("runs/{r}/repos"))? { |
| 1668 | 0× | if let Ok(m) = dest.get(&format!("runs/{r}/repos/{m}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) { |
| 1669 | 0× | wanted.extend(m.files.into_iter().map(|f| f.hash)); |
| 1670 | 0× | } |
| 1671 | | } |
| 1672 | | } |
| 1673 | 0× | for shard in dest.list("blobs")? { |
| 1674 | 0× | for b in dest.list(&format!("blobs/{shard}"))? { |
| 1675 | 0× | if !wanted.contains(&b) { |
| 1676 | 0× | dest.remove(&format!("blobs/{shard}/{b}"))?; |
| 1677 | 0× | } |
| 1678 | | } |
| 1679 | | } |
| 1680 | 0× | Ok(()) |
| 1681 | 13× | } |
| 1682 | |
|
| 1683 | |
|
| 1684 | |
|
| 1685 | 5× | async fn told(app: &Shared, s: &Setting) -> Option<(Vec<(i64, db::notify::Reason)>, String)> { |
| 1686 | 5× | let p = db::notify::Reason::Participating; |
| 1687 | 5× | if s.kind == "org" { |
| 1688 | 0× | let o = db::accounts::org_by_name(&app.db, &s.owner).await.ok().flatten()?; |
| 1689 | 0× | let owners = db::accounts::org_members(&app.db, o.id) |
| 1690 | 0× | .await |
| 1691 | 0× | .ok()? |
| 1692 | 0× | .into_iter() |
| 1693 | 0× | .filter(|(_, r)| r == "owner") |
| 1694 | 0× | .map(|(a, _)| (a.id, p)) |
| 1695 | 0× | .collect(); |
| 1696 | 0× | Some((owners, format!("/orgs/{}/settings/backups", s.owner))) |
| 1697 | | } else { |
| 1698 | 5× | let a = db::accounts::account_by_name(&app.db, &s.owner).await.ok().flatten()?; |
| 1699 | 5× | Some((vec![(a.id, p)], "/settings/backups".into())) |
| 1700 | | } |
| 1701 | 5× | } |
| 1702 | |
|
| 1703 | |
|
| 1704 | 16× | async fn status_before(app: &Shared, owner: &str) -> Option<String> { |
| 1705 | 16× | sqlx::query_scalar("SELECT status FROM backup_runs WHERE owner = $1 AND finished_ms IS NOT NULL ORDER BY id DESC LIMIT 1 OFFSET 1") |
| 1706 | 16× | .bind(owner) |
| 1707 | 16× | .fetch_optional(&app.db) |
| 1708 | 16× | .await |
| 1709 | 16× | .ok() |
| 1710 | 16× | .flatten() |
| 1711 | 16× | } |
| 1712 | |
|
| 1713 | |
|
| 1714 | |
|
| 1715 | 3× | async fn notify_failure(app: &Shared, s: &Setting, status: &str, detail: &str) { |
| 1716 | 3× | if status_before(app, &s.owner).await.is_some_and(|b| b != "ok") { |
| 1717 | 0× | return; |
| 1718 | 3× | } |
| 1719 | 3× | let Some((who, url)) = told(app, s).await else { return }; |
| 1720 | 3× | let title = format!("Backup of {} {status}: {}", s.owner, detail.chars().take(160).collect::<String>()); |
| 1721 | 3× | crate::notify::tell(app, "backup", &title, &url, "backups", None, who).await; |
| 1722 | 3× | } |
| 1723 | |
|
| 1724 | |
|
| 1725 | |
|
| 1726 | 13× | async fn notify_recovered(app: &Shared, s: &Setting, run: &Run) { |
| 1727 | 13× | if !status_before(app, &s.owner).await.is_some_and(|b| b != "ok") { |
| 1728 | 12× | return; |
| 1729 | 1× | } |
| 1730 | 1× | let Some((who, url)) = told(app, s).await else { return }; |
| 1731 | 1× | let title = format!("Backup of {} works again: {} repositories to {}", s.owner, run.repos, place(s)); |
| 1732 | 1× | crate::notify::tell(app, "backup.recovered", &title, &url, "backups", None, who).await; |
| 1733 | 13× | } |
| 1734 | |
|
| 1735 | |
|
| 1736 | | const SUMMARY_HOUR: u8 = 7; |
| 1737 | | const SUMMARY_SENT: &str = "backups.summary_day"; |
| 1738 | |
|
| 1739 | |
|
| 1740 | |
|
| 1741 | |
|
| 1742 | 1× | pub async fn daily_summary(app: &Shared, now_ms: i64, force: bool) -> sqlx::Result<usize> { |
| 1743 | 1× | let t = time::OffsetDateTime::from_unix_timestamp_nanos(now_ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
| 1744 | 1× | let day = format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()); |
| 1745 | 1× | if !force && (t.hour() < SUMMARY_HOUR || db::accounts::setting(&app.db, SUMMARY_SENT).await?.as_deref() == Some(day.as_str())) { |
| 1746 | 0× | return Ok(0); |
| 1747 | 1× | } |
| 1748 | 1× | db::accounts::set_setting(&app.db, SUMMARY_SENT, &day).await?; |
| 1749 | 1× | let settings: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE updated_ms > 0").fetch_all(&app.db).await?; |
| 1750 | 1× | let mut sent = 0; |
| 1751 | 1× | for s in settings { |
| 1752 | 1× | let runs: Vec<Run> = sqlx::query_as("SELECT * FROM backup_runs WHERE owner = $1 AND started_ms >= $2 ORDER BY id") |
| 1753 | 1× | .bind(&s.owner) |
| 1754 | 1× | .bind(now_ms - 86_400_000) |
| 1755 | 1× | .fetch_all(&app.db) |
| 1756 | 1× | .await?; |
| 1757 | 1× | if runs.is_empty() { |
| 1758 | 0× | continue; |
| 1759 | 1× | } |
| 1760 | 2× | let ok = runs.iter().filter(|r| r.status == "ok").count(); |
| 1761 | 1× | let bytes: i64 = runs.iter().map(|r| r.bytes).sum(); |
| 1762 | 1× | let repos = runs.iter().rev().find(|r| r.status == "ok").map_or(0, |r| r.repos); |
| 1763 | 1× | let how = if ok == runs.len() { "all ok".to_string() } else { format!("{} not whole", runs.len() - ok) }; |
| 1764 | 1× | let title = format!( |
| 1765 | | "Backups of {} today: {} runs, {how}, {repos} repositories, {:.1} MiB sent to {}", |
| 1766 | | s.owner, |
| 1767 | 1× | runs.len(), |
| 1768 | 1× | bytes as f64 / 1_048_576.0, |
| 1769 | 1× | place(&s) |
| 1770 | | ); |
| 1771 | 1× | let Some((who, url)) = told(app, &s).await else { continue }; |
| 1772 | 1× | crate::notify::tell(app, "backup.summary", &title, &url, "backups", None, who).await; |
| 1773 | 1× | sent += 1; |
| 1774 | | } |
| 1775 | 1× | Ok(sent) |
| 1776 | 1× | } |
| 1777 | |
|
| 1778 | |
|
| 1779 | |
|
| 1780 | 201× | pub async fn scheduler(app: Shared) { |
| 1781 | 201× | let _ = sqlx::query( |
| 1782 | 201× | "UPDATE backup_runs SET status = 'failed', detail = 'the hub restarted during the run', finished_ms = $1 WHERE status = 'running'", |
| 1783 | | ) |
| 1784 | 201× | .bind(db::now_ms()) |
| 1785 | 201× | .execute(&app.db) |
| 1786 | 201× | .await; |
| 1787 | | loop { |
| 1788 | 201× | tokio::time::sleep(std::time::Duration::from_secs(60)).await; |
| 1789 | 0× | if let Err(e) = daily_summary(&app, db::now_ms(), false).await { |
| 1790 | 0× | tracing::warn!("backup summary: {e}"); |
| 1791 | 0× | } |
| 1792 | 0× | for s in due(&app.db).await.unwrap_or_default() { |
| 1793 | 0× | if let Err(e) = run(&app, &s.owner, "schedule").await { |
| 1794 | 0× | tracing::warn!("backup of {}: {e:#}", s.owner); |
| 1795 | 0× | } |
| 1796 | | } |
| 1797 | | } |
| 1798 | | } |
| 1799 | |
|
| 1800 | |
|
| 1801 | |
|
| 1802 | |
|
| 1803 | |
|
| 1804 | 6× | pub async fn due(pool: &Pool) -> sqlx::Result<Vec<Setting>> { |
| 1805 | 6× | let all: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE updated_ms > 0").fetch_all(pool).await?; |
| 1806 | 6× | let repos = db::repos::list(pool).await?; |
| 1807 | 6× | let now = db::now_ms(); |
| 1808 | 6× | let mut out = Vec::new(); |
| 1809 | 6× | for s in all { |
| 1810 | 9× | let theirs: Vec<db::repos::RepoRow> = repos.iter().filter(|r| r.owner == s.owner).cloned().collect(); |
| 1811 | 6× | let Some(period) = period_with(&s, &theirs) else { continue }; |
| 1812 | 5× | let last: Option<i64> = |
| 1813 | 5× | sqlx::query_scalar("SELECT MAX(started_ms) FROM backup_runs WHERE owner = $1").bind(&s.owner).fetch_one(pool).await?; |
| 1814 | 5× | if last.is_none_or(|l| now - l >= period * 1000) { |
| 1815 | 3× | out.push(s); |
| 1816 | 3× | } |
| 1817 | | } |
| 1818 | 6× | Ok(out) |
| 1819 | 6× | } |
| 1820 | |
|
| 1821 | |
|
| 1822 | |
|
| 1823 | |
|
| 1824 | 5× | pub fn restore(dest: &dyn Destination, at: Option<&str>, repos_root: &Path, wikis_root: &Path) -> Result<Vec<String>> { |
| 1825 | 5× | let at = match at { |
| 1826 | 0× | Some(a) => a.to_string(), |
| 1827 | 5× | None => String::from_utf8_lossy(&dest.get("LATEST").context("no whole run at this destination")?).trim().to_string(), |
| 1828 | | }; |
| 1829 | 5× | if !dest.exists(&format!("runs/{at}/DONE"))? { |
| 1830 | 0× | bail!("run {at} is not whole (no DONE): pick another with --run"); |
| 1831 | 5× | } |
| 1832 | 5× | let mut done = Vec::new(); |
| 1833 | 6× | for name in dest.list(&format!("runs/{at}/repos"))? { |
| 1834 | 6× | let m: Manifest = serde_json::from_slice(&dest.get(&format!("runs/{at}/repos/{name}"))?)?; |
| 1835 | 6× | let root = if m.kind == "wikis" { wikis_root } else { repos_root }; |
| 1836 | 6× | let dir = root.join(&m.owner).join(&m.name); |
| 1837 | 6× | if dir.exists() { |
| 1838 | 0× | bail!("{} exists: restore into an empty hub, or move it aside", dir.display()); |
| 1839 | 6× | } |
| 1840 | 60× | for f in &m.files { |
| 1841 | 60× | let bytes = dest.get(&blob_key(&f.hash))?; |
| 1842 | 60× | if blake3::hash(&bytes).to_hex().as_str() != f.hash { |
| 1843 | 0× | bail!("{}/{} {}: the copy does not match its hash", m.owner, m.name, f.path); |
| 1844 | 60× | } |
| 1845 | 60× | let p = dir.join(&f.path); |
| 1846 | 60× | std::fs::create_dir_all(p.parent().context("no parent")?)?; |
| 1847 | 60× | std::fs::write(&p, &bytes)?; |
| 1848 | | } |
| 1849 | 6× | done.push(format!("{}/{} ({})", m.owner, m.name, m.kind)); |
| 1850 | | } |
| 1851 | 5× | Ok(done) |
| 1852 | 5× | } |
| 1853 | |
|
| 1854 | |
|
| 1855 | |
|
| 1856 | |
|
| 1857 | |
|
| 1858 | |
|
| 1859 | 5× | pub async fn restore_into_hub(app: &Shared, dest: std::sync::Arc<dyn Destination>, at: Option<String>) -> Result<Vec<String>> { |
| 1860 | 5× | let (repos_root, wikis_root) = (app.repos.root().to_path_buf(), app.wikis.root().to_path_buf()); |
| 1861 | 5× | let (d, a) = (dest.clone(), at.clone()); |
| 1862 | 5× | let done = tokio::task::spawn_blocking(move || restore(d.as_ref(), a.as_deref(), &repos_root, &wikis_root)).await??; |
| 1863 | 5× | let at = match at { |
| 1864 | 0× | Some(a) => a, |
| 1865 | 5× | None => String::from_utf8_lossy(&tokio::task::block_in_place(|| dest.get("LATEST"))?).trim().to_string(), |
| 1866 | | }; |
| 1867 | 5× | let mut notes = Vec::new(); |
| 1868 | 6× | for name in tokio::task::block_in_place(|| dest.list(&format!("runs/{at}/records")))? { |
| 1869 | 6× | let rec: serde_json::Value = |
| 1870 | 6× | serde_json::from_slice(&tokio::task::block_in_place(|| dest.get(&format!("runs/{at}/records/{name}")))?)?; |
| 1871 | 6× | let Some(row) = rec["repositories"].as_array().and_then(|a| a.first()) else { continue }; |
| 1872 | 6× | let (owner, repo) = (row["owner"].as_str().unwrap_or_default(), row["name"].as_str().unwrap_or_default()); |
| 1873 | 6× | if owner.is_empty() || db::repos::get(&app.db, owner, repo).await?.is_some() { |
| 1874 | 0× | continue; |
| 1875 | 6× | } |
| 1876 | 6× | let visibility = match row["visibility"].as_str() { |
| 1877 | 6× | Some("private") => hub_api::Visibility::Private, |
| 1878 | 4× | Some("internal") => hub_api::Visibility::Internal, |
| 1879 | 4× | _ => hub_api::Visibility::Public, |
| 1880 | | }; |
| 1881 | 6× | let made = db::repos::create(&app.db, owner, repo, row["description"].as_str().unwrap_or_default(), visibility).await?; |
| 1882 | | |
| 1883 | | |
| 1884 | 6× | let cols = table_cols(&app.db, "repositories").await?; |
| 1885 | 6× | let set: Vec<(&Col, serde_json::Value)> = cols |
| 1886 | 6× | .iter() |
| 1887 | 180× | .filter(|c| !["id", "owner", "name", "forked_from"].contains(&c.name.as_str())) |
| 1888 | 156× | .filter_map(|c| row.get(&c.name).filter(|v| !v.is_null()).map(|v| (c, v.clone()))) |
| 1889 | 6× | .collect(); |
| 1890 | 6× | if !set.is_empty() { |
| 1891 | 6× | let sql = format!( |
| 1892 | | "UPDATE repositories SET {} WHERE id = ${}", |
| 1893 | 156× | set.iter().enumerate().map(|(i, (c, _))| format!("\"{}\" = ${}", c.name, i + 1)).collect::<Vec<_>>().join(", "), |
| 1894 | 6× | set.len() + 1 |
| 1895 | | ); |
| 1896 | 6× | let mut q = sqlx::query(&sql); |
| 1897 | 156× | for (c, v) in set { |
| 1898 | 156× | q = bind_json(q, v, &c.ty); |
| 1899 | 156× | } |
| 1900 | 6× | q.bind(made.id).execute(&app.db).await?; |
| 1901 | 0× | } |
| 1902 | 6× | let old = row["id"].as_i64().unwrap_or(-1); |
| 1903 | 6× | for n in restore_records(app, &dest, &rec, old, made.id).await.with_context(|| format!("restore {owner}/{repo}'s records"))? { |
| 1904 | 2× | notes.push(format!("note: {owner}/{repo}: {n}")); |
| 1905 | 2× | } |
| 1906 | | } |
| 1907 | 5× | let mut done = done; |
| 1908 | 5× | done.extend(notes); |
| 1909 | 5× | Ok(done) |
| 1910 | 5× | } |
| 1911 | |
|
| 1912 | | #[cfg(test)] |
| 1913 | | mod tests { |
| 1914 | | use super::*; |
| 1915 | |
|
| 1916 | | #[test] |
| 1917 | 1× | fn a_directory_destination_keeps_keys_inside_its_root() { |
| 1918 | 1× | let d = tempfile::tempdir().unwrap(); |
| 1919 | 1× | let dest = Directory { root: d.path().to_path_buf() }; |
| 1920 | 1× | dest.put("runs/a/DONE", b"a").unwrap(); |
| 1921 | 1× | assert_eq!(dest.get("runs/a/DONE").unwrap(), b"a"); |
| 1922 | 1× | assert_eq!(dest.list("runs").unwrap(), vec!["a"]); |
| 1923 | 1× | assert!(dest.put("../escape", b"x").is_err() && dest.put("/abs", b"x").is_err()); |
| 1924 | 1× | dest.remove("runs").unwrap(); |
| 1925 | 1× | assert!(dest.list("runs").unwrap().is_empty()); |
| 1926 | 1× | } |
| 1927 | |
|
| 1928 | | #[test] |
| 1929 | 1× | fn files_are_sent_once_and_unchanged_ones_not_read_again() { |
| 1930 | 1× | let src = tempfile::tempdir().unwrap(); |
| 1931 | 1× | let out = tempfile::tempdir().unwrap(); |
| 1932 | 1× | let dest = Directory { root: out.path().to_path_buf() }; |
| 1933 | 1× | std::fs::create_dir_all(src.path().join(".oxy/objects/loose")).unwrap(); |
| 1934 | 1× | std::fs::write(src.path().join(".oxy/objects/loose/aa"), b"object").unwrap(); |
| 1935 | 1× | std::fs::write(src.path().join(".oxy/ops-head"), b"ref one").unwrap(); |
| 1936 | 1× | let mut s = Sent::default(); |
| 1937 | 1× | let files = copy_dir(&dest, src.path(), &HashMap::new(), false, &mut s).unwrap(); |
| 1938 | 1× | assert_eq!((files.len(), s.sent), (2, 2)); |
| 1939 | 2× | let known: HashMap<String, File> = files.iter().map(|f| (f.path.clone(), f.clone())).collect(); |
| 1940 | 1× | let mut s = Sent::default(); |
| 1941 | 1× | let again = copy_dir(&dest, src.path(), &known, false, &mut s).unwrap(); |
| 1942 | 1× | assert_eq!((again, s.sent), (files.clone(), 0), "nothing new travels"); |
| 1943 | | |
| 1944 | 1× | let mut s = Sent::default(); |
| 1945 | 1× | let first = copy_dir(&dest, src.path(), &HashMap::new(), true, &mut s).unwrap(); |
| 1946 | 1× | assert_eq!(first.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec![".oxy/objects/loose/aa"]); |
| 1947 | 1× | } |
| 1948 | |
|
| 1949 | | #[test] |
| 1950 | 1× | fn requests_are_signed_as_aws_documents() { |
| 1951 | | |
| 1952 | | |
| 1953 | | |
| 1954 | | |
| 1955 | 1× | let key_id = format!("{}{}", "AKIA", "IOSFODNN7EXAMPLE"); |
| 1956 | 1× | let secret = format!("{}{}", "wJalrXUtnFEMI/K7MDENG/", "bPxRfiCYEXAMPLEKEY"); |
| 1957 | 1× | let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; |
| 1958 | 1× | let headers = vec![ |
| 1959 | 1× | ("Host".to_string(), "examplebucket.s3.amazonaws.com".to_string()), |
| 1960 | 1× | ("Range".to_string(), "bytes=0-9".to_string()), |
| 1961 | 1× | ("x-amz-content-sha256".to_string(), empty.to_string()), |
| 1962 | 1× | ("x-amz-date".to_string(), "20130524T000000Z".to_string()), |
| 1963 | | ]; |
| 1964 | 1× | let auth = |
| 1965 | 1× | sigv4("GET", "/test.txt", &[], &headers, empty, (key_id.as_str(), secret.as_str()), "us-east-1", "s3", "20130524T000000Z"); |
| 1966 | 1× | assert_eq!( |
| 1967 | | auth, |
| 1968 | 1× | format!( |
| 1969 | | "AWS4-HMAC-SHA256 Credential={key_id}/20130524/us-east-1/s3/aws4_request, \ |
| 1970 | | SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, \ |
| 1971 | | Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" |
| 1972 | | ) |
| 1973 | | ); |
| 1974 | 1× | assert_eq!(aws_encode("runs/2026 x+y", true), "runs/2026%20x%2By"); |
| 1975 | 1× | assert_eq!(aws_encode("a/b", false), "a%2Fb"); |
| 1976 | 1× | assert_eq!(xml_text("<A><Key>runs/x&y</Key></A>", "Key").as_deref(), Some("runs/x&y")); |
| 1977 | 1× | } |
| 1978 | |
|
| 1979 | | #[test] |
| 1980 | 1× | fn run_names_sort_by_time() { |
| 1981 | 1× | assert_eq!(run_name(0), "19700101T000000Z"); |
| 1982 | 1× | assert!(run_name(1_791_900_000_000) < run_name(1_791_903_600_000)); |
| 1983 | 1× | } |
| 1984 | | } |