OXYGENHUB

Coverage / crates/hub/src/web/artifacts.rs 87.0%

107 of 123 lines run, 13 of 48 functions · run #538 on main · run missed

1
//! A repository's Artifacts menu (oxygenhub#254): every run's build
2
//! artifacts in one list, the runner images its jobs use, and its
3
//! Terraform state. Releases and reports keep their own pages.
4
5
use super::settings::{csrf, require};
6
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
7
use crate::auth::{Access, Viewer};
8
use crate::db::flow as fdb;
9
use crate::{db, Shared};
10
use askama::Template;
11
use axum::extract::{Form, Path, Query, State};
12
use axum::http::header;
13
use axum::response::{IntoResponse, Response};
14
use serde::Deserialize;
15
16
/// At most this many artifacts are listed, newest first.
17
const ARTIFACTS_SHOWN: i64 = 200;
18
/// Images used within this many days are listed.
19
const IMAGE_DAYS: i64 = 90;
20
21
#[derive(Template)]
22
#[template(path = "repo_artifacts.html")]
23
pub struct ArtifactsPage {
24
    shell: Shell,
25
    repo: RepoShell,
26
    artifacts: Vec<fdb::RepoArtifact>,
27
    limit: i64,
28
}
29
30
/// `GET /{owner}/{name}/artifacts`.
312×
pub async fn artifacts(
322×
    State(app): State<Shared>,
332×
    viewer: Viewer,
342×
    Path((owner, name)): Path<(String, String)>,
352×
) -> Result<Page<ArtifactsPage>, Error> {
362×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/artifacts"), &viewer);
372×
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
382×
    let artifacts = fdb::repo_artifacts(&app.db, row.id, ARTIFACTS_SHOWN).await.map_err(|e| Error::from_db(shell.clone(), e))?;
392×
    Ok(Page(ArtifactsPage { shell, repo: RepoShell::new(&app, &row, "artifacts").with_role(role), artifacts, limit: ARTIFACTS_SHOWN }))
402×
}
41
42
/// One image this repository's jobs ran on.
43
pub struct ImageView {
44
    /// The image's BLAKE3, or what the pipeline named when it is not a hash.
45
    pub image: String,
46
    pub hashed: bool,
47
    pub jobs: i64,
48
    pub last_ms: i64,
49
    /// Kept by the hub: its size and architecture.
50
    pub kept: Option<(i64, String)>,
51
}
52
53
#[derive(Template)]
54
#[template(path = "repo_images.html")]
55
pub struct ImagesPage {
56
    shell: Shell,
57
    repo: RepoShell,
58
    images: Vec<ImageView>,
59
    days: i64,
60
}
61
62
/// `GET /{owner}/{name}/images`.
631×
pub async fn images(
641×
    State(app): State<Shared>,
651×
    viewer: Viewer,
661×
    Path((owner, name)): Path<(String, String)>,
671×
) -> Result<Page<ImagesPage>, Error> {
681×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/images"), &viewer);
691×
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
701×
    let e = |e| Error::from_db(shell.clone(), e);
711×
    let uses = crate::images::uses_of(&app.db, row.id, db::now_ms() - IMAGE_DAYS * 24 * 3600 * 1000).await.map_err(e)?;
721×
    let kept = crate::images::kept(&app.db).await.map_err(e)?;
731×
    let images = uses
741×
        .into_iter()
751×
        .map(|u| {
760×
            let kept = kept.iter().find(|k| k.hash.eq_ignore_ascii_case(&u.image)).map(|k| (k.size, k.arch.clone()));
770×
            ImageView { hashed: jet::images::is_hash(&u.image), image: u.image, jobs: u.jobs, last_ms: u.last_ms, kept }
780×
        })
791×
        .collect();
801×
    Ok(Page(ImagesPage { shell, repo: RepoShell::new(&app, &row, "images").with_role(role), images, days: IMAGE_DAYS }))
811×
}
82
83
/// A state with its kept versions, newest first.
84
pub struct StateView {
85
    pub s: db::terraform::TfState,
86
    pub versions: Vec<db::terraform::TfVersion>,
87
}
88
89
#[derive(Template)]
90
#[template(path = "repo_terraform.html")]
91
pub struct TerraformPage {
92
    shell: Shell,
93
    repo: RepoShell,
94
    states: Vec<StateView>,
95
    notice: Option<String>,
96
    error: Option<String>,
97
}
98
995×
async fn terraform_page(
1005×
    app: &Shared,
1015×
    shell: Shell,
1025×
    row: db::repos::RepoRow,
1035×
    role: crate::auth::Role,
1045×
    notice: Option<String>,
1055×
    error: Option<String>,
1065×
) -> Result<Page<TerraformPage>, Error> {
1075×
    let e = |e| Error::from_db(shell.clone(), e);
1085×
    let mut states = Vec::new();
109
    // States hold secrets (#256): only writers see them listed.
1105×
    if crate::auth::roles::allows(Some(role), Access::Write) {
1113×
        for s in db::terraform::of_repo(&app.db, row.id).await.map_err(e)? {
1123×
            let versions = db::terraform::version_list(&app.db, row.id, &s.name).await.map_err(e)?;
1133×
            states.push(StateView { s, versions });
114
        }
1152×
    }
1165×
    Ok(Page(TerraformPage { shell, repo: RepoShell::new(app, &row, "terraform").with_role(role), states, notice, error }))
1175×
}
118
119
/// `GET /{owner}/{name}/terraform`: the states the hub keeps for this
120
/// repository (oxygenhub#252), their versions, and the backend block.
1214×
pub async fn terraform(
1224×
    State(app): State<Shared>,
1234×
    viewer: Viewer,
1244×
    Path((owner, name)): Path<(String, String)>,
1254×
) -> Result<Page<TerraformPage>, Error> {
1264×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
1274×
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1284×
    terraform_page(&app, shell, row, role, None, None).await
1294×
}
130
131
#[derive(Deserialize)]
132
pub struct DownloadQuery {
133
    version: Option<i64>,
134
}
135
136
/// `GET /{owner}/{name}/terraform/{state}/download[?version=ID]`: the
137
/// current state, or a kept version, for people with the write role.
1381×
pub async fn download(
1391×
    State(app): State<Shared>,
1401×
    viewer: Viewer,
1411×
    Path((owner, name, state)): Path<(String, String, String)>,
1421×
    Query(q): Query<DownloadQuery>,
1431×
) -> Result<Response, Error> {
1441×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
1451×
    let (_repo, row, _role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
1461×
    let e = |e| Error::from_db(shell.clone(), e);
1471×
    let (body, serial) = match q.version {
1481×
        Some(id) => db::terraform::version_body(&app.db, row.id, &state, id).await.map_err(e)?,
1490×
        None => db::terraform::get(&app.db, row.id, &state).await.map_err(e)?.filter(|s| !s.body.is_empty()).map(|s| (s.body, s.serial)),
150
    }
1511×
    .ok_or_else(|| Error::not_found(shell.clone()))?;
1521×
    let plain = app.secret_key.open(&body).ok_or_else(|| Error::not_found(shell.clone()))?;
1534×
    let file = format!("{}-serial-{serial}.tfstate", state.replace(|c: char| !(c.is_ascii_alphanumeric() || "-_.".contains(c)), "_"));
1541×
    Ok((
1551×
        [
1561×
            (header::CONTENT_TYPE, "application/json".to_string()),
1571×
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{file}\"")),
1581×
            (header::CACHE_CONTROL, "no-store".to_string()),
1591×
        ],
1601×
        plain,
1611×
    )
1621×
        .into_response())
1631×
}
164
165
#[derive(Deserialize)]
166
pub struct RestoreForm {
167
    #[serde(rename = "_csrf")]
168
    csrf_token: String,
169
    version: i64,
170
}
171
172
/// `POST /{owner}/{name}/terraform/{state}/restore` (#255): restore a kept
173
/// version, by someone with the maintain role, audited.
1741×
pub async fn restore(
1751×
    State(app): State<Shared>,
1761×
    viewer: Viewer,
1771×
    Path((owner, name, state)): Path<(String, String, String)>,
1781×
    Form(f): Form<RestoreForm>,
1791×
) -> Result<Page<TerraformPage>, Error> {
1801×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
1811×
    let me = require(&viewer, &shell)?;
1821×
    let (_repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1831×
    csrf(&viewer, &f.csrf_token, &shell)?;
1841×
    match crate::terraform::restore(&app, row.id, &state, f.version, &me.account.username).await {
1851×
        Ok(serial) => {
1861×
            db::accounts::audit(
1871×
                &app.db,
1881×
                Some(&me.account),
1891×
                "terraform.restored",
1901×
                &format!("{owner}/{name}:{state}"),
1911×
                &format!("version {}, now serial {serial}", f.version),
1921×
            )
1931×
            .await
1941×
            .ok();
1951×
            let msg = format!(
196
                "Restored {state}: it is now serial {serial}. The state it replaced is kept as a version, so you can go back to it."
197
            );
1981×
            terraform_page(&app, shell, row, role, Some(msg), None).await
199
        }
2000×
        Err(e) => terraform_page(&app, shell, row, role, None, Some(format!("Could not restore {state}: {}.", e.message()))).await,
201
    }
2021×
}