crates/cli/ents-web/src/pages/toolchains.rs
toolchains.rshistorycomment on this file
| 1 | //! `GET /toolchains`, `GET /toolchains/{name}`: a custom (not generic) |
| 2 | //! page family, per this crate's own top-level doc -- a toolchain's |
| 3 | //! [`ents_kiln::Recipe`] needs domain-specific rendering (`Embedded` vs |
| 4 | //! `Downloaded`, each with its own provenance shape) that would otherwise |
| 5 | //! push a `match Recipe::Embedded { .. } => ...` into the generic |
| 6 | //! reflection walk [`crate::render`] exists to keep type-agnostic. |
| 7 | //! Directory import stays a `git ents toolchain import` operation (it |
| 8 | //! takes a local directory path, not form data a browser can supply); |
| 9 | //! what `POST /toolchains` wires instead is [`toolchain::register`], |
| 10 | //! taking a recipe as text ([`ents_kiln::Recipe::parse`]'s own format) |
| 11 | //! -- an `embedded <tree-oid>` line or a `downloaded` component list is |
| 12 | //! exactly form data. |
| 13 | |
| 14 | use std::sync::Arc; |
| 15 | |
| 16 | use axum::Form; |
| 17 | use axum::extract::{Path, State}; |
| 18 | use axum::response::{IntoResponse, Redirect}; |
| 19 | use ents_kiln::toolchain; |
| 20 | use gix_object::{Find, Write}; |
| 21 | use maud::html; |
| 22 | use serde::Deserialize; |
| 23 | |
| 24 | use crate::error::{Error, Result}; |
| 25 | use crate::session::Session; |
| 26 | use crate::state::AppState; |
| 27 | |
| 28 | /// `GET /toolchains`. |
| 29 | /// |
| 30 | /// Every name resolves its own recipe (`toolchain::view`) so a name whose |
| 31 | /// stored tree does not match this build's [`ents_kiln::Toolchain`]/ |
| 32 | /// [`ents_kiln::Recipe`] shape (written by an older schema) surfaces in |
| 33 | /// the same [`crate::render::unreadable_disclosure`] every other entity |
| 34 | /// family's list page renders, while its name stays linked in the listing |
| 35 | /// (its show page renders the unreadable marker card) -- hand-rolled here |
| 36 | /// since [`toolchain::list`] itself only enumerates ref names, with no |
| 37 | /// reflected entity for [`crate::render`]'s generic machinery to walk |
| 38 | /// (this page family's own top-level doc). |
| 39 | /// |
| 40 | /// # Errors |
| 41 | /// |
| 42 | /// Propagates a ref-store read failure. |
| 43 | pub async fn list<O>( |
| 44 | State(state): State<Arc<AppState<O>>>, |
| 45 | axum::Extension(session): axum::Extension<Session>, |
| 46 | ) -> Result<maud::Markup> |
| 47 | where |
| 48 | O: Find + Write + Send + 'static, |
| 49 | { |
| 50 | let names = toolchain::list(state.refs.as_ref())?; |
| 51 | let mut failures = Vec::new(); |
| 52 | for name in &names { |
| 53 | if let Err(error) = toolchain::view(state.refs.as_ref(), &*state.objects(), name) { |
| 54 | failures.push((format!("refs/meta/toolchains/{name}"), error.to_string())); |
| 55 | } |
| 56 | } |
| 57 | let listing = if names.is_empty() { |
| 58 | super::blankslate( |
| 59 | "No toolchains yet", |
| 60 | html! { "Import one with " code { "git ents toolchain import" } "." }, |
| 61 | ) |
| 62 | } else { |
| 63 | crate::render::string_list(&names, |name| format!("/toolchains/{name}")) |
| 64 | }; |
| 65 | Ok(super::layout_meta( |
| 66 | &super::RepoHeader::from_state(&state), |
| 67 | &super::identity_label(&state), |
| 68 | "/toolchains", |
| 69 | "Toolchains", |
| 70 | html! { |
| 71 | (crate::render::unreadable_disclosure(&failures)) |
| 72 | (listing) |
| 73 | div.card { |
| 74 | div.card-header { "Import a toolchain" } |
| 75 | (import_form(&session)) |
| 76 | } |
| 77 | }, |
| 78 | )) |
| 79 | } |
| 80 | |
| 81 | /// The import-toolchain form (`POST /toolchains`): a name and a recipe in |
| 82 | /// [`ents_kiln::Recipe::parse`]'s own text format. |
| 83 | fn import_form(session: &Session) -> maud::Markup { |
| 84 | html! { |
| 85 | form method="post" action="/toolchains" { |
| 86 | (super::csrf_input(session)) |
| 87 | label { "Name" input type="text" name="name"; } |
| 88 | label { |
| 89 | "Recipe" |
| 90 | textarea name="recipe" |
| 91 | placeholder="embedded <tree-oid>\nor:\ndownloaded\n<url> <sha256> <strip> [dest]" {} |
| 92 | } |
| 93 | button type="submit" { "Import Toolchain" } |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | /// The form fields `POST /toolchains` accepts. |
| 99 | #[derive(Debug, Deserialize)] |
| 100 | pub struct ImportForm { |
| 101 | /// Name to record the toolchain under (`refs/meta/toolchains/<name>`). |
| 102 | name: String, |
| 103 | /// The recipe text ([`ents_kiln::Recipe::parse`]). |
| 104 | recipe: String, |
| 105 | /// The per-session CSRF token (`roots.web-session`). |
| 106 | csrf: String, |
| 107 | } |
| 108 | |
| 109 | /// `POST /toolchains`: record a toolchain from a recipe given as text |
| 110 | /// ([`toolchain::register`]) as a signed mutation on |
| 111 | /// `refs/meta/toolchains/<name>` -- the recipe-flow counterpart of |
| 112 | /// `git ents toolchain import`, whose directory walk cannot arrive as |
| 113 | /// form data (this module's own top-level doc). |
| 114 | /// |
| 115 | /// # Errors |
| 116 | /// |
| 117 | /// [`crate::Error::BadCsrf`] if `form.csrf` does not match; |
| 118 | /// [`Error::InvalidArgument`] on a recipe that does not parse or a name |
| 119 | /// that cannot form a ref; otherwise propagates the `receive` proposal's |
| 120 | /// own failures. |
| 121 | // @relation(roots.web-signing, roots.web-session, scope=function) |
| 122 | pub async fn register<O>( |
| 123 | State(state): State<Arc<AppState<O>>>, |
| 124 | axum::Extension(session): axum::Extension<Session>, |
| 125 | Form(form): Form<ImportForm>, |
| 126 | ) -> Result<impl IntoResponse> |
| 127 | where |
| 128 | O: Find + Write + Send + 'static, |
| 129 | { |
| 130 | super::require_csrf(&session, &form.csrf)?; |
| 131 | let recipe = ents_kiln::Recipe::parse(&form.recipe) |
| 132 | .map_err(|source| Error::InvalidArgument(format!("invalid recipe: {source}")))?; |
| 133 | let name = form.name.trim(); |
| 134 | let identity = state.identity.as_ref(); |
| 135 | let outcome = toolchain::register( |
| 136 | state.refs.as_ref(), |
| 137 | &*state.objects(), |
| 138 | state.events.as_ref(), |
| 139 | name, |
| 140 | &recipe, |
| 141 | &crate::receive_identity!(identity, crate::pages::member_author(&session)), |
| 142 | state.mode, |
| 143 | ) |
| 144 | .map_err(|source| match source { |
| 145 | ents_effect::Error::InvalidToolchainName(bad) => { |
| 146 | Error::InvalidArgument(format!("invalid toolchain name: {bad}")) |
| 147 | } |
| 148 | other => Error::from(other), |
| 149 | })?; |
| 150 | crate::error::outcome_to_result(outcome)?; |
| 151 | Ok(Redirect::to(&format!("/toolchains/{name}"))) |
| 152 | } |
| 153 | |
| 154 | /// `GET /toolchains/{name}`: the toolchain's recorded recipe and import |
| 155 | /// log. |
| 156 | /// |
| 157 | /// # Errors |
| 158 | /// |
| 159 | /// [`Error::NotFound`] if `name` has no toolchain ref at all |
| 160 | /// ([`ents_effect::Error::UnknownToolchain`]) -- a toolchain ref that |
| 161 | /// exists but whose stored tree does not match this build's |
| 162 | /// [`ents_kiln::Toolchain`]/[`ents_kiln::Recipe`] shape degrades to |
| 163 | /// [`crate::render::unreadable`] instead (`roots.web-agnostic`'s |
| 164 | /// graceful-degradation stance). The import log is best-effort once the |
| 165 | /// recipe itself reads back: a log entry this build cannot decode renders |
| 166 | /// as an empty log rather than failing the whole page, since the recipe is |
| 167 | /// this page's primary content. |
| 168 | pub async fn show<O>( |
| 169 | State(state): State<Arc<AppState<O>>>, |
| 170 | Path(name): Path<String>, |
| 171 | ) -> Result<maud::Markup> |
| 172 | where |
| 173 | O: Find + Write + Send + 'static, |
| 174 | { |
| 175 | // One `state.objects()` lock, reused for both `view` and `log`: a |
| 176 | // `match` scrutinee's own temporaries live for the whole match (arms |
| 177 | // included), so a second `state.objects()` inside the `Ok` arm below |
| 178 | // would try to lock this non-reentrant `Mutex` while the scrutinee's |
| 179 | // own guard is still held, self-deadlocking forever rather than |
| 180 | // erroring (see `crate::pages::members::read_all`'s identical |
| 181 | // rationale). |
| 182 | let objects = state.objects(); |
| 183 | let body = match toolchain::view(state.refs.as_ref(), &*objects, &name) { |
| 184 | Ok((toolchain, recipe)) => { |
| 185 | let log = toolchain::log(state.refs.as_ref(), &*objects, &name).unwrap_or_default(); |
| 186 | html! { |
| 187 | div.card { |
| 188 | dl.entity-view { |
| 189 | dt { "name" } dd { (toolchain.name) } |
| 190 | dt { "recipe" } dd { (format!("{recipe:?}")) } |
| 191 | } |
| 192 | } |
| 193 | h2 { "Import Log" } |
| 194 | @if log.is_empty() { |
| 195 | (super::blankslate( |
| 196 | "No import log", |
| 197 | html! { "This toolchain has no recorded import history." }, |
| 198 | )) |
| 199 | } @else { |
| 200 | div.card { |
| 201 | ul.string-list { |
| 202 | @for oid in &log { |
| 203 | li { |
| 204 | code { (super::short_oid(oid)) } |
| 205 | // Best-effort per entry: an unreadable |
| 206 | // commit in the chain (practically |
| 207 | // unreachable -- `toolchain::log` itself |
| 208 | // already errors on one) drops just its |
| 209 | // own authorship, not the whole log. |
| 210 | @if let Ok((author, seconds)) = super::commit_authorship(&*objects, *oid) { |
| 211 | span.muted { " · " (author) " · " (super::ago(seconds)) } |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 | Err(ents_effect::Error::UnknownToolchain(_)) => { |
| 221 | return Err(Error::NotFound { |
| 222 | what: format!("toolchain {name}"), |
| 223 | }); |
| 224 | } |
| 225 | Err(error) => crate::render::unreadable(&error.to_string()), |
| 226 | }; |
| 227 | Ok(super::layout_meta( |
| 228 | &super::RepoHeader::from_state(&state), |
| 229 | &super::identity_label(&state), |
| 230 | "/toolchains", |
| 231 | &name, |
| 232 | html! { |
| 233 | (super::child_crumbs("toolchains", "/toolchains", &name)) |
| 234 | (body) |
| 235 | }, |
| 236 | )) |
| 237 | } |