OXYGENHUB
# 946fe7a09faa oxygenhub / crates / hub / src / web / artifacts.rs
7.7 KiB · Rust · last changed Terraform state: list, download and restore kept versions 18 days ago source blame history raw
16ee5d19 Andy McCall ↶ 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
67523e06 Andy McCall ↶ 5
use super::settings::{csrf, require};
6
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
7
use crate::auth::{Access, Viewer};
16ee5d19 Andy McCall ↶ 8
use crate::db::flow as fdb;
9
use crate::{db, Shared};
10
use askama::Template;
67523e06 Andy McCall ↶ 11
use axum::extract::{Form, Path, Query, State};
12
use axum::http::header;
13
use axum::response::{IntoResponse, Response};
14
use serde::Deserialize;
16ee5d19 Andy McCall ↶ 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`.
31
pub async fn artifacts(
32
    State(app): State<Shared>,
33
    viewer: Viewer,
34
    Path((owner, name)): Path<(String, String)>,
35
) -> Result<Page<ArtifactsPage>, Error> {
36
    let shell = Shell::new(&app, &format!("/{owner}/{name}/artifacts"), &viewer);
37
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
38
    let artifacts = fdb::repo_artifacts(&app.db, row.id, ARTIFACTS_SHOWN).await.map_err(|e| Error::from_db(shell.clone(), e))?;
39
    Ok(Page(ArtifactsPage { shell, repo: RepoShell::new(&app, &row, "artifacts").with_role(role), artifacts, limit: ARTIFACTS_SHOWN }))
40
}
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`.
63
pub async fn images(
64
    State(app): State<Shared>,
65
    viewer: Viewer,
66
    Path((owner, name)): Path<(String, String)>,
67
) -> Result<Page<ImagesPage>, Error> {
68
    let shell = Shell::new(&app, &format!("/{owner}/{name}/images"), &viewer);
69
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
70
    let e = |e| Error::from_db(shell.clone(), e);
71
    let uses = crate::images::uses_of(&app.db, row.id, db::now_ms() - IMAGE_DAYS * 24 * 3600 * 1000).await.map_err(e)?;
72
    let kept = crate::images::kept(&app.db).await.map_err(e)?;
73
    let images = uses
74
        .into_iter()
75
        .map(|u| {
76
            let kept = kept.iter().find(|k| k.hash.eq_ignore_ascii_case(&u.image)).map(|k| (k.size, k.arch.clone()));
77
            ImageView { hashed: jet::images::is_hash(&u.image), image: u.image, jobs: u.jobs, last_ms: u.last_ms, kept }
78
        })
79
        .collect();
80
    Ok(Page(ImagesPage { shell, repo: RepoShell::new(&app, &row, "images").with_role(role), images, days: IMAGE_DAYS }))
81
}
82
67523e06 Andy McCall ↶ 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
16ee5d19 Andy McCall ↶ 89
#[derive(Template)]
90
#[template(path = "repo_terraform.html")]
91
pub struct TerraformPage {
92
    shell: Shell,
93
    repo: RepoShell,
67523e06 Andy McCall ↶ 94
    states: Vec<StateView>,
95
    notice: Option<String>,
96
    error: Option<String>,
97
}
98
99
async fn terraform_page(
100
    app: &Shared,
101
    shell: Shell,
102
    row: db::repos::RepoRow,
103
    role: crate::auth::Role,
104
    notice: Option<String>,
105
    error: Option<String>,
106
) -> Result<Page<TerraformPage>, Error> {
107
    let e = |e| Error::from_db(shell.clone(), e);
108
    let mut states = Vec::new();
109
    // States hold secrets (#256): only writers see them listed.
110
    if crate::auth::roles::allows(Some(role), Access::Write) {
111
        for s in db::terraform::of_repo(&app.db, row.id).await.map_err(e)? {
112
            let versions = db::terraform::version_list(&app.db, row.id, &s.name).await.map_err(e)?;
113
            states.push(StateView { s, versions });
114
        }
115
    }
116
    Ok(Page(TerraformPage { shell, repo: RepoShell::new(app, &row, "terraform").with_role(role), states, notice, error }))
16ee5d19 Andy McCall ↶ 117
}
118
119
/// `GET /{owner}/{name}/terraform`: the states the hub keeps for this
67523e06 Andy McCall ↶ 120
/// repository (oxygenhub#252), their versions, and the backend block.
16ee5d19 Andy McCall ↶ 121
pub async fn terraform(
122
    State(app): State<Shared>,
123
    viewer: Viewer,
124
    Path((owner, name)): Path<(String, String)>,
125
) -> Result<Page<TerraformPage>, Error> {
126
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
127
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
67523e06 Andy McCall ↶ 128
    terraform_page(&app, shell, row, role, None, None).await
129
}
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.
138
pub async fn download(
139
    State(app): State<Shared>,
140
    viewer: Viewer,
141
    Path((owner, name, state)): Path<(String, String, String)>,
142
    Query(q): Query<DownloadQuery>,
143
) -> Result<Response, Error> {
144
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
145
    let (_repo, row, _role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
16ee5d19 Andy McCall ↶ 146
    let e = |e| Error::from_db(shell.clone(), e);
67523e06 Andy McCall ↶ 147
    let (body, serial) = match q.version {
148
        Some(id) => db::terraform::version_body(&app.db, row.id, &state, id).await.map_err(e)?,
149
        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
    }
151
    .ok_or_else(|| Error::not_found(shell.clone()))?;
152
    let plain = app.secret_key.open(&body).ok_or_else(|| Error::not_found(shell.clone()))?;
153
    let file = format!("{}-serial-{serial}.tfstate", state.replace(|c: char| !(c.is_ascii_alphanumeric() || "-_.".contains(c)), "_"));
154
    Ok((
155
        [
156
            (header::CONTENT_TYPE, "application/json".to_string()),
157
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{file}\"")),
158
            (header::CACHE_CONTROL, "no-store".to_string()),
159
        ],
160
        plain,
161
    )
162
        .into_response())
163
}
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.
174
pub async fn restore(
175
    State(app): State<Shared>,
176
    viewer: Viewer,
177
    Path((owner, name, state)): Path<(String, String, String)>,
178
    Form(f): Form<RestoreForm>,
179
) -> Result<Page<TerraformPage>, Error> {
180
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
181
    let me = require(&viewer, &shell)?;
182
    let (_repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
183
    csrf(&viewer, &f.csrf_token, &shell)?;
184
    match crate::terraform::restore(&app, row.id, &state, f.version, &me.account.username).await {
185
        Ok(serial) => {
186
            db::accounts::audit(
187
                &app.db,
188
                Some(&me.account),
189
                "terraform.restored",
190
                &format!("{owner}/{name}:{state}"),
191
                &format!("version {}, now serial {serial}", f.version),
192
            )
193
            .await
194
            .ok();
195
            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
            );
198
            terraform_page(&app, shell, row, role, Some(msg), None).await
199
        }
200
        Err(e) => terraform_page(&app, shell, row, role, None, Some(format!("Could not restore {state}: {}.", e.message()))).await,
16ee5d19 Andy McCall ↶ 201
    }
202
}