//! A repository's Artifacts menu (oxygenhub#254): every run's build //! artifacts in one list, the runner images its jobs use, and its //! Terraform state. Releases and reports keep their own pages. use super::settings::{csrf, require}; use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Viewer}; use crate::db::flow as fdb; use crate::{db, Shared}; use askama::Template; use axum::extract::{Form, Path, Query, State}; use axum::http::header; use axum::response::{IntoResponse, Response}; use serde::Deserialize; /// At most this many artifacts are listed, newest first. const ARTIFACTS_SHOWN: i64 = 200; /// Images used within this many days are listed. const IMAGE_DAYS: i64 = 90; #[derive(Template)] #[template(path = "repo_artifacts.html")] pub struct ArtifactsPage { shell: Shell, repo: RepoShell, artifacts: Vec, limit: i64, } /// `GET /{owner}/{name}/artifacts`. pub async fn artifacts( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/artifacts"), &viewer); let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let artifacts = fdb::repo_artifacts(&app.db, row.id, ARTIFACTS_SHOWN).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Page(ArtifactsPage { shell, repo: RepoShell::new(&app, &row, "artifacts").with_role(role), artifacts, limit: ARTIFACTS_SHOWN })) } /// One image this repository's jobs ran on. pub struct ImageView { /// The image's BLAKE3, or what the pipeline named when it is not a hash. pub image: String, pub hashed: bool, pub jobs: i64, pub last_ms: i64, /// Kept by the hub: its size and architecture. pub kept: Option<(i64, String)>, } #[derive(Template)] #[template(path = "repo_images.html")] pub struct ImagesPage { shell: Shell, repo: RepoShell, images: Vec, days: i64, } /// `GET /{owner}/{name}/images`. pub async fn images( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/images"), &viewer); let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let e = |e| Error::from_db(shell.clone(), e); let uses = crate::images::uses_of(&app.db, row.id, db::now_ms() - IMAGE_DAYS * 24 * 3600 * 1000).await.map_err(e)?; let kept = crate::images::kept(&app.db).await.map_err(e)?; let images = uses .into_iter() .map(|u| { let kept = kept.iter().find(|k| k.hash.eq_ignore_ascii_case(&u.image)).map(|k| (k.size, k.arch.clone())); ImageView { hashed: jet::images::is_hash(&u.image), image: u.image, jobs: u.jobs, last_ms: u.last_ms, kept } }) .collect(); Ok(Page(ImagesPage { shell, repo: RepoShell::new(&app, &row, "images").with_role(role), images, days: IMAGE_DAYS })) } /// A state with its kept versions, newest first. pub struct StateView { pub s: db::terraform::TfState, pub versions: Vec, } #[derive(Template)] #[template(path = "repo_terraform.html")] pub struct TerraformPage { shell: Shell, repo: RepoShell, states: Vec, notice: Option, error: Option, } async fn terraform_page( app: &Shared, shell: Shell, row: db::repos::RepoRow, role: crate::auth::Role, notice: Option, error: Option, ) -> Result, Error> { let e = |e| Error::from_db(shell.clone(), e); let mut states = Vec::new(); // States hold secrets (#256): only writers see them listed. if crate::auth::roles::allows(Some(role), Access::Write) { for s in db::terraform::of_repo(&app.db, row.id).await.map_err(e)? { let versions = db::terraform::version_list(&app.db, row.id, &s.name).await.map_err(e)?; states.push(StateView { s, versions }); } } Ok(Page(TerraformPage { shell, repo: RepoShell::new(app, &row, "terraform").with_role(role), states, notice, error })) } /// `GET /{owner}/{name}/terraform`: the states the hub keeps for this /// repository (oxygenhub#252), their versions, and the backend block. pub async fn terraform( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer); let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; terraform_page(&app, shell, row, role, None, None).await } #[derive(Deserialize)] pub struct DownloadQuery { version: Option, } /// `GET /{owner}/{name}/terraform/{state}/download[?version=ID]`: the /// current state, or a kept version, for people with the write role. pub async fn download( State(app): State, viewer: Viewer, Path((owner, name, state)): Path<(String, String, String)>, Query(q): Query, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer); let (_repo, row, _role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let e = |e| Error::from_db(shell.clone(), e); let (body, serial) = match q.version { Some(id) => db::terraform::version_body(&app.db, row.id, &state, id).await.map_err(e)?, 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)), } .ok_or_else(|| Error::not_found(shell.clone()))?; let plain = app.secret_key.open(&body).ok_or_else(|| Error::not_found(shell.clone()))?; let file = format!("{}-serial-{serial}.tfstate", state.replace(|c: char| !(c.is_ascii_alphanumeric() || "-_.".contains(c)), "_")); Ok(( [ (header::CONTENT_TYPE, "application/json".to_string()), (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{file}\"")), (header::CACHE_CONTROL, "no-store".to_string()), ], plain, ) .into_response()) } #[derive(Deserialize)] pub struct RestoreForm { #[serde(rename = "_csrf")] csrf_token: String, version: i64, } /// `POST /{owner}/{name}/terraform/{state}/restore` (#255): restore a kept /// version, by someone with the maintain role, audited. pub async fn restore( State(app): State, viewer: Viewer, Path((owner, name, state)): Path<(String, String, String)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer); let me = require(&viewer, &shell)?; let (_repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; match crate::terraform::restore(&app, row.id, &state, f.version, &me.account.username).await { Ok(serial) => { db::accounts::audit( &app.db, Some(&me.account), "terraform.restored", &format!("{owner}/{name}:{state}"), &format!("version {}, now serial {serial}", f.version), ) .await .ok(); let msg = format!( "Restored {state}: it is now serial {serial}. The state it replaced is kept as a version, so you can go back to it." ); terraform_page(&app, shell, row, role, Some(msg), None).await } Err(e) => terraform_page(&app, shell, row, role, None, Some(format!("Could not restore {state}: {}.", e.message()))).await, } }