OXYGENHUB

Coverage / crates/hub/src/web/backups.rs 55.9%

118 of 211 lines run, 22 of 90 functions · run #525 on main · run missed

1
//! Backups' pages (docs/backups.md §5, oxygenhub#169): a user's under
2
//! Settings › Backups, an organisation's under its settings, each with
3
//! where its repositories go, how often, how long runs are kept, "back up
4
//! now" and the recent runs.
5
6
use super::settings::{csrf, require};
7
use super::{filters, Error, Page, Shell};
8
use crate::auth::Viewer;
9
use crate::backups::{self, Overview, Run, Setting};
10
use crate::Shared;
11
use askama::Template;
12
use axum::extract::{Path, State};
13
use axum::Form;
14
use serde::Deserialize;
15
16
#[derive(Template)]
17
#[template(path = "backups.html")]
18
pub struct BackupsPage {
19
    shell: Shell,
20
    /// `user` or `org`.
21
    kind: &'static str,
22
    owner: String,
23
    /// Where the form posts.
24
    base: String,
25
    setting: Setting,
26
    runs: Vec<Run>,
27
    /// The directories this hub allows.
28
    dirs: Vec<String>,
29
    notice: Option<String>,
30
    error: Option<String>,
31
}
32
33
impl BackupsPage {
348×
    fn every(&self) -> &'static [(&'static str, &'static str)] {
358×
        backups::EVERY
368×
    }
378×
    fn keeps(&self) -> &'static [i64] {
388×
        backups::KEEP
398×
    }
40
}
41
428×
async fn page(
438×
    app: &Shared,
448×
    shell: Shell,
458×
    kind: &'static str,
468×
    owner: &str,
478×
    notice: Option<String>,
488×
    error: Option<String>,
498×
) -> Result<Page<BackupsPage>, Error> {
508×
    let e = |e| Error::from_db(shell.clone(), e);
518×
    let setting = backups::setting(&app.db, owner).await.map_err(e)?.unwrap_or_else(|| Setting::off(owner, kind));
528×
    let runs = backups::runs(&app.db, owner, 50).await.map_err(e)?;
538×
    let base = if kind == "org" { format!("/orgs/{owner}/settings/backups") } else { "/settings/backups".into() };
548×
    let dirs = app.config.backups.directories.iter().map(|d| d.display().to_string()).collect();
558×
    Ok(Page(BackupsPage { shell, kind, owner: owner.into(), base, setting, runs, dirs, notice, error }))
568×
}
57
58
#[derive(Deserialize)]
59
pub struct BackupForm {
60
    #[serde(rename = "_csrf")]
61
    csrf_token: String,
62
    #[serde(flatten)]
63
    input: SettingInput,
64
}
65
66
/// What a backup setting is made from, by the page's form or the API
67
/// (oxygenhub#179): checked the same way either way.
68
#[derive(Clone, Debug, Default, Deserialize)]
69
pub(crate) struct SettingInput {
70
    pub every: String,
71
    #[serde(default)]
72
    pub destination: String,
73
    #[serde(default)]
74
    pub dir: String,
75
    #[serde(default)]
76
    pub bucket: String,
77
    #[serde(default)]
78
    pub provider: String,
79
    #[serde(default)]
80
    pub prefix: String,
81
    #[serde(default)]
82
    pub keep_days: String,
83
    /// An S3 destination's bucket, region and the IAM role the hub assumes.
84
    #[serde(default)]
85
    pub s3_bucket: String,
86
    #[serde(default)]
87
    pub s3_region: String,
88
    #[serde(default)]
89
    pub s3_role: String,
90
}
91
92
/// The form as a setting, or why it cannot be one.
939×
pub(crate) fn setting_of(app: &Shared, owner: &str, kind: &str, f: &SettingInput) -> Result<Setting, String> {
9420×
    if !backups::EVERY.iter().any(|(v, _)| *v == f.every) {
950×
        return Err("Pick how often: off, hourly or daily.".into());
969×
    }
979×
    let keep_days: i64 = f.keep_days.parse().unwrap_or(30);
989×
    if !backups::KEEP.contains(&keep_days) {
990×
        return Err("Keep runs for 7, 30 or 90 days.".into());
1009×
    }
1019×
    let prefix = f.prefix.trim().trim_matches('/').to_string();
1029×
    if prefix.split('/').any(|p| p == ".." || p == ".") {
1030×
        return Err("The prefix is a plain path, without . or .. parts.".into());
1049×
    }
1059×
    let mut region = String::new();
1069×
    let (target, provider) = match f.destination.as_str() {
1079×
        "dir" => {
1083×
            if !app.config.backups.directories.iter().any(|d| d.display().to_string() == f.dir) {
1091×
                return Err("Pick one of the directories this hub allows.".into());
1102×
            }
1112×
            (f.dir.clone(), String::new())
112
        }
1136×
        "s3" => {
1142×
            let bucket = f.s3_bucket.trim().trim_start_matches("s3://").trim_end_matches('/').to_string();
11522×
            if bucket.is_empty() || !bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-.".contains(c)) {
1160×
                return Err("Give the S3 bucket's name, such as andymccall-oxygen-backup.".into());
1172×
            }
1182×
            region = f.s3_region.trim().to_string();
11918×
            if region.is_empty() || !region.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
1200×
                return Err("Give the bucket's region, such as eu-west-2.".into());
1212×
            }
1222×
            let role = f.s3_role.trim().to_string();
1232×
            if !role.starts_with("arn:aws:iam::") || !role.contains(":role/") {
1241×
                return Err("Give the IAM role's ARN, arn:aws:iam::ACCOUNT:role/NAME (docs/backups.md §6).".into());
1251×
            }
1261×
            (bucket, role)
127
        }
1284×
        "gcs" => {
1294×
            let bucket = f.bucket.trim().trim_start_matches("gs://").trim_end_matches('/').to_string();
13044×
            if bucket.is_empty() || !bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-_.".contains(c)) {
1310×
                return Err("Give the bucket's name, such as andymccall-oxygen-backup.".into());
1324×
            }
1334×
            let provider = f.provider.trim().to_string();
1344×
            if !provider.starts_with("//iam.googleapis.com/projects/") || !provider.contains("/providers/") {
1352×
                return Err(
1362×
                    "Give the workload identity provider's full name, //iam.googleapis.com/projects/…/providers/… (docs/backups.md §6)."
1372×
                        .into(),
1382×
                );
1392×
            }
1402×
            (bucket, provider)
141
        }
1420×
        _ => return Err("Pick where backups go.".into()),
143
    };
1445×
    Ok(Setting {
1455×
        owner: owner.into(),
1465×
        kind: kind.into(),
1475×
        every: f.every.clone(),
1485×
        destination: f.destination.clone(),
1495×
        target,
1505×
        prefix,
1515×
        provider,
1525×
        keep_days,
1535×
        updated_ms: 0,
1545×
        region,
1555×
    })
1569×
}
157
1586×
async fn save(app: &Shared, shell: Shell, kind: &'static str, owner: &str, f: &BackupForm) -> Result<Page<BackupsPage>, Error> {
1596×
    match setting_of(app, owner, kind, &f.input) {
1603×
        Ok(s) => {
1613×
            backups::save(&app.db, &s).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1623×
            page(app, shell, kind, owner, Some("Saved.".into()), None).await
163
        }
1643×
        Err(why) => page(app, shell, kind, owner, None, Some(why)).await,
165
    }
1666×
}
167
1680×
async fn run_now(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result<Page<BackupsPage>, Error> {
1690×
    let set = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1700×
    if set.is_none() {
1710×
        return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await;
1720×
    }
1730×
    let (app2, owner2) = (app.clone(), owner.to_string());
1740×
    tokio::spawn(async move {
1750×
        if let Err(e) = backups::run(&app2, &owner2, "now").await {
1760×
            tracing::warn!("backup of {owner2}: {e:#}");
1770×
        }
1780×
    });
1790×
    page(app, shell, kind, owner, Some("Backing up now: the run shows below when it finishes.".into()), None).await
1800×
}
181
182
/// TEST: reach the saved destination and write, read and remove a probe.
1830×
async fn test(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result<Page<BackupsPage>, Error> {
1840×
    let Some(set) = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
1850×
        return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await;
186
    };
1870×
    let outcome = match backups::destination(app, &set) {
1880×
        Ok(dest) => {
1890×
            tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap_or_else(|e| Err(anyhow::anyhow!("{e}")))
190
        }
1910×
        Err(e) => Err(e),
192
    };
1930×
    match outcome {
194
        Ok(()) => {
1950×
            let place = backups::place(&set);
1960×
            page(app, shell, kind, owner, Some(format!("TEST passed: wrote, read and removed a probe in {place}.")), None).await
197
        }
1980×
        Err(e) => page(app, shell, kind, owner, None, Some(format!("TEST failed: {e:#}"))).await,
199
    }
2000×
}
201
202
#[derive(Deserialize)]
203
pub struct Csrf {
204
    #[serde(rename = "_csrf")]
205
    csrf_token: String,
206
}
207
2081×
pub async fn user(State(app): State<Shared>, viewer: Viewer) -> Result<Page<BackupsPage>, Error> {
2091×
    let shell = Shell::new(&app, "/settings/backups", &viewer);
2101×
    let me = require(&viewer, &shell)?;
2111×
    page(&app, shell, "user", &me.account.username, None, None).await
2121×
}
213
2146×
pub async fn user_save(State(app): State<Shared>, viewer: Viewer, Form(f): Form<BackupForm>) -> Result<Page<BackupsPage>, Error> {
2156×
    let shell = Shell::new(&app, "/settings/backups", &viewer);
2166×
    let me = require(&viewer, &shell)?;
2176×
    csrf(&viewer, &f.csrf_token, &shell)?;
2186×
    save(&app, shell, "user", &me.account.username, &f).await
2196×
}
220
2210×
pub async fn user_run(State(app): State<Shared>, viewer: Viewer, Form(f): Form<Csrf>) -> Result<Page<BackupsPage>, Error> {
2220×
    let shell = Shell::new(&app, "/settings/backups", &viewer);
2230×
    let me = require(&viewer, &shell)?;
2240×
    csrf(&viewer, &f.csrf_token, &shell)?;
2250×
    run_now(&app, shell, "user", &me.account.username).await
2260×
}
227
2282×
pub async fn org(State(app): State<Shared>, viewer: Viewer, Path(name): Path<String>) -> Result<Page<BackupsPage>, Error> {
2292×
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
2302×
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
2311×
    page(&app, shell, "org", &org.name, None, None).await
2322×
}
233
2340×
pub async fn org_save(
2350×
    State(app): State<Shared>,
2360×
    viewer: Viewer,
2370×
    Path(name): Path<String>,
2380×
    Form(f): Form<BackupForm>,
2390×
) -> Result<Page<BackupsPage>, Error> {
2400×
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
2410×
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
2420×
    csrf(&viewer, &f.csrf_token, &shell)?;
2430×
    save(&app, shell, "org", &org.name, &f).await
2440×
}
245
2460×
pub async fn org_run(
2470×
    State(app): State<Shared>,
2480×
    viewer: Viewer,
2490×
    Path(name): Path<String>,
2500×
    Form(f): Form<Csrf>,
2510×
) -> Result<Page<BackupsPage>, Error> {
2520×
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
2530×
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
2540×
    csrf(&viewer, &f.csrf_token, &shell)?;
2550×
    run_now(&app, shell, "org", &org.name).await
2560×
}
257
2580×
pub async fn user_test(State(app): State<Shared>, viewer: Viewer, Form(f): Form<Csrf>) -> Result<Page<BackupsPage>, Error> {
2590×
    let shell = Shell::new(&app, "/settings/backups", &viewer);
2600×
    let me = require(&viewer, &shell)?;
2610×
    csrf(&viewer, &f.csrf_token, &shell)?;
2620×
    test(&app, shell, "user", &me.account.username).await
2630×
}
264
2650×
pub async fn org_test(
2660×
    State(app): State<Shared>,
2670×
    viewer: Viewer,
2680×
    Path(name): Path<String>,
2690×
    Form(f): Form<Csrf>,
2700×
) -> Result<Page<BackupsPage>, Error> {
2710×
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
2720×
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
2730×
    csrf(&viewer, &f.csrf_token, &shell)?;
2740×
    test(&app, shell, "org", &org.name).await
2750×
}
276
277
#[derive(Template)]
278
#[template(path = "admin_backups.html")]
279
pub struct AdminPage {
280
    shell: Shell,
281
    owners: Vec<Overview>,
282
    /// Owners of repositories no backup covers, with how many each has.
283
    uncovered: Vec<(String, i64)>,
284
}
285
286
/// Admin › Backups (docs/backups.md §5): every owner's backup, whether it
287
/// ran and how it went, and who has none. Administrators only.
2883×
pub async fn admin(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AdminPage>, Error> {
2893×
    let shell = Shell::new(&app, "/admin/backups", &viewer);
2903×
    if !viewer.is_admin() {
2911×
        return Err(Error::not_found(shell));
2922×
    }
2932×
    let (owners, uncovered) = backups::overview(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2942×
    Ok(Page(AdminPage { shell, owners, uncovered }))
2953×
}