collin/anvil · c4d49193
feat: CI artifacts, issue tracker, push mirroring, push-to-create, repo-page polish
Collin Richards · 2026-06-10 09:17 UTC · c4d49193453402efb7865868251425cfc374ae4f · parent 3447f00b · browse files
modifiedCargo.lock+4 −0
| ⋯ 125 unchanged lines | |||
| 126 | 126 | "anvil-core", | |
| 127 | 127 | "anvil-git", | |
| 128 | 128 | "bollard", | |
| 129 | + | "flate2", | |
| 129 | 130 | "futures-util", | |
| 130 | 131 | "reqwest", | |
| 131 | 132 | "serde_json", | |
| 132 | 133 | "tar", | |
| 134 | + | "tempfile", | |
| 133 | 135 | "tokio", | |
| 134 | 136 | "tracing", | |
| 135 | 137 | ] | |
| ⋯ 20 unchanged lines | |||
| 156 | 158 | "argon2 0.5.3", | |
| 157 | 159 | "gix", | |
| 158 | 160 | "hmac 0.12.1", | |
| 161 | + | "rusqlite", | |
| 159 | 162 | "serde", | |
| 160 | 163 | "serde_yaml", | |
| 161 | 164 | "sha2 0.10.9", | |
| ⋯ 45 unchanged lines | |||
| 207 | 210 | "maud", | |
| 208 | 211 | "pulldown-cmark", | |
| 209 | 212 | "serde", | |
| 213 | + | "serde_json", | |
| 210 | 214 | "similar", | |
| 211 | 215 | "syntect", | |
| 212 | 216 | "time", | |
| ⋯ 5055 unchanged lines | |||
modifiedCargo.toml+4 −0
| ⋯ 34 unchanged lines | |||
| 35 | 35 | gix-pack = { version = "0.71", features = ["sha1"] } | |
| 36 | 36 | maud = { version = "0.27", features = ["axum"] } | |
| 37 | 37 | pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } | |
| 38 | + | flate2 = "1" | |
| 38 | 39 | rand = "0.10" | |
| 39 | 40 | # HTTP client for the CD deploy webhook. No TLS feature on purpose: the deploy | |
| 40 | 41 | # receiver is host-local plaintext HTTP, and enabling rustls would drag in | |
| 41 | 42 | # aws-lc-rs and break the musl cross-compile (see the russh note below). | |
| 42 | 43 | reqwest = { version = "0.12", default-features = false, features = ["json"] } | |
| 44 | + | # Used directly only for idempotent schema shims on existing databases; the | |
| 45 | + | # version tracks what toasty-driver-sqlite already pulls in. | |
| 46 | + | rusqlite = "0.39" | |
| 43 | 47 | serde = { version = "1", features = ["derive"] } | |
| 44 | 48 | serde_json = "1" | |
| 45 | 49 | serde_yaml = "0.9" | |
| ⋯ 25 unchanged lines | |||
modifiedDockerfile+3 −1
| ⋯ 7 unchanged lines | |||
| 8 | 8 | ||
| 9 | 9 | FROM debian:bookworm-slim | |
| 10 | 10 | ||
| 11 | + | # git is needed at runtime only for push mirroring (`git push --mirror`); | |
| 12 | + | # everything else speaks gix in-process. | |
| 11 | 13 | RUN apt-get update \ | |
| 12 | - | && apt-get install -y --no-install-recommends ca-certificates \ | |
| 14 | + | && apt-get install -y --no-install-recommends ca-certificates git \ | |
| 13 | 15 | && rm -rf /var/lib/apt/lists/* \ | |
| 14 | 16 | && useradd --system --user-group --home-dir /data anvil \ | |
| 15 | 17 | && mkdir -p /data /etc/anvil \ | |
| ⋯ 12 unchanged lines | |||
modifiedanvil.example.toml+7 −0
| ⋯ 38 unchanged lines | |||
| 39 | 39 | # User inside the job container, e.g. "1000:1000". Empty keeps the image default. | |
| 40 | 40 | run_as = "" | |
| 41 | 41 | ||
| 42 | + | # Artifact caps (MiB, 0 = unlimited): one artifact / one run's total / the | |
| 43 | + | # rolling per-repo budget. Over the repo budget, the oldest commits' artifacts | |
| 44 | + | # are deleted after each run (branch tips pinned). See docs/ci-artifacts.md. | |
| 45 | + | artifact_max_mb = 256 | |
| 46 | + | artifact_run_max_mb = 512 | |
| 47 | + | artifact_quota_mb = 4096 | |
| 48 | + | ||
| 42 | 49 | # Continuous deployment: on a green run of deploy_branch in the ONE repo named | |
| 43 | 50 | # by deploy_repo, POST to deploy_webhook with the X-Anvil-Deploy-Secret header. | |
| 44 | 51 | # Empty deploy_repo/deploy_webhook disables deploys entirely. | |
| ⋯ 4 unchanged lines | |||
modifiedcrates/anvil-ci/Cargo.toml+4 −0
| ⋯ 10 unchanged lines | |||
| 11 | 11 | anvil-core.workspace = true | |
| 12 | 12 | anvil-git.workspace = true | |
| 13 | 13 | bollard.workspace = true | |
| 14 | + | flate2.workspace = true | |
| 14 | 15 | futures-util.workspace = true | |
| 15 | 16 | reqwest.workspace = true | |
| 16 | 17 | serde_json.workspace = true | |
| 17 | 18 | tar.workspace = true | |
| 18 | 19 | tokio.workspace = true | |
| 19 | 20 | tracing.workspace = true | |
| 21 | + | ||
| 22 | + | [dev-dependencies] | |
| 23 | + | tempfile = "3" | |
modifiedcrates/anvil-ci/src/lib.rs+534 −38
| ⋯ 13 unchanged lines | |||
| 14 | 14 | //! pids/memory/cpu caps plus a wall-clock timeout and an optional image | |
| 15 | 15 | //! allowlist ([`anvil_core::config::CiConfig`]). | |
| 16 | 16 | ||
| 17 | - | use anvil_core::ci::{ | |
| 18 | - | self, | |
| 19 | - | Pipeline, | |
| 17 | + | use std::{ | |
| 18 | + | collections::BTreeMap, | |
| 19 | + | io::Read, | |
| 20 | + | path::Path, | |
| 20 | 21 | }; | |
| 21 | - | use anvil_core::config::CiConfig; | |
| 22 | + | ||
| 22 | 23 | use anvil_core::{ | |
| 23 | 24 | App, | |
| 25 | + | ci::{ | |
| 26 | + | self, | |
| 27 | + | ArtifactSpec, | |
| 28 | + | Pipeline, | |
| 29 | + | }, | |
| 30 | + | config::CiConfig, | |
| 24 | 31 | repos, | |
| 25 | 32 | storage, | |
| 26 | 33 | users, | |
| ⋯ 2 unchanged lines | |||
| 29 | 36 | self, | |
| 30 | 37 | TreeFile, | |
| 31 | 38 | }; | |
| 32 | - | use bollard::Docker; | |
| 33 | - | use bollard::container::{ | |
| 34 | - | Config, | |
| 35 | - | CreateContainerOptions, | |
| 36 | - | LogsOptions, | |
| 37 | - | RemoveContainerOptions, | |
| 38 | - | StartContainerOptions, | |
| 39 | - | UploadToContainerOptions, | |
| 40 | - | WaitContainerOptions, | |
| 39 | + | use bollard::{ | |
| 40 | + | Docker, | |
| 41 | + | container::{ | |
| 42 | + | Config, | |
| 43 | + | CreateContainerOptions, | |
| 44 | + | DownloadFromContainerOptions, | |
| 45 | + | LogsOptions, | |
| 46 | + | RemoveContainerOptions, | |
| 47 | + | StartContainerOptions, | |
| 48 | + | UploadToContainerOptions, | |
| 49 | + | WaitContainerOptions, | |
| 50 | + | }, | |
| 51 | + | image::CreateImageOptions, | |
| 52 | + | models::HostConfig, | |
| 41 | 53 | }; | |
| 42 | - | use bollard::image::CreateImageOptions; | |
| 43 | - | use bollard::models::HostConfig; | |
| 44 | 54 | use futures_util::StreamExt; | |
| 45 | 55 | use tokio::sync::mpsc::UnboundedReceiver; | |
| 46 | 56 | ||
| 47 | 57 | const WORKDIR: &str = "/workspace"; | |
| 48 | 58 | ||
| 59 | + | /// In-container directory where meta-extractor outputs land, one file per | |
| 60 | + | /// `<artifact>/<key>`. Downloaded as a tar after the run; file-per-value | |
| 61 | + | /// sidesteps quoting/JSON-escaping in shell entirely. | |
| 62 | + | const META_DIR: &str = "/tmp/anvil-meta"; | |
| 63 | + | ||
| 64 | + | /// Cap on the meta-extractor tar (the values are short strings). | |
| 65 | + | const META_TAR_CAP: u64 = 1024 * 1024; | |
| 66 | + | ||
| 67 | + | /// Per-value cap on extractor output, in bytes (after trimming). | |
| 68 | + | const META_VALUE_CAP: usize = 1024; | |
| 69 | + | ||
| 49 | 70 | /// Run the CI worker loop: recover interrupted runs, drain the queue, then | |
| 50 | 71 | /// process run ids as they arrive on `rx`. Runs one job at a time. | |
| 51 | 72 | pub async fn run_worker(app: App, mut rx: UnboundedReceiver<i64>) { | |
| ⋯ 59 unchanged lines | |||
| 111 | 132 | owner.username, repo.name, run.ref_name, pipeline.image | |
| 112 | 133 | ); | |
| 113 | 134 | ||
| 114 | - | let status = match execute(&pipeline, tar, &mut log, &app.config.ci).await { | |
| 115 | - | Ok(0) => ci::status::SUCCESS, | |
| 116 | - | Ok(code) => { | |
| 117 | - | log.push_str(&format!("\n[exited with status {code}]\n")); | |
| 118 | - | ci::status::FAILURE | |
| 135 | + | // Artifacts are collected into a scratch directory next to their final | |
| 136 | + | // home (same filesystem, so the swap below is a rename), then moved into | |
| 137 | + | // place only after the run finishes. | |
| 138 | + | let artifacts_root = app.config.artifacts_dir(); | |
| 139 | + | let scratch = artifacts_root | |
| 140 | + | .join(run.repo_id.to_string()) | |
| 141 | + | .join(format!(".collecting-{run_id}")); | |
| 142 | + | ||
| 143 | + | let (status, collected) = | |
| 144 | + | match execute(&pipeline, tar, &mut log, &app.config.ci, &scratch).await { | |
| 145 | + | Ok((0, collected)) => (ci::status::SUCCESS, collected), | |
| 146 | + | Ok((code, collected)) => { | |
| 147 | + | log.push_str(&format!("\n[exited with status {code}]\n")); | |
| 148 | + | (ci::status::FAILURE, collected) | |
| 149 | + | } | |
| 150 | + | Err(e) => { | |
| 151 | + | log.push_str(&format!("\n[runner error] {e}\n")); | |
| 152 | + | (ci::status::ERROR, Vec::new()) | |
| 153 | + | } | |
| 154 | + | }; | |
| 155 | + | ||
| 156 | + | // Swap the collected set into place, replacing any earlier run's | |
| 157 | + | // artifacts for this commit, then record the rows. | |
| 158 | + | if !collected.is_empty() { | |
| 159 | + | let final_dir = storage::artifact_commit_dir(&artifacts_root, run.repo_id, &run.commit); | |
| 160 | + | let swap = async { | |
| 161 | + | ci::delete_artifacts_for_commit(&app.db, run.repo_id, &run.commit) | |
| 162 | + | .await | |
| 163 | + | .map_err(|e| e.to_string())?; | |
| 164 | + | if final_dir.exists() { | |
| 165 | + | std::fs::remove_dir_all(&final_dir).map_err(|e| e.to_string())?; | |
| 166 | + | } | |
| 167 | + | std::fs::rename(&scratch, &final_dir).map_err(|e| e.to_string())?; | |
| 168 | + | for c in &collected { | |
| 169 | + | ci::add_artifact( | |
| 170 | + | &app.db, | |
| 171 | + | run_id, | |
| 172 | + | run.repo_id, | |
| 173 | + | &run.commit, | |
| 174 | + | &c.name, | |
| 175 | + | c.size, | |
| 176 | + | c.is_dir, | |
| 177 | + | c.browse, | |
| 178 | + | &c.meta, | |
| 179 | + | ) | |
| 180 | + | .await | |
| 181 | + | .map_err(|e| e.to_string())?; | |
| 182 | + | } | |
| 183 | + | Ok::<_, String>(()) | |
| 184 | + | }; | |
| 185 | + | match swap.await { | |
| 186 | + | Ok(()) => { | |
| 187 | + | log.push_str(&format!("\n[collected {} artifact(s)]\n", collected.len())); | |
| 188 | + | gc_artifacts(app, run.repo_id, &repo_path, &run.commit, &mut log).await; | |
| 189 | + | } | |
| 190 | + | Err(e) => log.push_str(&format!("\n[storing artifacts failed: {e}]\n")), | |
| 119 | 191 | } | |
| 120 | - | Err(e) => { | |
| 121 | - | log.push_str(&format!("\n[runner error] {e}\n")); | |
| 122 | - | ci::status::ERROR | |
| 123 | - | } | |
| 124 | - | }; | |
| 192 | + | } | |
| 193 | + | let _ = std::fs::remove_dir_all(&scratch); // no-op when renamed away | |
| 125 | 194 | ||
| 126 | 195 | ci::append_log(&app.db, run_id, &log).await.ok(); | |
| 127 | 196 | ci::finish(&app.db, run_id, status).await.ok(); | |
| ⋯ 13 unchanged lines | |||
| 141 | 210 | Ok(()) | |
| 142 | 211 | } | |
| 143 | 212 | ||
| 213 | + | /// Enforce `[ci] artifact_quota_mb` for one repository: while over budget, | |
| 214 | + | /// delete the oldest commit's artifacts (rows + directory). Branch-tip | |
| 215 | + | /// commits and the just-stored commit are pinned. Deterministic — runs after | |
| 216 | + | /// every artifact-producing run, no background sweeper. Best-effort: failures | |
| 217 | + | /// are logged, never failing the run. | |
| 218 | + | async fn gc_artifacts( | |
| 219 | + | app: &App, | |
| 220 | + | repo_id: i64, | |
| 221 | + | repo_path: &Path, | |
| 222 | + | keep_commit: &str, | |
| 223 | + | log: &mut String, | |
| 224 | + | ) { | |
| 225 | + | let quota = match app.config.ci.artifact_quota_mb { | |
| 226 | + | 0 => return, | |
| 227 | + | mb => mb_cap(mb), | |
| 228 | + | }; | |
| 229 | + | let rows = match ci::artifacts_for_repo(&app.db, repo_id).await { | |
| 230 | + | Ok(rows) => rows, | |
| 231 | + | Err(e) => { | |
| 232 | + | log.push_str(&format!("\n[artifact gc: listing failed: {e}]\n")); | |
| 233 | + | return; | |
| 234 | + | } | |
| 235 | + | }; | |
| 236 | + | ||
| 237 | + | // Per-commit totals and ages. | |
| 238 | + | let mut commits: BTreeMap<String, (i64, i64)> = BTreeMap::new(); // commit → (oldest created_at, bytes) | |
| 239 | + | let mut total: u64 = 0; | |
| 240 | + | for row in &rows { | |
| 241 | + | let entry = commits | |
| 242 | + | .entry(row.commit.clone()) | |
| 243 | + | .or_insert((row.created_at, 0)); | |
| 244 | + | entry.0 = entry.0.min(row.created_at); | |
| 245 | + | entry.1 += row.size; | |
| 246 | + | total = total.saturating_add(row.size.max(0) as u64); | |
| 247 | + | } | |
| 248 | + | if total <= quota { | |
| 249 | + | return; | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | let pinned = browse::branch_tips(repo_path).unwrap_or_default(); | |
| 253 | + | let mut victims: Vec<(i64, String, i64)> = commits | |
| 254 | + | .into_iter() | |
| 255 | + | .filter(|(commit, _)| commit != keep_commit && !pinned.contains(commit)) | |
| 256 | + | .map(|(commit, (oldest, bytes))| (oldest, commit, bytes)) | |
| 257 | + | .collect(); | |
| 258 | + | victims.sort(); | |
| 259 | + | ||
| 260 | + | for (_, commit, bytes) in victims { | |
| 261 | + | if total <= quota { | |
| 262 | + | break; | |
| 263 | + | } | |
| 264 | + | let dir = storage::artifact_commit_dir(&app.config.artifacts_dir(), repo_id, &commit); | |
| 265 | + | if let Err(e) = std::fs::remove_dir_all(&dir) { | |
| 266 | + | log.push_str(&format!("\n[artifact gc: removing {commit}: {e}]\n")); | |
| 267 | + | continue; // keep the rows; retried next run | |
| 268 | + | } | |
| 269 | + | if let Err(e) = ci::delete_artifacts_for_commit(&app.db, repo_id, &commit).await { | |
| 270 | + | log.push_str(&format!("\n[artifact gc: forgetting {commit}: {e}]\n")); | |
| 271 | + | continue; | |
| 272 | + | } | |
| 273 | + | total = total.saturating_sub(bytes.max(0) as u64); | |
| 274 | + | log.push_str(&format!( | |
| 275 | + | "\n[artifact gc: dropped {} for old commit {}]\n", | |
| 276 | + | fmt_mb(bytes), | |
| 277 | + | &commit[..commit.len().min(12)] | |
| 278 | + | )); | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | /// Bytes as a short MiB string for log lines. | |
| 283 | + | fn fmt_mb(bytes: i64) -> String { | |
| 284 | + | format!("{:.1} MiB", bytes.max(0) as f64 / (1024.0 * 1024.0)) | |
| 285 | + | } | |
| 286 | + | ||
| 144 | 287 | /// POST the configured deploy webhook. Best-effort: logs success/failure but | |
| 145 | 288 | /// never fails the run (CI already passed). | |
| 146 | 289 | async fn deploy(app: &App, owner: &str, name: &str, run: &anvil_core::CiRun) { | |
| ⋯ 23 unchanged lines | |||
| 170 | 313 | } | |
| 171 | 314 | } | |
| 172 | 315 | ||
| 316 | + | /// One artifact collected from the job container, already written under the | |
| 317 | + | /// scratch directory; `process` swaps it into the commit's directory. | |
| 318 | + | struct Collected { | |
| 319 | + | name: String, | |
| 320 | + | size: i64, | |
| 321 | + | is_dir: bool, | |
| 322 | + | browse: bool, | |
| 323 | + | /// JSON object of extractor key → output. | |
| 324 | + | meta: String, | |
| 325 | + | } | |
| 326 | + | ||
| 173 | 327 | /// Execute the pipeline in a sandboxed container, streaming output into `log`. | |
| 174 | - | /// Returns the container's exit code. | |
| 328 | + | /// Returns the container's exit code and any artifacts collected into | |
| 329 | + | /// `scratch` (empty on timeout — the container is already gone). | |
| 175 | 330 | /// | |
| 176 | 331 | /// The job container never sees the Docker socket and gets no mounts of any | |
| 177 | - | /// kind (the checkout is *uploaded*, not bind-mounted). All capabilities are | |
| 178 | - | /// dropped and `no-new-privileges` is set unconditionally; pids/memory/cpu | |
| 179 | - | /// caps, the wall-clock timeout, network access, the container user, and the | |
| 180 | - | /// image allowlist come from `cfg`. | |
| 332 | + | /// kind (the checkout is *uploaded*, not bind-mounted; artifacts are | |
| 333 | + | /// *downloaded* out the same way). All capabilities are dropped and | |
| 334 | + | /// `no-new-privileges` is set unconditionally; pids/memory/cpu caps, the | |
| 335 | + | /// wall-clock timeout, network access, the container user, and the image | |
| 336 | + | /// allowlist come from `cfg`. | |
| 181 | 337 | async fn execute( | |
| 182 | 338 | pipeline: &Pipeline, | |
| 183 | 339 | tar: Vec<u8>, | |
| 184 | 340 | log: &mut String, | |
| 185 | 341 | cfg: &CiConfig, | |
| 186 | - | ) -> Result<i64, String> { | |
| 342 | + | scratch: &Path, | |
| 343 | + | ) -> Result<(i64, Vec<Collected>), String> { | |
| 187 | 344 | if !cfg.image_allowed(&pipeline.image) { | |
| 188 | 345 | return Err(format!( | |
| 189 | 346 | "image {} is not permitted by ci.allowed_images", | |
| ⋯ 21 unchanged lines | |||
| 211 | 368 | item.map_err(|e| format!("pull {}: {e}", pipeline.image))?; | |
| 212 | 369 | } | |
| 213 | 370 | ||
| 214 | - | // Build a single `set -e` script from the steps. | |
| 215 | - | let mut script = String::from("set -e\n"); | |
| 371 | + | // Build a single `set -e` script from the steps. The steps run in a | |
| 372 | + | // subshell so the meta-extractor trailer still runs (and the original | |
| 373 | + | // exit code is preserved) when a step fails — failure artifacts like test | |
| 374 | + | // reports are the ones that matter most. | |
| 375 | + | let mut script = String::from("(\nset -e\n"); | |
| 216 | 376 | for step in &pipeline.steps { | |
| 217 | 377 | script.push_str("printf '\\n=== %s ===\\n' "); | |
| 218 | 378 | script.push_str(&single_quote(step.label())); | |
| ⋯ 1 unchanged line | |||
| 220 | 380 | script.push_str(&step.run); | |
| 221 | 381 | script.push('\n'); | |
| 222 | 382 | } | |
| 383 | + | script.push_str(")\nanvil_rc=$?\n"); | |
| 384 | + | for a in &pipeline.artifacts { | |
| 385 | + | if a.meta.is_empty() { | |
| 386 | + | continue; | |
| 387 | + | } | |
| 388 | + | // Names and keys are parse-time validated to [A-Za-z0-9._-]+, so they | |
| 389 | + | // interpolate into the script safely. | |
| 390 | + | script.push_str(&format!("mkdir -p {META_DIR}/{}\n", a.name)); | |
| 391 | + | for (key, cmd) in &a.meta { | |
| 392 | + | script.push_str(&format!( | |
| 393 | + | "{{\n{cmd}\n}} > {META_DIR}/{}/{key} 2>/dev/null || :\n", | |
| 394 | + | a.name | |
| 395 | + | )); | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | script.push_str("exit $anvil_rc\n"); | |
| 223 | 399 | ||
| 224 | 400 | // The sandbox. Limits of 0 mean "unlimited" and omit the corresponding cap. | |
| 225 | 401 | let host_config = HostConfig { | |
| ⋯ 80 unchanged lines | |||
| 306 | 482 | .unwrap_or_else(|_| Err(format!("job exceeded ci.timeout_secs ({secs}s); killed"))), | |
| 307 | 483 | }; | |
| 308 | 484 | ||
| 485 | + | // Artifacts come out of the (now stopped) container before it is removed. | |
| 486 | + | let collected = match &result { | |
| 487 | + | Ok(_) if !pipeline.artifacts.is_empty() => { | |
| 488 | + | collect_artifacts(&docker, &id, pipeline, cfg, scratch, log).await | |
| 489 | + | } | |
| 490 | + | _ => Vec::new(), | |
| 491 | + | }; | |
| 492 | + | ||
| 309 | 493 | let _ = docker | |
| 310 | 494 | .remove_container( | |
| 311 | 495 | &id, | |
| ⋯ 4 unchanged lines | |||
| 316 | 500 | ) | |
| 317 | 501 | .await; | |
| 318 | 502 | ||
| 319 | - | result | |
| 503 | + | result.map(|code| (code, collected)) | |
| 320 | 504 | } | |
| 321 | 505 | ||
| 322 | - | /// Build an uncompressed tar of the checkout, rooted at `workspace/` so it | |
| 323 | - | /// extracts to `/workspace` when uploaded to the container root. | |
| 324 | - | fn build_tar(files: &[TreeFile]) -> Vec<u8> { | |
| 325 | - | let mut builder = tar::Builder::new(Vec::new()); | |
| 506 | + | /// Collect the pipeline's declared artifacts from the stopped container into | |
| 507 | + | /// `scratch`. Failures are per-artifact: each is logged and skipped, never | |
| 508 | + | /// failing the run. | |
| 509 | + | async fn collect_artifacts( | |
| 510 | + | docker: &Docker, | |
| 511 | + | id: &str, | |
| 512 | + | pipeline: &Pipeline, | |
| 513 | + | cfg: &CiConfig, | |
| 514 | + | scratch: &Path, | |
| 515 | + | log: &mut String, | |
| 516 | + | ) -> Vec<Collected> { | |
| 517 | + | if let Err(e) = std::fs::create_dir_all(scratch) { | |
| 518 | + | log.push_str(&format!( | |
| 519 | + | "\n[artifacts: creating scratch dir failed: {e}]\n" | |
| 520 | + | )); | |
| 521 | + | return Vec::new(); | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | // Extractor outputs first: artifact name → key → value. | |
| 525 | + | let mut metas: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new(); | |
| 526 | + | if pipeline.artifacts.iter().any(|a| !a.meta.is_empty()) { | |
| 527 | + | match download_tar(docker, id, META_DIR, META_TAR_CAP).await { | |
| 528 | + | Ok(Some(bytes)) => metas = parse_meta_tar(&bytes), | |
| 529 | + | Ok(None) => log.push_str("\n[artifacts: extractor output exceeded its cap]\n"), | |
| 530 | + | Err(e) => log.push_str(&format!("\n[artifacts: reading extractor output: {e}]\n")), | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | let per_artifact_cap = mb_cap(cfg.artifact_max_mb); | |
| 535 | + | let mut run_budget = mb_cap(cfg.artifact_run_max_mb); | |
| 536 | + | let mut collected = Vec::new(); | |
| 537 | + | for spec in &pipeline.artifacts { | |
| 538 | + | let cap = per_artifact_cap.min(run_budget); | |
| 539 | + | let note = |log: &mut String, what: &str| { | |
| 540 | + | log.push_str(&format!("\n[artifact {}: {what}]\n", spec.name)); | |
| 541 | + | }; | |
| 542 | + | let bytes = match download_tar(docker, id, &format!("{WORKDIR}/{}", spec.path), cap).await { | |
| 543 | + | Ok(Some(bytes)) => bytes, | |
| 544 | + | Ok(None) => { | |
| 545 | + | note(log, "exceeds the size cap; skipped"); | |
| 546 | + | continue; | |
| 547 | + | } | |
| 548 | + | Err(e) => { | |
| 549 | + | note(log, &format!("download failed ({e}); skipped")); | |
| 550 | + | continue; | |
| 551 | + | } | |
| 552 | + | }; | |
| 553 | + | match store_artifact(spec, &bytes, scratch) { | |
| 554 | + | Ok((size, is_dir)) => { | |
| 555 | + | run_budget = run_budget.saturating_sub(size as u64); | |
| 556 | + | let meta = metas.get(&spec.name).cloned().unwrap_or_default(); | |
| 557 | + | collected.push(Collected { | |
| 558 | + | name: spec.name.clone(), | |
| 559 | + | size, | |
| 560 | + | is_dir, | |
| 561 | + | browse: spec.browse, | |
| 562 | + | meta: serde_json::to_string(&meta).unwrap_or_else(|_| "{}".into()), | |
| 563 | + | }); | |
| 564 | + | } | |
| 565 | + | Err(e) => note(log, &format!("storing failed ({e}); skipped")), | |
| 566 | + | } | |
| 567 | + | } | |
| 568 | + | collected | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | /// `0` (unlimited) → `u64::MAX`, otherwise MiB → bytes. | |
| 572 | + | fn mb_cap(mb: i64) -> u64 { | |
| 573 | + | if mb <= 0 { | |
| 574 | + | u64::MAX | |
| 575 | + | } else { | |
| 576 | + | mb as u64 * 1024 * 1024 | |
| 577 | + | } | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | /// Download `path` from the container as a tar, buffering at most `cap` bytes | |
| 581 | + | /// (`Ok(None)` when exceeded). | |
| 582 | + | async fn download_tar( | |
| 583 | + | docker: &Docker, | |
| 584 | + | id: &str, | |
| 585 | + | path: &str, | |
| 586 | + | cap: u64, | |
| 587 | + | ) -> Result<Option<Vec<u8>>, String> { | |
| 588 | + | let mut stream = docker.download_from_container( | |
| 589 | + | id, | |
| 590 | + | Some(DownloadFromContainerOptions { | |
| 591 | + | path: path.to_string(), | |
| 592 | + | }), | |
| 593 | + | ); | |
| 594 | + | let mut buf = Vec::new(); | |
| 595 | + | while let Some(chunk) = stream.next().await { | |
| 596 | + | let chunk = chunk.map_err(|e| e.to_string())?; | |
| 597 | + | if (buf.len() + chunk.len()) as u64 > cap { | |
| 598 | + | return Ok(None); | |
| 599 | + | } | |
| 600 | + | buf.extend_from_slice(&chunk); | |
| 601 | + | } | |
| 602 | + | Ok(Some(buf)) | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | /// Parse the extractor-output tar (`anvil-meta/<artifact>/<key>` files) into | |
| 606 | + | /// artifact → key → trimmed value. | |
| 607 | + | fn parse_meta_tar(bytes: &[u8]) -> BTreeMap<String, BTreeMap<String, String>> { | |
| 608 | + | let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new(); | |
| 609 | + | let mut archive = tar::Archive::new(bytes); | |
| 610 | + | let Ok(entries) = archive.entries() else { | |
| 611 | + | return out; | |
| 612 | + | }; | |
| 613 | + | for entry in entries.flatten() { | |
| 614 | + | if !entry.header().entry_type().is_file() { | |
| 615 | + | continue; | |
| 616 | + | } | |
| 617 | + | let Ok(path) = entry.path() else { continue }; | |
| 618 | + | // anvil-meta/<artifact>/<key> | |
| 619 | + | let parts: Vec<String> = path | |
| 620 | + | .components() | |
| 621 | + | .skip(1) | |
| 622 | + | .map(|c| c.as_os_str().to_string_lossy().into_owned()) | |
| 623 | + | .collect(); | |
| 624 | + | let [artifact, key] = parts.as_slice() else { | |
| 625 | + | continue; | |
| 626 | + | }; | |
| 627 | + | let (artifact, key) = (artifact.clone(), key.clone()); | |
| 628 | + | let mut value = String::new(); | |
| 629 | + | let _ = entry.take(META_VALUE_CAP as u64).read_to_string(&mut value); | |
| 630 | + | let value = value.trim().to_string(); | |
| 631 | + | if !value.is_empty() { | |
| 632 | + | out.entry(artifact).or_default().insert(key, value); | |
| 633 | + | } | |
| 634 | + | } | |
| 635 | + | out | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | /// Write one downloaded artifact tar into `scratch`, returning (size, is_dir). | |
| 639 | + | /// | |
| 640 | + | /// - a file is stored as-is at `scratch/<name>` | |
| 641 | + | /// - a directory with `browse` is extracted under `scratch/<name>/` | |
| 642 | + | /// - any other directory is stored compressed at `scratch/<name>.tar.gz` | |
| 643 | + | fn store_artifact( | |
| 644 | + | spec: &ArtifactSpec, | |
| 645 | + | tar_bytes: &[u8], | |
| 646 | + | scratch: &Path, | |
| 647 | + | ) -> Result<(i64, bool), String> { | |
| 648 | + | // The docker archive endpoint roots entries at the requested item's | |
| 649 | + | // basename; its first entry tells file from directory. | |
| 650 | + | let mut archive = tar::Archive::new(tar_bytes); | |
| 651 | + | let mut entries = archive.entries().map_err(|e| e.to_string())?; | |
| 652 | + | let first = entries | |
| 653 | + | .next() | |
| 654 | + | .ok_or("empty archive")? | |
| 655 | + | .map_err(|e| e.to_string())?; | |
| 656 | + | let is_dir = first.header().entry_type().is_dir(); | |
| 657 | + | ||
| 658 | + | if !is_dir { | |
| 659 | + | let mut entry = first; | |
| 660 | + | let mut content = Vec::new(); | |
| 661 | + | entry.read_to_end(&mut content).map_err(|e| e.to_string())?; | |
| 662 | + | std::fs::write(scratch.join(&spec.name), &content).map_err(|e| e.to_string())?; | |
| 663 | + | return Ok((content.len() as i64, false)); | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | if !spec.browse { | |
| 667 | + | let file = std::fs::File::create(scratch.join(format!("{}.tar.gz", spec.name))) | |
| 668 | + | .map_err(|e| e.to_string())?; | |
| 669 | + | let mut enc = flate2::write::GzEncoder::new(file, flate2::Compression::default()); | |
| 670 | + | std::io::Write::write_all(&mut enc, tar_bytes).map_err(|e| e.to_string())?; | |
| 671 | + | let file = enc.finish().map_err(|e| e.to_string())?; | |
| 672 | + | let size = file.metadata().map_err(|e| e.to_string())?.len(); | |
| 673 | + | return Ok((size as i64, true)); | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | // Browsable: extract regular files under scratch/<name>/, stripping the | |
| 677 | + | // basename prefix. Entry paths come from docker's tar of a real | |
| 678 | + | // filesystem, but stay defensive: relative components only, no links. | |
| 679 | + | let root = scratch.join(&spec.name); | |
| 680 | + | let mut total = 0i64; | |
| 681 | + | let mut archive = tar::Archive::new(tar_bytes); | |
| 682 | + | for entry in archive.entries().map_err(|e| e.to_string())?.flatten() { | |
| 683 | + | if !entry.header().entry_type().is_file() { | |
| 684 | + | continue; | |
| 685 | + | } | |
| 686 | + | let Ok(path) = entry.path() else { continue }; | |
| 687 | + | let mut rel = std::path::PathBuf::new(); | |
| 688 | + | let mut ok = true; | |
| 689 | + | for c in path.components().skip(1) { | |
| 690 | + | match c { | |
| 691 | + | std::path::Component::Normal(p) => rel.push(p), | |
| 692 | + | _ => { | |
| 693 | + | ok = false; | |
| 694 | + | break; | |
| 695 | + | } | |
| 696 | + | } | |
| 697 | + | } | |
| 698 | + | if !ok || rel.as_os_str().is_empty() { | |
| 699 | + | continue; | |
| 700 | + | } | |
| 701 | + | let dest = root.join(&rel); | |
| 702 | + | if let Some(parent) = dest.parent() { | |
| 703 | + | std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; | |
| 704 | + | } | |
| 705 | + | let mut entry = entry; | |
| 706 | + | let mut content = Vec::new(); | |
| 707 | + | entry.read_to_end(&mut content).map_err(|e| e.to_string())?; | |
| 708 | + | std::fs::write(&dest, &content).map_err(|e| e.to_string())?; | |
| 709 | + | total += content.len() as i64; | |
| 710 | + | } | |
| 711 | + | let _ = std::fs::create_dir_all(&root); // empty dir artifact still exists | |
| 712 | + | Ok((total, true)) | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | /// Build an uncompressed tar of the checkout, rooted at `workspace/` so it | |
| 716 | + | /// extracts to `/workspace` when uploaded to the container root. | |
| 717 | + | fn build_tar(files: &[TreeFile]) -> Vec<u8> { | |
| 718 | + | let mut builder = tar::Builder::new(Vec::new()); | |
| 326 | 719 | for f in files { | |
| 327 | 720 | let mut header = tar::Header::new_gnu(); | |
| 328 | 721 | header.set_size(f.content.len() as u64); | |
| ⋯ 12 unchanged lines | |||
| 341 | 734 | fn single_quote(s: &str) -> String { | |
| 342 | 735 | format!("'{}'", s.replace('\'', "'\\''")) | |
| 343 | 736 | } | |
| 737 | + | ||
| 738 | + | #[cfg(test)] | |
| 739 | + | mod tests { | |
| 740 | + | use super::*; | |
| 741 | + | ||
| 742 | + | /// Build a tar the way docker's archive endpoint does: entries rooted at | |
| 743 | + | /// the requested item's basename. | |
| 744 | + | fn tar_of(entries: &[(&str, Option<&str>)]) -> Vec<u8> { | |
| 745 | + | let mut b = tar::Builder::new(Vec::new()); | |
| 746 | + | for (path, content) in entries { | |
| 747 | + | let mut h = tar::Header::new_gnu(); | |
| 748 | + | match content { | |
| 749 | + | Some(c) => { | |
| 750 | + | h.set_size(c.len() as u64); | |
| 751 | + | h.set_mode(0o644); | |
| 752 | + | h.set_entry_type(tar::EntryType::Regular); | |
| 753 | + | b.append_data(&mut h, path, c.as_bytes()).unwrap(); | |
| 754 | + | } | |
| 755 | + | None => { | |
| 756 | + | h.set_size(0); | |
| 757 | + | h.set_mode(0o755); | |
| 758 | + | h.set_entry_type(tar::EntryType::Directory); | |
| 759 | + | b.append_data(&mut h, path, std::io::empty()).unwrap(); | |
| 760 | + | } | |
| 761 | + | } | |
| 762 | + | } | |
| 763 | + | b.into_inner().unwrap() | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | fn spec(name: &str, path: &str, browse: bool) -> ArtifactSpec { | |
| 767 | + | ArtifactSpec { | |
| 768 | + | name: name.into(), | |
| 769 | + | path: path.into(), | |
| 770 | + | browse, | |
| 771 | + | meta: Default::default(), | |
| 772 | + | } | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | #[test] | |
| 776 | + | fn stores_a_file_artifact_as_is() { | |
| 777 | + | let dir = tempfile::tempdir().unwrap(); | |
| 778 | + | let tar = tar_of(&[("anvild", Some("ELF..."))]); | |
| 779 | + | let (size, is_dir) = store_artifact( | |
| 780 | + | &spec("bin", "target/release/anvild", false), | |
| 781 | + | &tar, | |
| 782 | + | dir.path(), | |
| 783 | + | ) | |
| 784 | + | .unwrap(); | |
| 785 | + | assert!(!is_dir); | |
| 786 | + | assert_eq!(size, 6); | |
| 787 | + | assert_eq!(std::fs::read(dir.path().join("bin")).unwrap(), b"ELF..."); | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | #[test] | |
| 791 | + | fn stores_a_directory_artifact_as_tar_gz() { | |
| 792 | + | let dir = tempfile::tempdir().unwrap(); | |
| 793 | + | let tar = tar_of(&[("doc", None), ("doc/index.html", Some("<html>"))]); | |
| 794 | + | let (size, is_dir) = | |
| 795 | + | store_artifact(&spec("doc", "target/doc", false), &tar, dir.path()).unwrap(); | |
| 796 | + | assert!(is_dir); | |
| 797 | + | let stored = dir.path().join("doc.tar.gz"); | |
| 798 | + | assert_eq!(size, stored.metadata().unwrap().len() as i64); | |
| 799 | + | // Round-trips through gzip back to the original tar bytes. | |
| 800 | + | let mut gz = flate2::read::GzDecoder::new(std::fs::File::open(&stored).unwrap()); | |
| 801 | + | let mut bytes = Vec::new(); | |
| 802 | + | gz.read_to_end(&mut bytes).unwrap(); | |
| 803 | + | assert_eq!(bytes, tar); | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | #[test] | |
| 807 | + | fn extracts_a_browsable_directory_artifact() { | |
| 808 | + | let dir = tempfile::tempdir().unwrap(); | |
| 809 | + | let tar = tar_of(&[ | |
| 810 | + | ("doc", None), | |
| 811 | + | ("doc/index.html", Some("<html>")), | |
| 812 | + | ("doc/sub", None), | |
| 813 | + | ("doc/sub/page.html", Some("<p>")), | |
| 814 | + | ]); | |
| 815 | + | let (size, is_dir) = | |
| 816 | + | store_artifact(&spec("doc", "target/doc", true), &tar, dir.path()).unwrap(); | |
| 817 | + | assert!(is_dir); | |
| 818 | + | assert_eq!(size, 6 + 3); | |
| 819 | + | let root = dir.path().join("doc"); | |
| 820 | + | assert_eq!(std::fs::read(root.join("index.html")).unwrap(), b"<html>"); | |
| 821 | + | assert_eq!(std::fs::read(root.join("sub/page.html")).unwrap(), b"<p>"); | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | #[test] | |
| 825 | + | fn parses_meta_tar_with_trimmed_capped_values() { | |
| 826 | + | let tar = tar_of(&[ | |
| 827 | + | ("anvil-meta", None), | |
| 828 | + | ("anvil-meta/bin", None), | |
| 829 | + | ("anvil-meta/bin/version", Some("anvild 0.0.0\n")), | |
| 830 | + | ("anvil-meta/bin/empty", Some(" \n")), | |
| 831 | + | ("anvil-meta/doc", None), | |
| 832 | + | ("anvil-meta/doc/pages", Some("42")), | |
| 833 | + | ]); | |
| 834 | + | let metas = parse_meta_tar(&tar); | |
| 835 | + | assert_eq!(metas["bin"]["version"], "anvild 0.0.0"); | |
| 836 | + | assert_eq!(metas["doc"]["pages"], "42"); | |
| 837 | + | assert!(!metas["bin"].contains_key("empty"), "blank values dropped"); | |
| 838 | + | } | |
| 839 | + | } | |
modifiedcrates/anvil-core/Cargo.toml+1 −0
| ⋯ 9 unchanged lines | |||
| 10 | 10 | [dependencies] | |
| 11 | 11 | gix.workspace = true | |
| 12 | 12 | toasty.workspace = true | |
| 13 | + | rusqlite.workspace = true | |
| 13 | 14 | argon2.workspace = true | |
| 14 | 15 | hmac.workspace = true | |
| 15 | 16 | sha2.workspace = true | |
| ⋯ 11 unchanged lines | |||
modifiedcrates/anvil-core/src/ci.rs+258 −6
| ⋯ 2 unchanged lines | |||
| 3 | 3 | ||
| 4 | 4 | use serde::Deserialize; | |
| 5 | 5 | ||
| 6 | - | use crate::error::{ | |
| 7 | - | Error, | |
| 8 | - | Result, | |
| 6 | + | use crate::{ | |
| 7 | + | error::{ | |
| 8 | + | Error, | |
| 9 | + | Result, | |
| 10 | + | }, | |
| 11 | + | models::{ | |
| 12 | + | CiArtifact, | |
| 13 | + | CiRun, | |
| 14 | + | }, | |
| 9 | 15 | }; | |
| 10 | - | use crate::models::CiRun; | |
| 11 | 16 | ||
| 12 | 17 | /// Run status values stored in [`CiRun::status`]. | |
| 13 | 18 | pub mod status { | |
| ⋯ 7 unchanged lines | |||
| 21 | 26 | /// Path of the pipeline definition within a repository. | |
| 22 | 27 | pub const PIPELINE_PATH: &str = ".anvil/ci.yml"; | |
| 23 | 28 | ||
| 24 | - | /// A parsed pipeline: a base image and ordered straight-line steps. | |
| 29 | + | /// A parsed pipeline: a base image, ordered straight-line steps, and the | |
| 30 | + | /// artifacts to collect afterwards. | |
| 25 | 31 | #[derive(Clone, Debug, Deserialize)] | |
| 26 | 32 | pub struct Pipeline { | |
| 27 | 33 | /// Docker image the steps run in, e.g. `rust:1.95-bookworm`. | |
| 28 | 34 | pub image: String, | |
| 29 | 35 | #[serde(default)] | |
| 30 | 36 | pub steps: Vec<Step>, | |
| 37 | + | #[serde(default)] | |
| 38 | + | pub artifacts: Vec<ArtifactSpec>, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// A declared artifact: a path in the workspace to collect after the steps | |
| 42 | + | /// run, plus optional metadata extractors (see `docs/ci-artifacts.md`). | |
| 43 | + | #[derive(Clone, Debug, Deserialize)] | |
| 44 | + | pub struct ArtifactSpec { | |
| 45 | + | /// Display/URL name; unique within the pipeline, `[A-Za-z0-9._-]+`. | |
| 46 | + | pub name: String, | |
| 47 | + | /// Path relative to the workspace root. A file downloads as-is; a | |
| 48 | + | /// directory downloads as a tarball — unless `browse` is set. | |
| 49 | + | pub path: String, | |
| 50 | + | /// Serve this (directory) artifact as a browsable static site instead of | |
| 51 | + | /// a download, e.g. rustdoc output. | |
| 52 | + | #[serde(default)] | |
| 53 | + | pub browse: bool, | |
| 54 | + | /// Metadata extractors: key → shell command, run *inside the job | |
| 55 | + | /// container* after the steps. Each command's stdout (trimmed, capped) | |
| 56 | + | /// becomes the value shown next to the artifact. | |
| 57 | + | #[serde(default)] | |
| 58 | + | pub meta: std::collections::BTreeMap<String, String>, | |
| 31 | 59 | } | |
| 32 | 60 | ||
| 33 | 61 | /// One pipeline step: a shell command, with an optional display name. | |
| ⋯ 17 unchanged lines | |||
| 51 | 79 | ||
| 52 | 80 | /// Parse a `.anvil/ci.yml` pipeline definition. | |
| 53 | 81 | pub fn parse_pipeline(yaml: &str) -> Result<Pipeline> { | |
| 54 | - | let pipeline: Pipeline = serde_yaml::from_str(yaml) | |
| 82 | + | let mut pipeline: Pipeline = serde_yaml::from_str(yaml) | |
| 55 | 83 | .map_err(|e| Error::Invalid(format!("invalid {PIPELINE_PATH}: {e}")))?; | |
| 56 | 84 | if pipeline.image.trim().is_empty() { | |
| 57 | 85 | return Err(Error::Invalid(format!( | |
| 58 | 86 | "{PIPELINE_PATH}: `image` is required" | |
| 59 | 87 | ))); | |
| 60 | 88 | } | |
| 89 | + | let mut seen = std::collections::BTreeSet::new(); | |
| 90 | + | for a in &mut pipeline.artifacts { | |
| 91 | + | let invalid = | |
| 92 | + | |what: &str| Error::Invalid(format!("{PIPELINE_PATH}: artifact `{}`: {what}", a.name)); | |
| 93 | + | if a.name.is_empty() | |
| 94 | + | || !a | |
| 95 | + | .name | |
| 96 | + | .chars() | |
| 97 | + | .all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c)) | |
| 98 | + | { | |
| 99 | + | return Err(invalid("name must be non-empty [A-Za-z0-9._-]+")); | |
| 100 | + | } | |
| 101 | + | if !seen.insert(a.name.clone()) { | |
| 102 | + | return Err(invalid("duplicate name")); | |
| 103 | + | } | |
| 104 | + | // Normalize away a trailing slash so a directory path and the same | |
| 105 | + | // path without the slash behave identically downstream. | |
| 106 | + | a.path = a.path.trim_end_matches('/').to_string(); | |
| 107 | + | if a.path.is_empty() | |
| 108 | + | || a.path.starts_with('/') | |
| 109 | + | || a.path.split('/').any(|seg| seg == ".." || seg.is_empty()) | |
| 110 | + | { | |
| 111 | + | return Err(invalid( | |
| 112 | + | "path must be relative to the workspace, without `..`", | |
| 113 | + | )); | |
| 114 | + | } | |
| 115 | + | // Meta keys become file names in the extractor handoff, so they get | |
| 116 | + | // the same charset as artifact names. | |
| 117 | + | for key in a.meta.keys() { | |
| 118 | + | if key.is_empty() | |
| 119 | + | || !key | |
| 120 | + | .chars() | |
| 121 | + | .all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c)) | |
| 122 | + | { | |
| 123 | + | return Err(invalid(&format!( | |
| 124 | + | "meta key `{key}` must be [A-Za-z0-9._-]+" | |
| 125 | + | ))); | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 61 | 129 | Ok(pipeline) | |
| 62 | 130 | } | |
| 63 | 131 | ||
| ⋯ 116 unchanged lines | |||
| 180 | 248 | Ok(runs.into_iter().map(|r| r.id).collect()) | |
| 181 | 249 | } | |
| 182 | 250 | ||
| 251 | + | /// Record a collected artifact. `meta` is a JSON object string (`{}` if none). | |
| 252 | + | #[allow(clippy::too_many_arguments)] | |
| 253 | + | pub async fn add_artifact( | |
| 254 | + | db: &toasty::Db, | |
| 255 | + | run_id: i64, | |
| 256 | + | repo_id: i64, | |
| 257 | + | commit: &str, | |
| 258 | + | name: &str, | |
| 259 | + | size: i64, | |
| 260 | + | is_dir: bool, | |
| 261 | + | browse: bool, | |
| 262 | + | meta: &str, | |
| 263 | + | ) -> Result<CiArtifact> { | |
| 264 | + | let mut conn = db.clone(); | |
| 265 | + | let artifact = toasty::create!(CiArtifact { | |
| 266 | + | run_id: run_id, | |
| 267 | + | repo_id: repo_id, | |
| 268 | + | commit: commit, | |
| 269 | + | name: name, | |
| 270 | + | size: size, | |
| 271 | + | is_dir: is_dir, | |
| 272 | + | browse: browse, | |
| 273 | + | meta: meta, | |
| 274 | + | created_at: crate::now(), | |
| 275 | + | }) | |
| 276 | + | .exec(&mut conn) | |
| 277 | + | .await?; | |
| 278 | + | Ok(artifact) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /// A run's artifacts, in declaration (insertion) order. | |
| 282 | + | pub async fn artifacts_for_run(db: &toasty::Db, run_id: i64) -> Result<Vec<CiArtifact>> { | |
| 283 | + | let mut conn = db.clone(); | |
| 284 | + | let artifacts = CiArtifact::filter(CiArtifact::fields().run_id().eq(run_id)) | |
| 285 | + | .order_by(CiArtifact::fields().id().asc()) | |
| 286 | + | .exec(&mut conn) | |
| 287 | + | .await?; | |
| 288 | + | Ok(artifacts) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// The newest artifact named `name` for `commit` (across that commit's runs). | |
| 292 | + | pub async fn latest_artifact( | |
| 293 | + | db: &toasty::Db, | |
| 294 | + | repo_id: i64, | |
| 295 | + | commit: &str, | |
| 296 | + | name: &str, | |
| 297 | + | ) -> Result<Option<CiArtifact>> { | |
| 298 | + | let mut conn = db.clone(); | |
| 299 | + | let artifact = CiArtifact::filter(CiArtifact::fields().repo_id().eq(repo_id)) | |
| 300 | + | .filter(CiArtifact::fields().commit().eq(commit)) | |
| 301 | + | .filter(CiArtifact::fields().name().eq(name)) | |
| 302 | + | .order_by(CiArtifact::fields().id().desc()) | |
| 303 | + | .first() | |
| 304 | + | .exec(&mut conn) | |
| 305 | + | .await?; | |
| 306 | + | Ok(artifact) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | /// All artifact rows for a repository (for GC accounting). Small at our scale. | |
| 310 | + | pub async fn artifacts_for_repo(db: &toasty::Db, repo_id: i64) -> Result<Vec<CiArtifact>> { | |
| 311 | + | let mut conn = db.clone(); | |
| 312 | + | let artifacts = CiArtifact::filter(CiArtifact::fields().repo_id().eq(repo_id)) | |
| 313 | + | .exec(&mut conn) | |
| 314 | + | .await?; | |
| 315 | + | Ok(artifacts) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | /// Delete the artifact rows for one commit (the on-disk directory is the | |
| 319 | + | /// caller's to remove). Used when re-running a commit and by GC. | |
| 320 | + | pub async fn delete_artifacts_for_commit( | |
| 321 | + | db: &toasty::Db, | |
| 322 | + | repo_id: i64, | |
| 323 | + | commit: &str, | |
| 324 | + | ) -> Result<()> { | |
| 325 | + | let mut conn = db.clone(); | |
| 326 | + | let rows = CiArtifact::filter(CiArtifact::fields().repo_id().eq(repo_id)) | |
| 327 | + | .filter(CiArtifact::fields().commit().eq(commit)) | |
| 328 | + | .exec(&mut conn) | |
| 329 | + | .await?; | |
| 330 | + | for row in rows { | |
| 331 | + | let mut conn = db.clone(); | |
| 332 | + | row.delete().exec(&mut conn).await?; | |
| 333 | + | } | |
| 334 | + | Ok(()) | |
| 335 | + | } | |
| 336 | + | ||
| 183 | 337 | #[cfg(test)] | |
| 184 | 338 | mod tests { | |
| 185 | 339 | use super::*; | |
| ⋯ 22 unchanged lines | |||
| 208 | 362 | assert!(parse_pipeline("steps: []\n").is_err()); | |
| 209 | 363 | } | |
| 210 | 364 | ||
| 365 | + | #[test] | |
| 366 | + | fn parses_and_validates_artifacts() { | |
| 367 | + | let p = parse_pipeline( | |
| 368 | + | r#"image: rust:1.95 | |
| 369 | + | artifacts: | |
| 370 | + | - name: anvild | |
| 371 | + | path: target/release/anvild | |
| 372 | + | meta: | |
| 373 | + | version: ./target/release/anvild --version | |
| 374 | + | - name: doc | |
| 375 | + | path: target/doc/ | |
| 376 | + | browse: true | |
| 377 | + | "#, | |
| 378 | + | ) | |
| 379 | + | .unwrap(); | |
| 380 | + | assert_eq!(p.artifacts.len(), 2); | |
| 381 | + | assert_eq!(p.artifacts[0].name, "anvild"); | |
| 382 | + | assert_eq!( | |
| 383 | + | p.artifacts[0].meta["version"], | |
| 384 | + | "./target/release/anvild --version" | |
| 385 | + | ); | |
| 386 | + | assert!(!p.artifacts[0].browse); | |
| 387 | + | assert_eq!(p.artifacts[1].path, "target/doc", "trailing slash trimmed"); | |
| 388 | + | assert!(p.artifacts[1].browse); | |
| 389 | + | ||
| 390 | + | let must_fail = |yaml: &str, why: &str| { | |
| 391 | + | assert!( | |
| 392 | + | parse_pipeline(&format!("image: i\n{yaml}")).is_err(), | |
| 393 | + | "{why}" | |
| 394 | + | ); | |
| 395 | + | }; | |
| 396 | + | must_fail( | |
| 397 | + | "artifacts: [{name: 'a b', path: x}]", | |
| 398 | + | "space in name rejected", | |
| 399 | + | ); | |
| 400 | + | must_fail( | |
| 401 | + | "artifacts: [{name: 'a/b', path: x}]", | |
| 402 | + | "slash in name rejected", | |
| 403 | + | ); | |
| 404 | + | must_fail("artifacts: [{name: '', path: x}]", "empty name rejected"); | |
| 405 | + | must_fail( | |
| 406 | + | "artifacts: [{name: a, path: x}, {name: a, path: y}]", | |
| 407 | + | "duplicate name rejected", | |
| 408 | + | ); | |
| 409 | + | must_fail( | |
| 410 | + | "artifacts: [{name: a, path: /etc}]", | |
| 411 | + | "absolute path rejected", | |
| 412 | + | ); | |
| 413 | + | must_fail( | |
| 414 | + | "artifacts: [{name: a, path: '../up'}]", | |
| 415 | + | "traversal rejected", | |
| 416 | + | ); | |
| 417 | + | must_fail( | |
| 418 | + | "artifacts: [{name: a, path: 'x//y'}]", | |
| 419 | + | "empty segment rejected", | |
| 420 | + | ); | |
| 421 | + | must_fail("artifacts: [{name: a, path: ''}]", "empty path rejected"); | |
| 422 | + | } | |
| 423 | + | ||
| 211 | 424 | // Exercises the run-lifecycle queries against a real SQLite database, to | |
| 212 | 425 | // confirm the ORM-level `order_by`/`limit`/filter actually work (Toasty is | |
| 213 | 426 | // pre-1.0, so we don't take that on faith). | |
| ⋯ 25 unchanged lines | |||
| 239 | 452 | assert_eq!(queued.len(), 4); | |
| 240 | 453 | assert!(queued.windows(2).all(|w| w[0] < w[1]), "ascending"); | |
| 241 | 454 | } | |
| 455 | + | ||
| 456 | + | #[tokio::test] | |
| 457 | + | async fn artifact_rows_round_trip() { | |
| 458 | + | let dir = tempfile::tempdir().unwrap(); | |
| 459 | + | let db = crate::db::connect(dir.path().join("t.db")).await.unwrap(); | |
| 460 | + | ||
| 461 | + | add_artifact(&db, 1, 7, "abc", "bin", 100, false, false, "{}") | |
| 462 | + | .await | |
| 463 | + | .unwrap(); | |
| 464 | + | add_artifact(&db, 1, 7, "abc", "doc", 5000, true, true, r#"{"v":"1"}"#) | |
| 465 | + | .await | |
| 466 | + | .unwrap(); | |
| 467 | + | // A newer run of the same commit supersedes for `latest_artifact`. | |
| 468 | + | add_artifact(&db, 2, 7, "abc", "bin", 200, false, false, "{}") | |
| 469 | + | .await | |
| 470 | + | .unwrap(); | |
| 471 | + | ||
| 472 | + | let run1 = artifacts_for_run(&db, 1).await.unwrap(); | |
| 473 | + | assert_eq!(run1.len(), 2); | |
| 474 | + | assert_eq!(run1[0].name, "bin"); | |
| 475 | + | assert!(run1[1].browse); | |
| 476 | + | ||
| 477 | + | let latest = latest_artifact(&db, 7, "abc", "bin") | |
| 478 | + | .await | |
| 479 | + | .unwrap() | |
| 480 | + | .unwrap(); | |
| 481 | + | assert_eq!(latest.run_id, 2); | |
| 482 | + | assert_eq!(latest.size, 200); | |
| 483 | + | assert!( | |
| 484 | + | latest_artifact(&db, 8, "abc", "bin") | |
| 485 | + | .await | |
| 486 | + | .unwrap() | |
| 487 | + | .is_none(), | |
| 488 | + | "scoped to repo" | |
| 489 | + | ); | |
| 490 | + | ||
| 491 | + | delete_artifacts_for_commit(&db, 7, "abc").await.unwrap(); | |
| 492 | + | assert!(artifacts_for_repo(&db, 7).await.unwrap().is_empty()); | |
| 493 | + | } | |
| 242 | 494 | } | |
modifiedcrates/anvil-core/src/config.rs+20 −0
| ⋯ 77 unchanged lines | |||
| 78 | 78 | /// to the image). Empty keeps the image's default user. Note many base | |
| 79 | 79 | /// images assume root for e.g. `apt-get`. | |
| 80 | 80 | pub run_as: String, | |
| 81 | + | /// Size cap for a single artifact, in MiB; larger artifacts are skipped | |
| 82 | + | /// (with a log note), never failing the run. `0` means unlimited. | |
| 83 | + | /// Defaults to 256. | |
| 84 | + | pub artifact_max_mb: i64, | |
| 85 | + | /// Combined size cap for one run's artifacts, in MiB. Artifacts that would | |
| 86 | + | /// push the run over it are skipped. `0` means unlimited. Defaults to 512. | |
| 87 | + | pub artifact_run_max_mb: i64, | |
| 88 | + | /// Combined artifact budget per repository, in MiB. After each run, oldest | |
| 89 | + | /// commits' artifacts are deleted until the repo fits (branch-head commits | |
| 90 | + | /// are pinned). `0` means unlimited. Defaults to 4096. | |
| 91 | + | pub artifact_quota_mb: i64, | |
| 81 | 92 | } | |
| 82 | 93 | ||
| 83 | 94 | #[derive(Clone, Debug, Deserialize, Serialize)] | |
| ⋯ 54 unchanged lines | |||
| 138 | 149 | timeout_secs: 1800, | |
| 139 | 150 | network: true, | |
| 140 | 151 | run_as: String::new(), | |
| 152 | + | artifact_max_mb: 256, | |
| 153 | + | artifact_run_max_mb: 512, | |
| 154 | + | artifact_quota_mb: 4096, | |
| 141 | 155 | } | |
| 142 | 156 | } | |
| 143 | 157 | } | |
| ⋯ 73 unchanged lines | |||
| 217 | 231 | self.data_dir.join("repositories") | |
| 218 | 232 | } | |
| 219 | 233 | ||
| 234 | + | /// Root directory under which CI artifacts are stored | |
| 235 | + | /// (`artifacts/{repo_id}/{commit}/…` — see `docs/ci-artifacts.md`). | |
| 236 | + | pub fn artifacts_dir(&self) -> PathBuf { | |
| 237 | + | self.data_dir.join("artifacts") | |
| 238 | + | } | |
| 239 | + | ||
| 220 | 240 | /// Whether session cookies should carry the `Secure` attribute (HTTPS-only). | |
| 221 | 241 | /// Derived from the public base URL's scheme, so local plaintext dev still | |
| 222 | 242 | /// works while production behind TLS gets `Secure` automatically. | |
| ⋯ 57 unchanged lines | |||
modifiedcrates/anvil-core/src/db.rs+232 −8
| ⋯ 1 unchanged line | |||
| 2 | 2 | ||
| 3 | 3 | use std::path::Path; | |
| 4 | 4 | ||
| 5 | - | use crate::error::Result; | |
| 6 | - | use crate::models::{ | |
| 7 | - | CiRun, | |
| 8 | - | Repository, | |
| 9 | - | Session, | |
| 10 | - | SshKey, | |
| 11 | - | User, | |
| 5 | + | use crate::{ | |
| 6 | + | error::Result, | |
| 7 | + | models::{ | |
| 8 | + | CiArtifact, | |
| 9 | + | CiRun, | |
| 10 | + | Issue, | |
| 11 | + | IssueComment, | |
| 12 | + | Repository, | |
| 13 | + | Session, | |
| 14 | + | SshKey, | |
| 15 | + | User, | |
| 16 | + | }, | |
| 12 | 17 | }; | |
| 13 | 18 | ||
| 14 | 19 | /// Open (creating if necessary) the SQLite database at `path`, creating the | |
| ⋯ 11 unchanged lines | |||
| 26 | 31 | let url = format!("sqlite:{}", path.display()); | |
| 27 | 32 | ||
| 28 | 33 | let db = toasty::Db::builder() | |
| 29 | - | .models(toasty::models!(User, Repository, SshKey, Session, CiRun)) | |
| 34 | + | .models(toasty::models!( | |
| 35 | + | User, | |
| 36 | + | Repository, | |
| 37 | + | SshKey, | |
| 38 | + | Session, | |
| 39 | + | CiRun, | |
| 40 | + | CiArtifact, | |
| 41 | + | Issue, | |
| 42 | + | IssueComment | |
| 43 | + | )) | |
| 30 | 44 | .connect(&url) | |
| 31 | 45 | .await?; | |
| 32 | 46 | ||
| 33 | 47 | if fresh { | |
| 34 | 48 | db.push_schema().await?; | |
| 49 | + | } else { | |
| 50 | + | migrate_existing(path)?; | |
| 35 | 51 | } | |
| 36 | 52 | Ok(db) | |
| 37 | 53 | } | |
| 54 | + | ||
| 55 | + | /// Tables added after a deployment's database was first created, as idempotent | |
| 56 | + | /// DDL applied to existing databases (Toasty's `push_schema` only runs on a | |
| 57 | + | /// fresh file; see above). Each statement must match what `push_schema` would | |
| 58 | + | /// generate for the model — `schema_shim_matches_push_schema` asserts that. | |
| 59 | + | const SCHEMA_SHIMS: &[&str] = &[ | |
| 60 | + | CI_ARTIFACTS_DDL, | |
| 61 | + | r#"CREATE INDEX IF NOT EXISTS "index_ci_artifacts_by_repo_id" ON "ci_artifacts" ("repo_id")"#, | |
| 62 | + | r#"CREATE INDEX IF NOT EXISTS "index_ci_artifacts_by_run_id" ON "ci_artifacts" ("run_id")"#, | |
| 63 | + | ISSUES_DDL, | |
| 64 | + | r#"CREATE INDEX IF NOT EXISTS "index_issues_by_repo_id" ON "issues" ("repo_id")"#, | |
| 65 | + | ISSUE_COMMENTS_DDL, | |
| 66 | + | r#"CREATE INDEX IF NOT EXISTS "index_issue_comments_by_issue_id" ON "issue_comments" ("issue_id")"#, | |
| 67 | + | ]; | |
| 68 | + | ||
| 69 | + | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( | |
| 70 | + | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 71 | + | "run_id" BIGINT NOT NULL, | |
| 72 | + | "repo_id" BIGINT NOT NULL, | |
| 73 | + | "commit" TEXT NOT NULL, | |
| 74 | + | "name" TEXT NOT NULL, | |
| 75 | + | "size" BIGINT NOT NULL, | |
| 76 | + | "is_dir" BOOLEAN NOT NULL, | |
| 77 | + | "browse" BOOLEAN NOT NULL, | |
| 78 | + | "meta" TEXT NOT NULL, | |
| 79 | + | "created_at" BIGINT NOT NULL )"#; | |
| 80 | + | ||
| 81 | + | const ISSUES_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "issues" ( | |
| 82 | + | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 83 | + | "repo_id" BIGINT NOT NULL, | |
| 84 | + | "number" BIGINT NOT NULL, | |
| 85 | + | "title" TEXT NOT NULL, | |
| 86 | + | "body" TEXT NOT NULL, | |
| 87 | + | "author_id" BIGINT NOT NULL, | |
| 88 | + | "state" TEXT NOT NULL, | |
| 89 | + | "created_at" BIGINT NOT NULL, | |
| 90 | + | "updated_at" BIGINT NOT NULL )"#; | |
| 91 | + | ||
| 92 | + | const ISSUE_COMMENTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "issue_comments" ( | |
| 93 | + | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 94 | + | "issue_id" BIGINT NOT NULL, | |
| 95 | + | "author_id" BIGINT NOT NULL, | |
| 96 | + | "body" TEXT NOT NULL, | |
| 97 | + | "created_at" BIGINT NOT NULL )"#; | |
| 98 | + | ||
| 99 | + | /// Columns added to existing tables after deployment, applied as | |
| 100 | + | /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS` | |
| 101 | + | /// for columns, so presence is checked via `pragma_table_info`). The model | |
| 102 | + | /// must declare the field *last* so fresh and migrated column orders agree. | |
| 103 | + | /// The `DEFAULT` backfills existing rows; Toasty's fresh DDL omits it, which | |
| 104 | + | /// is fine — inserts always provide every field. | |
| 105 | + | const COLUMN_SHIMS: &[(&str, &str, &str)] = &[( | |
| 106 | + | "repositories", | |
| 107 | + | "mirror_url", | |
| 108 | + | r#"ALTER TABLE "repositories" ADD COLUMN "mirror_url" TEXT NOT NULL DEFAULT ''"#, | |
| 109 | + | )]; | |
| 110 | + | ||
| 111 | + | /// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses | |
| 112 | + | /// rusqlite directly (already in-tree via Toasty's SQLite driver); every | |
| 113 | + | /// statement is a no-op once applied. | |
| 114 | + | fn migrate_existing(path: &Path) -> Result<()> { | |
| 115 | + | let migrate_err = |e: &dyn std::fmt::Display| crate::Error::Config(format!("schema shim: {e}")); | |
| 116 | + | let conn = rusqlite::Connection::open(path) | |
| 117 | + | .map_err(|e| crate::Error::Config(format!("opening {} to migrate: {e}", path.display())))?; | |
| 118 | + | for ddl in SCHEMA_SHIMS { | |
| 119 | + | conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?; | |
| 120 | + | } | |
| 121 | + | for (table, column, ddl) in COLUMN_SHIMS { | |
| 122 | + | let present: i64 = conn | |
| 123 | + | .query_row( | |
| 124 | + | "SELECT count(*) FROM pragma_table_info(?1) WHERE name = ?2", | |
| 125 | + | (table, column), | |
| 126 | + | |r| r.get(0), | |
| 127 | + | ) | |
| 128 | + | .map_err(|e| migrate_err(&e))?; | |
| 129 | + | if present == 0 { | |
| 130 | + | conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?; | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | Ok(()) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[cfg(test)] | |
| 137 | + | mod tests { | |
| 138 | + | use super::*; | |
| 139 | + | ||
| 140 | + | /// Tables created by shims (i.e. added after the first deployment). | |
| 141 | + | const SHIMMED_TABLES: &[&str] = &["ci_artifacts", "issues", "issue_comments"]; | |
| 142 | + | ||
| 143 | + | /// Every schema object (table + indexes) for `table`, normalized. | |
| 144 | + | fn schema_objects(path: &Path, table: &str) -> Vec<String> { | |
| 145 | + | let conn = rusqlite::Connection::open(path).unwrap(); | |
| 146 | + | let mut stmt = conn | |
| 147 | + | .prepare( | |
| 148 | + | "SELECT sql FROM sqlite_master WHERE tbl_name=?1 \ | |
| 149 | + | AND sql IS NOT NULL ORDER BY name", | |
| 150 | + | ) | |
| 151 | + | .unwrap(); | |
| 152 | + | stmt.query_map([table], |r| r.get::<_, String>(0)) | |
| 153 | + | .unwrap() | |
| 154 | + | .map(|s| { | |
| 155 | + | s.unwrap() | |
| 156 | + | .replace(" IF NOT EXISTS", "") | |
| 157 | + | .split_whitespace() | |
| 158 | + | .collect::<Vec<_>>() | |
| 159 | + | .join(" ") | |
| 160 | + | }) | |
| 161 | + | .collect() | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// The hand-written shim DDL must produce the same tables and indexes as | |
| 165 | + | /// a fresh `push_schema`, or fresh and migrated databases would diverge. | |
| 166 | + | #[tokio::test] | |
| 167 | + | async fn schema_shim_matches_push_schema() { | |
| 168 | + | let dir = tempfile::tempdir().unwrap(); | |
| 169 | + | ||
| 170 | + | let fresh = dir.path().join("fresh.db"); | |
| 171 | + | connect(&fresh).await.unwrap(); | |
| 172 | + | ||
| 173 | + | // A database from before the shimmed tables existed. | |
| 174 | + | let migrated = dir.path().join("migrated.db"); | |
| 175 | + | connect(&migrated).await.unwrap(); | |
| 176 | + | let conn = rusqlite::Connection::open(&migrated).unwrap(); | |
| 177 | + | for table in SHIMMED_TABLES { | |
| 178 | + | conn.execute_batch(&format!("DROP TABLE {table}")).unwrap(); | |
| 179 | + | } | |
| 180 | + | drop(conn); | |
| 181 | + | connect(&migrated).await.unwrap(); | |
| 182 | + | ||
| 183 | + | for table in SHIMMED_TABLES { | |
| 184 | + | assert_eq!( | |
| 185 | + | schema_objects(&fresh, table), | |
| 186 | + | schema_objects(&migrated, table), | |
| 187 | + | "schema diverges for {table}" | |
| 188 | + | ); | |
| 189 | + | assert!( | |
| 190 | + | !schema_objects(&fresh, table).is_empty(), | |
| 191 | + | "no schema objects for {table}" | |
| 192 | + | ); | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// Column name/type/notnull triples for a table. | |
| 197 | + | fn columns(path: &Path, table: &str) -> Vec<(String, String, bool)> { | |
| 198 | + | let conn = rusqlite::Connection::open(path).unwrap(); | |
| 199 | + | let mut stmt = conn | |
| 200 | + | .prepare("SELECT name, type, \"notnull\" FROM pragma_table_info(?1)") | |
| 201 | + | .unwrap(); | |
| 202 | + | stmt.query_map([table], |r| { | |
| 203 | + | Ok((r.get(0)?, r.get(1)?, r.get::<_, i64>(2)? != 0)) | |
| 204 | + | }) | |
| 205 | + | .unwrap() | |
| 206 | + | .map(|r| r.unwrap()) | |
| 207 | + | .collect() | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// A column shim must converge an old table to the fresh schema's columns | |
| 211 | + | /// (same names, order, types, nullability — the DDL text itself differs | |
| 212 | + | /// because of the backfill `DEFAULT`). | |
| 213 | + | #[tokio::test] | |
| 214 | + | async fn column_shim_matches_push_schema() { | |
| 215 | + | let dir = tempfile::tempdir().unwrap(); | |
| 216 | + | ||
| 217 | + | let fresh = dir.path().join("fresh.db"); | |
| 218 | + | connect(&fresh).await.unwrap(); | |
| 219 | + | ||
| 220 | + | let migrated = dir.path().join("migrated.db"); | |
| 221 | + | connect(&migrated).await.unwrap(); | |
| 222 | + | let conn = rusqlite::Connection::open(&migrated).unwrap(); | |
| 223 | + | conn.execute_batch(r#"ALTER TABLE "repositories" DROP COLUMN "mirror_url""#) | |
| 224 | + | .unwrap(); | |
| 225 | + | drop(conn); | |
| 226 | + | connect(&migrated).await.unwrap(); | |
| 227 | + | connect(&migrated).await.unwrap(); // idempotent | |
| 228 | + | ||
| 229 | + | assert_eq!( | |
| 230 | + | columns(&fresh, "repositories"), | |
| 231 | + | columns(&migrated, "repositories") | |
| 232 | + | ); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// Reconnecting to an existing database that predates a table must create | |
| 236 | + | /// it (and reconnecting again must be a no-op). | |
| 237 | + | #[tokio::test] | |
| 238 | + | async fn migrate_existing_adds_missing_tables() { | |
| 239 | + | let dir = tempfile::tempdir().unwrap(); | |
| 240 | + | let path = dir.path().join("old.db"); | |
| 241 | + | connect(&path).await.unwrap(); | |
| 242 | + | ||
| 243 | + | // Simulate a database from before the table existed. | |
| 244 | + | let conn = rusqlite::Connection::open(&path).unwrap(); | |
| 245 | + | conn.execute_batch("DROP TABLE ci_artifacts").unwrap(); | |
| 246 | + | drop(conn); | |
| 247 | + | ||
| 248 | + | connect(&path).await.unwrap(); | |
| 249 | + | connect(&path).await.unwrap(); // idempotent | |
| 250 | + | ||
| 251 | + | let conn = rusqlite::Connection::open(&path).unwrap(); | |
| 252 | + | let n: i64 = conn | |
| 253 | + | .query_row( | |
| 254 | + | "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ci_artifacts'", | |
| 255 | + | [], | |
| 256 | + | |r| r.get(0), | |
| 257 | + | ) | |
| 258 | + | .unwrap(); | |
| 259 | + | assert_eq!(n, 1); | |
| 260 | + | } | |
| 261 | + | } | |
addedcrates/anvil-core/src/issues.rs+181 −0
| 1 | + | //! Per-repository issues: creation, listing, comments, and open/close state. | |
| 2 | + | //! | |
| 3 | + | //! Issues are numbered per repository (`#1`, `#2`, …) in creation order. | |
| 4 | + | //! Numbering is assigned as max+1 at creation, which cannot race with a | |
| 5 | + | //! single server process (our deployment shape). | |
| 6 | + | ||
| 7 | + | use crate::{ | |
| 8 | + | error::{ | |
| 9 | + | Error, | |
| 10 | + | Result, | |
| 11 | + | }, | |
| 12 | + | models::{ | |
| 13 | + | Issue, | |
| 14 | + | IssueComment, | |
| 15 | + | }, | |
| 16 | + | }; | |
| 17 | + | ||
| 18 | + | /// Issue state values stored in [`Issue::state`]. | |
| 19 | + | pub mod state { | |
| 20 | + | pub const OPEN: &str = "open"; | |
| 21 | + | pub const CLOSED: &str = "closed"; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// Create an issue, assigning the repo's next number. | |
| 25 | + | pub async fn create( | |
| 26 | + | db: &toasty::Db, | |
| 27 | + | repo_id: i64, | |
| 28 | + | author_id: i64, | |
| 29 | + | title: &str, | |
| 30 | + | body: &str, | |
| 31 | + | ) -> Result<Issue> { | |
| 32 | + | let title = title.trim(); | |
| 33 | + | if title.is_empty() { | |
| 34 | + | return Err(Error::Invalid("issue title must not be empty".into())); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | let mut conn = db.clone(); | |
| 38 | + | let last = Issue::filter(Issue::fields().repo_id().eq(repo_id)) | |
| 39 | + | .order_by(Issue::fields().number().desc()) | |
| 40 | + | .first() | |
| 41 | + | .exec(&mut conn) | |
| 42 | + | .await?; | |
| 43 | + | let number = last.map(|i| i.number).unwrap_or(0) + 1; | |
| 44 | + | ||
| 45 | + | let now = crate::now(); | |
| 46 | + | let mut conn = db.clone(); | |
| 47 | + | let issue = toasty::create!(Issue { | |
| 48 | + | repo_id: repo_id, | |
| 49 | + | number: number, | |
| 50 | + | title: title, | |
| 51 | + | body: body.trim(), | |
| 52 | + | author_id: author_id, | |
| 53 | + | state: state::OPEN, | |
| 54 | + | created_at: now, | |
| 55 | + | updated_at: now, | |
| 56 | + | }) | |
| 57 | + | .exec(&mut conn) | |
| 58 | + | .await?; | |
| 59 | + | Ok(issue) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Find one issue by its per-repo number. | |
| 63 | + | pub async fn find(db: &toasty::Db, repo_id: i64, number: i64) -> Result<Option<Issue>> { | |
| 64 | + | let mut conn = db.clone(); | |
| 65 | + | let issue = Issue::filter(Issue::fields().repo_id().eq(repo_id)) | |
| 66 | + | .filter(Issue::fields().number().eq(number)) | |
| 67 | + | .first() | |
| 68 | + | .exec(&mut conn) | |
| 69 | + | .await?; | |
| 70 | + | Ok(issue) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// List a repository's issues in one state, most recently active first. | |
| 74 | + | pub async fn list(db: &toasty::Db, repo_id: i64, state: &str) -> Result<Vec<Issue>> { | |
| 75 | + | let mut conn = db.clone(); | |
| 76 | + | let issues = Issue::filter(Issue::fields().repo_id().eq(repo_id)) | |
| 77 | + | .filter(Issue::fields().state().eq(state)) | |
| 78 | + | .order_by(Issue::fields().updated_at().desc()) | |
| 79 | + | .exec(&mut conn) | |
| 80 | + | .await?; | |
| 81 | + | Ok(issues) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Open/closed counts for the list page tabs. | |
| 85 | + | pub async fn counts(db: &toasty::Db, repo_id: i64) -> Result<(usize, usize)> { | |
| 86 | + | // Two filtered fetches; issue counts stay small at our scale. | |
| 87 | + | let open = list(db, repo_id, state::OPEN).await?.len(); | |
| 88 | + | let closed = list(db, repo_id, state::CLOSED).await?.len(); | |
| 89 | + | Ok((open, closed)) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// Set an issue's state (`open`/`closed`) and bump its activity time. | |
| 93 | + | pub async fn set_state(db: &toasty::Db, issue: &mut Issue, new_state: &str) -> Result<()> { | |
| 94 | + | let mut conn = db.clone(); | |
| 95 | + | issue | |
| 96 | + | .update() | |
| 97 | + | .state(new_state) | |
| 98 | + | .updated_at(crate::now()) | |
| 99 | + | .exec(&mut conn) | |
| 100 | + | .await?; | |
| 101 | + | Ok(()) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Add a comment and bump the issue's activity time. | |
| 105 | + | pub async fn comment( | |
| 106 | + | db: &toasty::Db, | |
| 107 | + | issue: &mut Issue, | |
| 108 | + | author_id: i64, | |
| 109 | + | body: &str, | |
| 110 | + | ) -> Result<IssueComment> { | |
| 111 | + | let body = body.trim(); | |
| 112 | + | if body.is_empty() { | |
| 113 | + | return Err(Error::Invalid("comment must not be empty".into())); | |
| 114 | + | } | |
| 115 | + | let mut conn = db.clone(); | |
| 116 | + | let comment = toasty::create!(IssueComment { | |
| 117 | + | issue_id: issue.id, | |
| 118 | + | author_id: author_id, | |
| 119 | + | body: body, | |
| 120 | + | created_at: crate::now(), | |
| 121 | + | }) | |
| 122 | + | .exec(&mut conn) | |
| 123 | + | .await?; | |
| 124 | + | let mut conn = db.clone(); | |
| 125 | + | issue | |
| 126 | + | .update() | |
| 127 | + | .updated_at(crate::now()) | |
| 128 | + | .exec(&mut conn) | |
| 129 | + | .await?; | |
| 130 | + | Ok(comment) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// An issue's comments, oldest first. | |
| 134 | + | pub async fn comments(db: &toasty::Db, issue_id: i64) -> Result<Vec<IssueComment>> { | |
| 135 | + | let mut conn = db.clone(); | |
| 136 | + | let comments = IssueComment::filter(IssueComment::fields().issue_id().eq(issue_id)) | |
| 137 | + | .order_by(IssueComment::fields().id().asc()) | |
| 138 | + | .exec(&mut conn) | |
| 139 | + | .await?; | |
| 140 | + | Ok(comments) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[cfg(test)] | |
| 144 | + | mod tests { | |
| 145 | + | use super::*; | |
| 146 | + | ||
| 147 | + | #[tokio::test] | |
| 148 | + | async fn numbering_listing_and_comments() { | |
| 149 | + | let dir = tempfile::tempdir().unwrap(); | |
| 150 | + | let db = crate::db::connect(dir.path().join("t.db")).await.unwrap(); | |
| 151 | + | ||
| 152 | + | let a = create(&db, 1, 10, "first", "body").await.unwrap(); | |
| 153 | + | let b = create(&db, 1, 10, "second", "").await.unwrap(); | |
| 154 | + | let other = create(&db, 2, 10, "other repo", "").await.unwrap(); | |
| 155 | + | assert_eq!((a.number, b.number), (1, 2)); | |
| 156 | + | assert_eq!(other.number, 1, "numbering is per-repo"); | |
| 157 | + | ||
| 158 | + | assert!(create(&db, 1, 10, " ", "x").await.is_err(), "blank title"); | |
| 159 | + | ||
| 160 | + | // Activity ordering: commenting on #1 moves it to the front. | |
| 161 | + | let mut a = find(&db, 1, 1).await.unwrap().unwrap(); | |
| 162 | + | std::thread::sleep(std::time::Duration::from_millis(1100)); | |
| 163 | + | comment(&db, &mut a, 11, "hello").await.unwrap(); | |
| 164 | + | let open = list(&db, 1, state::OPEN).await.unwrap(); | |
| 165 | + | assert_eq!(open.len(), 2); | |
| 166 | + | assert_eq!(open[0].number, 1, "recently-commented issue first"); | |
| 167 | + | ||
| 168 | + | let mut b = find(&db, 1, 2).await.unwrap().unwrap(); | |
| 169 | + | set_state(&db, &mut b, state::CLOSED).await.unwrap(); | |
| 170 | + | assert_eq!(counts(&db, 1).await.unwrap(), (1, 1)); | |
| 171 | + | assert_eq!(list(&db, 1, state::CLOSED).await.unwrap()[0].number, 2); | |
| 172 | + | ||
| 173 | + | let cs = comments(&db, a.id).await.unwrap(); | |
| 174 | + | assert_eq!(cs.len(), 1); | |
| 175 | + | assert_eq!(cs[0].body, "hello"); | |
| 176 | + | assert!( | |
| 177 | + | comment(&db, &mut a, 11, " ").await.is_err(), | |
| 178 | + | "blank comment" | |
| 179 | + | ); | |
| 180 | + | } | |
| 181 | + | } |
modifiedcrates/anvil-core/src/lib.rs+4 −0
| ⋯ 9 unchanged lines | |||
| 10 | 10 | pub mod config; | |
| 11 | 11 | pub mod db; | |
| 12 | 12 | pub mod error; | |
| 13 | + | pub mod issues; | |
| 13 | 14 | pub mod models; | |
| 14 | 15 | pub mod repos; | |
| 15 | 16 | pub mod sessions; | |
| ⋯ 7 unchanged lines | |||
| 23 | 24 | Result, | |
| 24 | 25 | }; | |
| 25 | 26 | pub use models::{ | |
| 27 | + | CiArtifact, | |
| 26 | 28 | CiRun, | |
| 29 | + | Issue, | |
| 30 | + | IssueComment, | |
| 27 | 31 | Repository, | |
| 28 | 32 | Session, | |
| 29 | 33 | SshKey, | |
| ⋯ 96 unchanged lines | |||
modifiedcrates/anvil-core/src/models.rs+71 −0
| ⋯ 34 unchanged lines | |||
| 35 | 35 | /// Short name of the default branch, e.g. `main`. | |
| 36 | 36 | pub default_branch: String, | |
| 37 | 37 | pub created_at: i64, | |
| 38 | + | /// Push-mirror remote: after every successful push, refs are mirrored to | |
| 39 | + | /// this git URL (`git push --mirror`). Empty disables mirroring. New | |
| 40 | + | /// columns go last so `ALTER TABLE ADD COLUMN` on existing databases | |
| 41 | + | /// agrees with the fresh-schema column order. | |
| 42 | + | pub mirror_url: String, | |
| 38 | 43 | } | |
| 39 | 44 | ||
| 40 | 45 | /// A CI run for a pushed commit. | |
| ⋯ 20 unchanged lines | |||
| 61 | 66 | pub finished_at: i64, | |
| 62 | 67 | } | |
| 63 | 68 | ||
| 69 | + | /// One artifact produced by a CI run, stored on disk under | |
| 70 | + | /// `data_dir/artifacts/{repo_id}/{commit}/` (see `docs/ci-artifacts.md`). | |
| 71 | + | /// | |
| 72 | + | /// `commit` is denormalized from the run so per-commit lookups (the | |
| 73 | + | /// latest-on-branch alias) don't join through runs. | |
| 74 | + | #[derive(Clone, Debug, toasty::Model)] | |
| 75 | + | pub struct CiArtifact { | |
| 76 | + | #[key] | |
| 77 | + | #[auto] | |
| 78 | + | pub id: i64, | |
| 79 | + | #[index] | |
| 80 | + | pub run_id: i64, | |
| 81 | + | #[index] | |
| 82 | + | pub repo_id: i64, | |
| 83 | + | /// Full commit SHA the producing run was for. | |
| 84 | + | pub commit: String, | |
| 85 | + | /// Declared artifact name (unique within a pipeline, not globally). | |
| 86 | + | pub name: String, | |
| 87 | + | /// Total size in bytes (summed over files for directory artifacts). | |
| 88 | + | pub size: i64, | |
| 89 | + | /// Directory artifact (stored as a tarball, or extracted when `browse`). | |
| 90 | + | pub is_dir: bool, | |
| 91 | + | /// Served as a browsable static site rather than a download. | |
| 92 | + | pub browse: bool, | |
| 93 | + | /// JSON object of metadata-extractor key → output. | |
| 94 | + | pub meta: String, | |
| 95 | + | pub created_at: i64, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// An issue on a repository. `number` is the user-facing per-repo sequence | |
| 99 | + | /// (`#1`, `#2`, …); `id` stays the global key. `state` is `open` or `closed`. | |
| 100 | + | /// | |
| 101 | + | /// Numbering is assigned as max+1 at creation; with a single server process | |
| 102 | + | /// (our deployment shape) that cannot race. | |
| 103 | + | #[derive(Clone, Debug, toasty::Model)] | |
| 104 | + | pub struct Issue { | |
| 105 | + | #[key] | |
| 106 | + | #[auto] | |
| 107 | + | pub id: i64, | |
| 108 | + | #[index] | |
| 109 | + | pub repo_id: i64, | |
| 110 | + | pub number: i64, | |
| 111 | + | pub title: String, | |
| 112 | + | /// Markdown body (may be empty). | |
| 113 | + | pub body: String, | |
| 114 | + | pub author_id: i64, | |
| 115 | + | pub state: String, | |
| 116 | + | pub created_at: i64, | |
| 117 | + | /// Bumped on comments and state changes, for "recently active" ordering. | |
| 118 | + | pub updated_at: i64, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// A comment on an [`Issue`]. | |
| 122 | + | #[derive(Clone, Debug, toasty::Model)] | |
| 123 | + | pub struct IssueComment { | |
| 124 | + | #[key] | |
| 125 | + | #[auto] | |
| 126 | + | pub id: i64, | |
| 127 | + | #[index] | |
| 128 | + | pub issue_id: i64, | |
| 129 | + | pub author_id: i64, | |
| 130 | + | /// Markdown body. | |
| 131 | + | pub body: String, | |
| 132 | + | pub created_at: i64, | |
| 133 | + | } | |
| 134 | + | ||
| 64 | 135 | /// A web login session, keyed by an opaque random token stored in a cookie. | |
| 65 | 136 | #[derive(Debug, toasty::Model)] | |
| 66 | 137 | pub struct Session { | |
| ⋯ 25 unchanged lines | |||
modifiedcrates/anvil-core/src/repos.rs+98 −9
| ⋯ 1 unchanged line | |||
| 2 | 2 | ||
| 3 | 3 | use std::path::Path; | |
| 4 | 4 | ||
| 5 | - | use crate::error::{ | |
| 6 | - | Error, | |
| 7 | - | Result, | |
| 8 | - | }; | |
| 9 | - | use crate::models::{ | |
| 10 | - | Repository, | |
| 11 | - | User, | |
| 5 | + | use crate::{ | |
| 6 | + | error::{ | |
| 7 | + | Error, | |
| 8 | + | Result, | |
| 9 | + | }, | |
| 10 | + | models::{ | |
| 11 | + | Repository, | |
| 12 | + | User, | |
| 13 | + | }, | |
| 14 | + | storage, | |
| 12 | 15 | }; | |
| 13 | - | use crate::storage; | |
| 14 | 16 | ||
| 15 | 17 | /// A repository joined with its owner's username, for listing pages. | |
| 16 | 18 | pub struct RepoWithOwner { | |
| ⋯ 32 unchanged lines | |||
| 49 | 51 | is_private: is_private, | |
| 50 | 52 | default_branch: "main", | |
| 51 | 53 | created_at: crate::now(), | |
| 54 | + | mirror_url: "", | |
| 52 | 55 | }) | |
| 53 | 56 | .exec(&mut conn) | |
| 54 | 57 | .await?; | |
| ⋯ 11 unchanged lines | |||
| 66 | 69 | Ok(repo) | |
| 67 | 70 | } | |
| 68 | 71 | ||
| 72 | + | /// Push-to-create: create `owner_username/name` on first push, when the | |
| 73 | + | /// authenticated pusher is allowed to — the pusher *is* the owner, or is an | |
| 74 | + | /// admin. Created repositories are private (flip in settings afterwards). | |
| 75 | + | /// `Ok(None)` means the policy said no; callers fall back to not-found so | |
| 76 | + | /// nothing about the namespace leaks. | |
| 77 | + | pub async fn create_on_push( | |
| 78 | + | db: &toasty::Db, | |
| 79 | + | repositories_dir: &Path, | |
| 80 | + | owner_username: &str, | |
| 81 | + | name: &str, | |
| 82 | + | pusher: &User, | |
| 83 | + | ) -> Result<Option<Repository>> { | |
| 84 | + | let Some(owner) = crate::users::find_by_username(db, owner_username).await? else { | |
| 85 | + | return Ok(None); | |
| 86 | + | }; | |
| 87 | + | if pusher.id != owner.id && !pusher.is_admin { | |
| 88 | + | return Ok(None); | |
| 89 | + | } | |
| 90 | + | let repo = create(db, repositories_dir, &owner, name, "", true).await?; | |
| 91 | + | tracing::info!( | |
| 92 | + | "push-to-create: {}/{name} (by {})", | |
| 93 | + | owner.username, | |
| 94 | + | pusher.username | |
| 95 | + | ); | |
| 96 | + | Ok(Some(repo)) | |
| 97 | + | } | |
| 98 | + | ||
| 69 | 99 | /// Find a repository by its id. | |
| 70 | 100 | pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<Repository>> { | |
| 71 | 101 | let mut db = db.clone(); | |
| ⋯ 4 unchanged lines | |||
| 76 | 106 | Ok(repo) | |
| 77 | 107 | } | |
| 78 | 108 | ||
| 79 | - | /// Update a repository's description and visibility. | |
| 109 | + | /// Update a repository's description, visibility, and push-mirror URL. | |
| 80 | 110 | pub async fn update_settings( | |
| 81 | 111 | db: &toasty::Db, | |
| 82 | 112 | repo_id: i64, | |
| 83 | 113 | description: &str, | |
| 84 | 114 | is_private: bool, | |
| 115 | + | mirror_url: &str, | |
| 85 | 116 | ) -> Result<()> { | |
| 86 | 117 | let mut conn = db.clone(); | |
| 87 | 118 | let Some(mut repo) = Repository::filter(Repository::fields().id().eq(repo_id)) | |
| ⋯ 7 unchanged lines | |||
| 95 | 126 | repo.update() | |
| 96 | 127 | .description(description) | |
| 97 | 128 | .is_private(is_private) | |
| 129 | + | .mirror_url(mirror_url.trim()) | |
| 98 | 130 | .exec(&mut conn) | |
| 99 | 131 | .await?; | |
| 100 | 132 | Ok(()) | |
| ⋯ 53 unchanged lines | |||
| 154 | 186 | } | |
| 155 | 187 | Ok(()) | |
| 156 | 188 | } | |
| 189 | + | ||
| 190 | + | #[cfg(test)] | |
| 191 | + | mod tests { | |
| 192 | + | use super::*; | |
| 193 | + | ||
| 194 | + | /// Push-to-create only fires for the namespace owner or an admin, and the | |
| 195 | + | /// repos it creates are private. | |
| 196 | + | #[tokio::test] | |
| 197 | + | async fn create_on_push_policy() { | |
| 198 | + | let dir = tempfile::tempdir().unwrap(); | |
| 199 | + | let db = crate::db::connect(dir.path().join("t.db")).await.unwrap(); | |
| 200 | + | let repos_dir = dir.path().join("repos"); | |
| 201 | + | ||
| 202 | + | let alice = crate::users::create(&db, "alice", "", "pw-alice-1", false) | |
| 203 | + | .await | |
| 204 | + | .unwrap(); | |
| 205 | + | let bob = crate::users::create(&db, "bob", "", "pw-bob-1", false) | |
| 206 | + | .await | |
| 207 | + | .unwrap(); | |
| 208 | + | let root = crate::users::create(&db, "root", "", "pw-root-1", true) | |
| 209 | + | .await | |
| 210 | + | .unwrap(); | |
| 211 | + | ||
| 212 | + | // A stranger can't create into someone else's namespace… | |
| 213 | + | let denied = create_on_push(&db, &repos_dir, "alice", "proj", &bob) | |
| 214 | + | .await | |
| 215 | + | .unwrap(); | |
| 216 | + | assert!(denied.is_none()); | |
| 217 | + | // …nor into a namespace with no such user. | |
| 218 | + | let nobody = create_on_push(&db, &repos_dir, "ghost", "proj", &bob) | |
| 219 | + | .await | |
| 220 | + | .unwrap(); | |
| 221 | + | assert!(nobody.is_none()); | |
| 222 | + | ||
| 223 | + | // The owner can, and gets a private repo on disk. | |
| 224 | + | let repo = create_on_push(&db, &repos_dir, "alice", "proj", &alice) | |
| 225 | + | .await | |
| 226 | + | .unwrap() | |
| 227 | + | .expect("owner may push-create"); | |
| 228 | + | assert!(repo.is_private); | |
| 229 | + | assert!(storage::repo_path(&repos_dir, "alice", "proj").exists()); | |
| 230 | + | ||
| 231 | + | // An admin can create into any namespace. | |
| 232 | + | let by_admin = create_on_push(&db, &repos_dir, "bob", "tool", &root) | |
| 233 | + | .await | |
| 234 | + | .unwrap(); | |
| 235 | + | assert!(by_admin.is_some()); | |
| 236 | + | ||
| 237 | + | // Re-creating an existing repo is an error (callers never reach this: | |
| 238 | + | // they only call after a failed lookup). | |
| 239 | + | assert!( | |
| 240 | + | create_on_push(&db, &repos_dir, "alice", "proj", &alice) | |
| 241 | + | .await | |
| 242 | + | .is_err() | |
| 243 | + | ); | |
| 244 | + | } | |
| 245 | + | } | |
modifiedcrates/anvil-core/src/storage.rs+14 −6
| ⋯ 19 unchanged lines | |||
| 20 | 20 | repositories_dir.join(owner).join(format!("{name}.git")) | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | + | /// Directory holding one commit's CI artifacts | |
| 24 | + | /// (`<artifacts_dir>/<repo_id>/<commit>` — see `docs/ci-artifacts.md`). | |
| 25 | + | pub fn artifact_commit_dir(artifacts_dir: &Path, repo_id: i64, commit: &str) -> PathBuf { | |
| 26 | + | artifacts_dir.join(repo_id.to_string()).join(commit) | |
| 27 | + | } | |
| 28 | + | ||
| 23 | 29 | /// Create a new bare repository on disk, returning the opened handle. | |
| 24 | 30 | /// | |
| 25 | 31 | /// `HEAD` is pointed at `refs/heads/<default_branch>` so the on-disk repository | |
| ⋯ 26 unchanged lines | |||
| 52 | 58 | ||
| 53 | 59 | /// Point `HEAD` at `refs/heads/<branch>` as a symbolic reference. | |
| 54 | 60 | fn set_head_branch(repo: &gix::Repository, branch: &str) -> Result<()> { | |
| 55 | - | use gix::refs::Target; | |
| 56 | - | use gix::refs::transaction::{ | |
| 57 | - | Change, | |
| 58 | - | LogChange, | |
| 59 | - | PreviousValue, | |
| 60 | - | RefEdit, | |
| 61 | + | use gix::refs::{ | |
| 62 | + | Target, | |
| 63 | + | transaction::{ | |
| 64 | + | Change, | |
| 65 | + | LogChange, | |
| 66 | + | PreviousValue, | |
| 67 | + | RefEdit, | |
| 68 | + | }, | |
| 61 | 69 | }; | |
| 62 | 70 | ||
| 63 | 71 | let target_name: gix::refs::FullName = format!("refs/heads/{branch}") | |
| ⋯ 24 unchanged lines | |||
modifiedcrates/anvil-git/src/browse.rs+35 −5
| ⋯ 2 unchanged lines | |||
| 3 | 3 | //! These helpers back the web UI. Each opens the bare repo by path; that is | |
| 4 | 4 | //! cheap enough at our scale and keeps the API stateless. | |
| 5 | 5 | ||
| 6 | - | use std::collections::{ | |
| 7 | - | BTreeMap, | |
| 8 | - | BTreeSet, | |
| 6 | + | use std::{ | |
| 7 | + | collections::{ | |
| 8 | + | BTreeMap, | |
| 9 | + | BTreeSet, | |
| 10 | + | }, | |
| 11 | + | fmt::Display, | |
| 12 | + | path::Path, | |
| 9 | 13 | }; | |
| 10 | - | use std::fmt::Display; | |
| 11 | - | use std::path::Path; | |
| 12 | 14 | ||
| 13 | 15 | use crate::error::{ | |
| 14 | 16 | Error, | |
| ⋯ 219 unchanged lines | |||
| 234 | 236 | Ok(out) | |
| 235 | 237 | } | |
| 236 | 238 | ||
| 239 | + | /// The commit ids each local branch currently points at (deduplicated). | |
| 240 | + | /// Used to pin branch-tip artifacts during GC. | |
| 241 | + | pub fn branch_tips(repo_path: &Path) -> Result<BTreeSet<String>> { | |
| 242 | + | let repo = gix::open(repo_path).map_err(read)?; | |
| 243 | + | let refs = repo.references().map_err(read)?; | |
| 244 | + | let mut tips = BTreeSet::new(); | |
| 245 | + | for r in refs.local_branches().map_err(read)?.flatten() { | |
| 246 | + | if let Some(id) = r.try_id() { | |
| 247 | + | tips.insert(id.to_string()); | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | Ok(tips) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// Resolve `rev` (branch, tag, or commit-ish) to its full commit id. | |
| 254 | + | pub fn resolve_commit(repo_path: &Path, rev: &str) -> Result<String> { | |
| 255 | + | let repo = gix::open(repo_path).map_err(read)?; | |
| 256 | + | let id = repo | |
| 257 | + | .rev_parse_single(rev) | |
| 258 | + | .map_err(read)? | |
| 259 | + | .object() | |
| 260 | + | .map_err(read)? | |
| 261 | + | .peel_to_commit() | |
| 262 | + | .map_err(read)? | |
| 263 | + | .id(); | |
| 264 | + | Ok(id.to_string()) | |
| 265 | + | } | |
| 266 | + | ||
| 237 | 267 | /// Walk commit history starting at `rev`, newest first, up to `limit` commits. | |
| 238 | 268 | pub fn commit_log(repo_path: &Path, rev: &str, limit: usize) -> Result<Vec<CommitInfo>> { | |
| 239 | 269 | let repo = gix::open(repo_path).map_err(read)?; | |
| ⋯ 293 unchanged lines | |||
modifiedcrates/anvil-git/src/lib.rs+1 −0
| ⋯ 14 unchanged lines | |||
| 15 | 15 | ||
| 16 | 16 | pub mod browse; | |
| 17 | 17 | pub mod error; | |
| 18 | + | pub mod mirror; | |
| 18 | 19 | pub mod smart_http; | |
| 19 | 20 | pub mod ssh; | |
| 20 | 21 | pub mod trigger; | |
| ⋯ 9 unchanged lines | |||
addedcrates/anvil-git/src/mirror.rs+152 −0
| 1 | + | //! Push mirroring: after a successful push to an anvil repo, forward all refs | |
| 2 | + | //! to a configured remote (e.g. a GitHub repo) with `git push --mirror`. | |
| 3 | + | //! | |
| 4 | + | //! This shells out to the `git` CLI — gitoxide can't push yet. The runtime | |
| 5 | + | //! image installs git for exactly this. Mirroring is best-effort and runs in | |
| 6 | + | //! the background: a failure is logged (with credentials stripped) and never | |
| 7 | + | //! affects the push that triggered it. | |
| 8 | + | //! | |
| 9 | + | //! For GitHub over HTTPS, use a token URL: | |
| 10 | + | //! `https://x-access-token:<token>@github.com/you/repo.git`. The URL is | |
| 11 | + | //! stored as-is in the database — treat it like a secret. | |
| 12 | + | ||
| 13 | + | use std::path::PathBuf; | |
| 14 | + | ||
| 15 | + | /// Spawn a background `git push --mirror <url>` for `repo_path`. Returns | |
| 16 | + | /// immediately; the result is only logged. | |
| 17 | + | pub fn spawn_push(repo_path: PathBuf, url: String) { | |
| 18 | + | tokio::spawn(async move { | |
| 19 | + | let shown = redact(&url); | |
| 20 | + | match push(&repo_path, &url).await { | |
| 21 | + | Ok(()) => tracing::info!("mirror: pushed {} to {shown}", repo_path.display()), | |
| 22 | + | Err(e) => tracing::error!( | |
| 23 | + | "mirror: push of {} to {shown} failed: {e}", | |
| 24 | + | repo_path.display() | |
| 25 | + | ), | |
| 26 | + | } | |
| 27 | + | }); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | async fn push(repo_path: &std::path::Path, url: &str) -> Result<(), String> { | |
| 31 | + | let output = tokio::process::Command::new("git") | |
| 32 | + | .arg("-C") | |
| 33 | + | .arg(repo_path) | |
| 34 | + | .args(["push", "--mirror", url]) | |
| 35 | + | // Never block on a credential prompt; fail instead. | |
| 36 | + | .env("GIT_TERMINAL_PROMPT", "0") | |
| 37 | + | .output() | |
| 38 | + | .await | |
| 39 | + | .map_err(|e| format!("running git: {e} (is git installed?)"))?; | |
| 40 | + | if output.status.success() { | |
| 41 | + | Ok(()) | |
| 42 | + | } else { | |
| 43 | + | Err(redact(&String::from_utf8_lossy(&output.stderr)) | |
| 44 | + | .lines() | |
| 45 | + | .collect::<Vec<_>>() | |
| 46 | + | .join(" / ")) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Strip the userinfo (`user:token@`) out of anything URL-shaped so secrets | |
| 51 | + | /// never reach the log. | |
| 52 | + | fn redact(text: &str) -> String { | |
| 53 | + | let mut out = String::with_capacity(text.len()); | |
| 54 | + | for (i, part) in text.split("://").enumerate() { | |
| 55 | + | if i == 0 { | |
| 56 | + | out.push_str(part); | |
| 57 | + | continue; | |
| 58 | + | } | |
| 59 | + | out.push_str("://"); | |
| 60 | + | match part.split_once('@') { | |
| 61 | + | // Heuristic: an '@' before the next '/' is userinfo. | |
| 62 | + | Some((userinfo, rest)) if !userinfo.contains('/') => { | |
| 63 | + | out.push_str("***@"); | |
| 64 | + | out.push_str(rest); | |
| 65 | + | } | |
| 66 | + | _ => out.push_str(part), | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | out | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[cfg(test)] | |
| 73 | + | mod tests { | |
| 74 | + | use super::*; | |
| 75 | + | ||
| 76 | + | #[test] | |
| 77 | + | fn redacts_userinfo_only() { | |
| 78 | + | assert_eq!( | |
| 79 | + | redact("https://x-access-token:ghp_abc@github.com/a/b.git"), | |
| 80 | + | "https://***@github.com/a/b.git" | |
| 81 | + | ); | |
| 82 | + | assert_eq!( | |
| 83 | + | redact("error: https://github.com/a/b.git denied"), | |
| 84 | + | "error: https://github.com/a/b.git denied" | |
| 85 | + | ); | |
| 86 | + | assert_eq!(redact("no urls here"), "no urls here"); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// End-to-end against a local bare "remote": a mirror push transfers | |
| 90 | + | /// branches and removes deleted ones. | |
| 91 | + | #[tokio::test] | |
| 92 | + | async fn mirror_push_to_local_remote() { | |
| 93 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 94 | + | let src = tmp.path().join("src.git"); | |
| 95 | + | let dst = tmp.path().join("dst.git"); | |
| 96 | + | let work = tmp.path().join("w"); | |
| 97 | + | ||
| 98 | + | let git = |args: &[&str], dir: &std::path::Path| { | |
| 99 | + | let out = std::process::Command::new("git") | |
| 100 | + | .args(args) | |
| 101 | + | .current_dir(dir) | |
| 102 | + | .env("GIT_AUTHOR_NAME", "t") | |
| 103 | + | .env("GIT_AUTHOR_EMAIL", "t@example.com") | |
| 104 | + | .env("GIT_COMMITTER_NAME", "t") | |
| 105 | + | .env("GIT_COMMITTER_EMAIL", "t@example.com") | |
| 106 | + | .output() | |
| 107 | + | .expect("run git"); | |
| 108 | + | assert!(out.status.success(), "git {args:?}: {out:?}"); | |
| 109 | + | }; | |
| 110 | + | ||
| 111 | + | git(&["init", "-q", "--bare", src.to_str().unwrap()], tmp.path()); | |
| 112 | + | git(&["init", "-q", "--bare", dst.to_str().unwrap()], tmp.path()); | |
| 113 | + | git( | |
| 114 | + | &["init", "-q", "-b", "main", work.to_str().unwrap()], | |
| 115 | + | tmp.path(), | |
| 116 | + | ); | |
| 117 | + | std::fs::write(work.join("f"), "x").unwrap(); | |
| 118 | + | git(&["add", "."], &work); | |
| 119 | + | git(&["commit", "-qm", "c1"], &work); | |
| 120 | + | git( | |
| 121 | + | &["push", "-q", src.to_str().unwrap(), "main", "main:extra"], | |
| 122 | + | &work, | |
| 123 | + | ); | |
| 124 | + | ||
| 125 | + | push(&src, dst.to_str().unwrap()).await.unwrap(); | |
| 126 | + | let heads = std::process::Command::new("git") | |
| 127 | + | .args([ | |
| 128 | + | "-C", | |
| 129 | + | dst.to_str().unwrap(), | |
| 130 | + | "branch", | |
| 131 | + | "--format=%(refname:short)", | |
| 132 | + | ]) | |
| 133 | + | .output() | |
| 134 | + | .unwrap(); | |
| 135 | + | let heads = String::from_utf8_lossy(&heads.stdout); | |
| 136 | + | assert!(heads.contains("main") && heads.contains("extra")); | |
| 137 | + | ||
| 138 | + | // Deleting a branch upstream propagates on the next mirror push. | |
| 139 | + | git(&["push", "-q", src.to_str().unwrap(), ":extra"], &work); | |
| 140 | + | push(&src, dst.to_str().unwrap()).await.unwrap(); | |
| 141 | + | let heads = std::process::Command::new("git") | |
| 142 | + | .args([ | |
| 143 | + | "-C", | |
| 144 | + | dst.to_str().unwrap(), | |
| 145 | + | "branch", | |
| 146 | + | "--format=%(refname:short)", | |
| 147 | + | ]) | |
| 148 | + | .output() | |
| 149 | + | .unwrap(); | |
| 150 | + | assert!(!String::from_utf8_lossy(&heads.stdout).contains("extra")); | |
| 151 | + | } | |
| 152 | + | } |
modifiedcrates/anvil-ssh/src/lib.rs+54 −40
| ⋯ 5 unchanged lines | |||
| 6 | 6 | //! | |
| 7 | 7 | //! The SSH bind address is configurable (`[ssh] listen` in the config). | |
| 8 | 8 | ||
| 9 | - | use std::net::SocketAddr; | |
| 10 | - | use std::path::{ | |
| 11 | - | Path, | |
| 12 | - | PathBuf, | |
| 9 | + | use std::{ | |
| 10 | + | net::SocketAddr, | |
| 11 | + | path::{ | |
| 12 | + | Path, | |
| 13 | + | PathBuf, | |
| 14 | + | }, | |
| 15 | + | sync::Arc, | |
| 16 | + | time::Duration, | |
| 13 | 17 | }; | |
| 14 | - | use std::sync::Arc; | |
| 15 | - | use std::time::Duration; | |
| 16 | 18 | ||
| 17 | 19 | use anvil_core::{ | |
| 18 | 20 | App, | |
| ⋯ 4 unchanged lines | |||
| 23 | 25 | users, | |
| 24 | 26 | }; | |
| 25 | 27 | use anvil_git::ssh as git_ssh; | |
| 26 | - | use russh::keys::ssh_key::{ | |
| 27 | - | HashAlg, | |
| 28 | - | LineEnding, | |
| 29 | - | PublicKey, | |
| 30 | - | }; | |
| 31 | - | use russh::keys::{ | |
| 32 | - | Algorithm, | |
| 33 | - | PrivateKey, | |
| 34 | - | }; | |
| 35 | - | use russh::server::{ | |
| 36 | - | self, | |
| 37 | - | Auth, | |
| 38 | - | Handler, | |
| 39 | - | Msg, | |
| 40 | - | Server as _, | |
| 41 | - | Session, | |
| 42 | - | }; | |
| 43 | 28 | use russh::{ | |
| 44 | 29 | Channel, | |
| 45 | 30 | ChannelId, | |
| 31 | + | keys::{ | |
| 32 | + | Algorithm, | |
| 33 | + | PrivateKey, | |
| 34 | + | ssh_key::{ | |
| 35 | + | HashAlg, | |
| 36 | + | LineEnding, | |
| 37 | + | PublicKey, | |
| 38 | + | }, | |
| 39 | + | }, | |
| 40 | + | server::{ | |
| 41 | + | self, | |
| 42 | + | Auth, | |
| 43 | + | Handler, | |
| 44 | + | Msg, | |
| 45 | + | Server as _, | |
| 46 | + | Session, | |
| 47 | + | }, | |
| 46 | 48 | }; | |
| 47 | 49 | use tokio::net::TcpListener; | |
| 48 | 50 | ||
| ⋯ 132 unchanged lines | |||
| 181 | 183 | return fail(session, channel_id, "unsupported command"); | |
| 182 | 184 | }; | |
| 183 | 185 | let need_write = service == anvil_git::Service::ReceivePack; | |
| 184 | - | let (path, repo_id) = | |
| 185 | - | match authorize_repo(&self.app, self.authed_user, &rel, need_write).await { | |
| 186 | - | Ok(resolved) => resolved, | |
| 187 | - | Err(message) => return fail(session, channel_id, message), | |
| 188 | - | }; | |
| 186 | + | let (path, repo) = match authorize_repo(&self.app, self.authed_user, &rel, need_write).await | |
| 187 | + | { | |
| 188 | + | Ok(resolved) => resolved, | |
| 189 | + | Err(message) => return fail(session, channel_id, message), | |
| 190 | + | }; | |
| 189 | 191 | let Some(channel) = self.channel.take() else { | |
| 190 | 192 | return fail(session, channel_id, "no session channel"); | |
| 191 | 193 | }; | |
| ⋯ 28 unchanged lines | |||
| 220 | 222 | && let Some(before) = before | |
| 221 | 223 | { | |
| 222 | 224 | for run_id in | |
| 223 | - | anvil_git::trigger::enqueue_ci_for_push(&app.db, repo_id, &path, &before).await | |
| 225 | + | anvil_git::trigger::enqueue_ci_for_push(&app.db, repo.id, &path, &before).await | |
| 224 | 226 | { | |
| 225 | 227 | app.notify_ci(run_id); | |
| 226 | 228 | } | |
| 229 | + | if !repo.mirror_url.is_empty() { | |
| 230 | + | anvil_git::mirror::spawn_push(path.clone(), repo.mirror_url.clone()); | |
| 231 | + | } | |
| 227 | 232 | } | |
| 228 | 233 | ||
| 229 | 234 | let _ = handle.exit_status_request(channel_id, code).await; | |
| ⋯ 20 unchanged lines | |||
| 250 | 255 | authed_user: Option<i64>, | |
| 251 | 256 | rel: &str, | |
| 252 | 257 | need_write: bool, | |
| 253 | - | ) -> Result<(PathBuf, i64), &'static str> { | |
| 258 | + | ) -> Result<(PathBuf, anvil_core::Repository), &'static str> { | |
| 254 | 259 | let (owner, repo) = rel | |
| 255 | 260 | .trim_start_matches('/') | |
| 256 | 261 | .split_once('/') | |
| ⋯ 4 unchanged lines | |||
| 261 | 266 | return Err("invalid repository path"); | |
| 262 | 267 | } | |
| 263 | 268 | ||
| 269 | + | let viewer = match authed_user { | |
| 270 | + | Some(id) => users::find_by_id(&app.db, id).await.ok().flatten(), | |
| 271 | + | None => None, | |
| 272 | + | }; | |
| 273 | + | ||
| 264 | 274 | let owner_user = users::find_by_username(&app.db, owner) | |
| 265 | 275 | .await | |
| 266 | 276 | .ok() | |
| 267 | 277 | .flatten() | |
| 268 | 278 | .ok_or("repository not found")?; | |
| 269 | - | let repo = repos::find(&app.db, owner_user.id, name) | |
| 270 | - | .await | |
| 271 | - | .ok() | |
| 272 | - | .flatten() | |
| 273 | - | .ok_or("repository not found")?; | |
| 274 | - | ||
| 275 | - | let viewer = match authed_user { | |
| 276 | - | Some(id) => users::find_by_id(&app.db, id).await.ok().flatten(), | |
| 277 | - | None => None, | |
| 279 | + | let repo = match repos::find(&app.db, owner_user.id, name).await { | |
| 280 | + | Ok(Some(repo)) => repo, | |
| 281 | + | // Push-to-create: a push to a missing repo creates it when the | |
| 282 | + | // authenticated pusher owns the namespace (or is an admin). | |
| 283 | + | Ok(None) if need_write && viewer.is_some() => { | |
| 284 | + | let pusher = viewer.as_ref().expect("checked is_some"); | |
| 285 | + | repos::create_on_push(&app.db, &app.config.repositories_dir(), owner, name, pusher) | |
| 286 | + | .await | |
| 287 | + | .ok() | |
| 288 | + | .flatten() | |
| 289 | + | .ok_or("repository not found")? | |
| 290 | + | } | |
| 291 | + | _ => return Err("repository not found"), | |
| 278 | 292 | }; | |
| 279 | 293 | let allowed = if need_write { | |
| 280 | 294 | access::can_write(&repo, viewer.as_ref()) | |
| ⋯ 8 unchanged lines | |||
| 289 | 303 | if !path.exists() { | |
| 290 | 304 | return Err("repository not found"); | |
| 291 | 305 | } | |
| 292 | - | Ok((path, repo.id)) | |
| 306 | + | Ok((path, repo)) | |
| 293 | 307 | } | |
modifiedcrates/anvil-web/Cargo.toml+1 −0
| ⋯ 16 unchanged lines | |||
| 17 | 17 | tower-http.workspace = true | |
| 18 | 18 | tracing.workspace = true | |
| 19 | 19 | serde.workspace = true | |
| 20 | + | serde_json.workspace = true | |
| 20 | 21 | base64.workspace = true | |
| 21 | 22 | lru.workspace = true | |
| 22 | 23 | maud.workspace = true | |
| ⋯ 4 unchanged lines | |||
addedcrates/anvil-web/src/artifacts.rs+241 −0
| 1 | + | //! Serving CI artifacts (see `docs/ci-artifacts.md`). | |
| 2 | + | //! | |
| 3 | + | //! Three routes: | |
| 4 | + | //! - `/{owner}/{repo}/ci/{run}/artifacts/{name}` — run-scoped download. | |
| 5 | + | //! - `/{owner}/{repo}/artifacts/{rev}/{name}` — alias resolving `rev` (branch, | |
| 6 | + | //! tag, or commit) to that commit's newest artifact; redirects to the | |
| 7 | + | //! canonical location. With CI running on every push tip, a branch name | |
| 8 | + | //! here is "latest on branch". | |
| 9 | + | //! - `/{owner}/{repo}/artifacts/{rev}/{name}/{*path}` — browsable artifacts | |
| 10 | + | //! (`browse: true`): files served like a pages site, with `index.html` | |
| 11 | + | //! resolution. Same forge-origin caveat as pages (`docs/untrusted-mode.md` | |
| 12 | + | //! §2). | |
| 13 | + | //! | |
| 14 | + | //! Access follows repository visibility, like CI logs and pages. | |
| 15 | + | ||
| 16 | + | use std::path::{ | |
| 17 | + | Component, | |
| 18 | + | PathBuf, | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | use anvil_core::{ | |
| 22 | + | App, | |
| 23 | + | CiArtifact, | |
| 24 | + | ci, | |
| 25 | + | storage, | |
| 26 | + | }; | |
| 27 | + | use anvil_git::browse; | |
| 28 | + | use axum::{ | |
| 29 | + | Router, | |
| 30 | + | extract::{ | |
| 31 | + | Path, | |
| 32 | + | State, | |
| 33 | + | }, | |
| 34 | + | http::header, | |
| 35 | + | response::{ | |
| 36 | + | IntoResponse, | |
| 37 | + | Redirect, | |
| 38 | + | Response, | |
| 39 | + | }, | |
| 40 | + | routing::get, | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | use crate::{ | |
| 44 | + | auth::CurrentUser, | |
| 45 | + | pages::file_response, | |
| 46 | + | ui::{ | |
| 47 | + | not_found, | |
| 48 | + | resolve_repo, | |
| 49 | + | server_error, | |
| 50 | + | }, | |
| 51 | + | }; | |
| 52 | + | ||
| 53 | + | /// Mount the artifact routes. | |
| 54 | + | pub fn routes(router: Router<App>) -> Router<App> { | |
| 55 | + | router | |
| 56 | + | .route( | |
| 57 | + | "/{owner}/{repo}/ci/{run}/artifacts/{name}", | |
| 58 | + | get(download_for_run), | |
| 59 | + | ) | |
| 60 | + | .route("/{owner}/{repo}/artifacts/{rev}/{name}", get(alias)) | |
| 61 | + | .route( | |
| 62 | + | "/{owner}/{repo}/artifacts/{rev}/{name}/{*path}", | |
| 63 | + | get(browse_serve), | |
| 64 | + | ) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// The on-disk location of a stored artifact (file or tarball form). | |
| 68 | + | fn stored_path(app: &App, a: &CiArtifact) -> PathBuf { | |
| 69 | + | let dir = storage::artifact_commit_dir(&app.config.artifacts_dir(), a.repo_id, &a.commit); | |
| 70 | + | if a.is_dir && !a.browse { | |
| 71 | + | dir.join(format!("{}.tar.gz", a.name)) | |
| 72 | + | } else { | |
| 73 | + | dir.join(&a.name) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// `GET /{owner}/{repo}/ci/{run}/artifacts/{name}` — download one artifact of | |
| 78 | + | /// one run. Browsable artifacts redirect to their site root instead. | |
| 79 | + | async fn download_for_run( | |
| 80 | + | State(app): State<App>, | |
| 81 | + | CurrentUser(user): CurrentUser, | |
| 82 | + | Path((owner, repo, run_id, name)): Path<(String, String, i64, String)>, | |
| 83 | + | ) -> Response { | |
| 84 | + | let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await { | |
| 85 | + | Ok(v) => v, | |
| 86 | + | Err(resp) => return resp, | |
| 87 | + | }; | |
| 88 | + | let artifacts = match ci::artifacts_for_run(&app.db, run_id).await { | |
| 89 | + | Ok(a) => a, | |
| 90 | + | Err(e) => return server_error(e), | |
| 91 | + | }; | |
| 92 | + | let Some(artifact) = artifacts | |
| 93 | + | .into_iter() | |
| 94 | + | .find(|a| a.name == name && a.repo_id == meta.id) | |
| 95 | + | else { | |
| 96 | + | return not_found("no such artifact"); | |
| 97 | + | }; | |
| 98 | + | if artifact.browse { | |
| 99 | + | return Redirect::to(&format!( | |
| 100 | + | "/{owner}/{repo}/artifacts/{}/{}/index.html", | |
| 101 | + | artifact.commit, artifact.name | |
| 102 | + | )) | |
| 103 | + | .into_response(); | |
| 104 | + | } | |
| 105 | + | serve_download(&app, &artifact) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// `GET /{owner}/{repo}/artifacts/{rev}/{name}` — resolve `rev` and serve the | |
| 109 | + | /// newest artifact for that commit (downloads directly; browsable artifacts | |
| 110 | + | /// redirect to their site root under the same rev, keeping the URL stable). | |
| 111 | + | async fn alias( | |
| 112 | + | State(app): State<App>, | |
| 113 | + | CurrentUser(user): CurrentUser, | |
| 114 | + | Path((owner, repo, rev, name)): Path<(String, String, String, String)>, | |
| 115 | + | ) -> Response { | |
| 116 | + | let (repo_path, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await { | |
| 117 | + | Ok(v) => v, | |
| 118 | + | Err(resp) => return resp, | |
| 119 | + | }; | |
| 120 | + | let artifact = match lookup(&app, &repo_path, meta.id, &rev, &name).await { | |
| 121 | + | Ok(a) => a, | |
| 122 | + | Err(resp) => return resp, | |
| 123 | + | }; | |
| 124 | + | if artifact.browse { | |
| 125 | + | return Redirect::to(&format!( | |
| 126 | + | "/{owner}/{repo}/artifacts/{}/{}/index.html", | |
| 127 | + | crate::ui::enc_ref(&rev), | |
| 128 | + | artifact.name | |
| 129 | + | )) | |
| 130 | + | .into_response(); | |
| 131 | + | } | |
| 132 | + | serve_download(&app, &artifact) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// `GET /{owner}/{repo}/artifacts/{rev}/{name}/{*path}` — serve a file from a | |
| 136 | + | /// browsable artifact's extracted tree. Directory paths resolve to their | |
| 137 | + | /// `index.html`, redirecting to the trailing-slash form first so the site's | |
| 138 | + | /// relative links work. | |
| 139 | + | async fn browse_serve( | |
| 140 | + | State(app): State<App>, | |
| 141 | + | CurrentUser(user): CurrentUser, | |
| 142 | + | Path((owner, repo, rev, name, path)): Path<(String, String, String, String, String)>, | |
| 143 | + | ) -> Response { | |
| 144 | + | let (repo_path, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await { | |
| 145 | + | Ok(v) => v, | |
| 146 | + | Err(resp) => return resp, | |
| 147 | + | }; | |
| 148 | + | let artifact = match lookup(&app, &repo_path, meta.id, &rev, &name).await { | |
| 149 | + | Ok(a) => a, | |
| 150 | + | Err(resp) => return resp, | |
| 151 | + | }; | |
| 152 | + | if !artifact.browse { | |
| 153 | + | return not_found("not a browsable artifact"); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | let root = stored_path(&app, &artifact); | |
| 157 | + | let trimmed = path.trim_end_matches('/'); | |
| 158 | + | let Some(rel) = sanitize(trimmed) else { | |
| 159 | + | return not_found("no such file"); | |
| 160 | + | }; | |
| 161 | + | ||
| 162 | + | let file = root.join(&rel); | |
| 163 | + | if file.is_file() { | |
| 164 | + | return match std::fs::read(&file) { | |
| 165 | + | Ok(bytes) => file_response(trimmed, bytes), | |
| 166 | + | Err(e) => server_error(e), | |
| 167 | + | }; | |
| 168 | + | } | |
| 169 | + | // A directory (or the artifact root, when `path` was only slashes): | |
| 170 | + | // resolve its index.html behind a trailing-slash redirect. | |
| 171 | + | let index = file.join("index.html"); | |
| 172 | + | if index.is_file() { | |
| 173 | + | if !path.ends_with('/') { | |
| 174 | + | return Redirect::to(&format!( | |
| 175 | + | "/{owner}/{repo}/artifacts/{}/{name}/{trimmed}/", | |
| 176 | + | crate::ui::enc_ref(&rev) | |
| 177 | + | )) | |
| 178 | + | .into_response(); | |
| 179 | + | } | |
| 180 | + | return match std::fs::read(&index) { | |
| 181 | + | Ok(bytes) => file_response("index.html", bytes), | |
| 182 | + | Err(e) => server_error(e), | |
| 183 | + | }; | |
| 184 | + | } | |
| 185 | + | not_found("no such file") | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// Resolve `rev` to a commit and find that commit's newest artifact `name`. | |
| 189 | + | async fn lookup( | |
| 190 | + | app: &App, | |
| 191 | + | repo_path: &std::path::Path, | |
| 192 | + | repo_id: i64, | |
| 193 | + | rev: &str, | |
| 194 | + | name: &str, | |
| 195 | + | ) -> Result<CiArtifact, Response> { | |
| 196 | + | let commit = browse::resolve_commit(repo_path, rev) | |
| 197 | + | .map_err(|_| not_found("no such branch, tag, or commit"))?; | |
| 198 | + | ci::latest_artifact(&app.db, repo_id, &commit, name) | |
| 199 | + | .await | |
| 200 | + | .map_err(server_error)? | |
| 201 | + | .ok_or_else(|| not_found("no artifact with that name for this commit")) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// Attachment response for a stored file/tarball artifact. | |
| 205 | + | fn serve_download(app: &App, artifact: &CiArtifact) -> Response { | |
| 206 | + | let path = stored_path(app, artifact); | |
| 207 | + | let bytes = match std::fs::read(&path) { | |
| 208 | + | Ok(b) => b, | |
| 209 | + | Err(_) => return not_found("artifact data missing on disk"), | |
| 210 | + | }; | |
| 211 | + | let filename = path | |
| 212 | + | .file_name() | |
| 213 | + | .map(|n| n.to_string_lossy().into_owned()) | |
| 214 | + | .unwrap_or_else(|| artifact.name.clone()); | |
| 215 | + | ( | |
| 216 | + | [ | |
| 217 | + | (header::CONTENT_TYPE, "application/octet-stream".to_string()), | |
| 218 | + | (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), | |
| 219 | + | ( | |
| 220 | + | header::CONTENT_DISPOSITION, | |
| 221 | + | format!("attachment; filename=\"{filename}\""), | |
| 222 | + | ), | |
| 223 | + | ], | |
| 224 | + | bytes, | |
| 225 | + | ) | |
| 226 | + | .into_response() | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /// Normalize a request path to a safe relative path (no `..`, no absolutes, | |
| 230 | + | /// no empty/`.` segments). `None` rejects the request. | |
| 231 | + | fn sanitize(path: &str) -> Option<PathBuf> { | |
| 232 | + | let mut out = PathBuf::new(); | |
| 233 | + | for c in std::path::Path::new(path).components() { | |
| 234 | + | match c { | |
| 235 | + | Component::Normal(p) => out.push(p), | |
| 236 | + | Component::CurDir => {} | |
| 237 | + | _ => return None, | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | Some(out) | |
| 241 | + | } |
modifiedcrates/anvil-web/src/git_http.rs+73 −16
| ⋯ 7 unchanged lines | |||
| 8 | 8 | //! Access control: public repos may be cloned anonymously; private repos and all | |
| 9 | 9 | //! pushes require HTTP Basic auth, enforced via [`anvil_core::access`]. | |
| 10 | 10 | ||
| 11 | - | use std::collections::HashMap; | |
| 12 | - | use std::path::PathBuf; | |
| 11 | + | use std::{ | |
| 12 | + | collections::HashMap, | |
| 13 | + | path::PathBuf, | |
| 14 | + | }; | |
| 13 | 15 | ||
| 14 | 16 | use anvil_core::{ | |
| 15 | 17 | App, | |
| ⋯ 66 unchanged lines | |||
| 82 | 84 | Ok((path, meta)) | |
| 83 | 85 | } | |
| 84 | 86 | ||
| 87 | + | /// Resolve a repo for a *push*, creating it on the fly when it doesn't exist | |
| 88 | + | /// and the authenticated pusher owns the namespace (or is an admin) — see | |
| 89 | + | /// [`repos::create_on_push`]. An unauthenticated request for a missing repo | |
| 90 | + | /// gets the Basic challenge, so `git push` to a new name prompts for | |
| 91 | + | /// credentials instead of failing with 404. | |
| 92 | + | async fn load_repo_for_push( | |
| 93 | + | app: &App, | |
| 94 | + | headers: &HeaderMap, | |
| 95 | + | owner: &str, | |
| 96 | + | repo: &str, | |
| 97 | + | ) -> Result<(PathBuf, Repository), Response> { | |
| 98 | + | match load_repo(app, owner, repo).await { | |
| 99 | + | Err(resp) if resp.status() == StatusCode::NOT_FOUND => { | |
| 100 | + | let name = repo.strip_suffix(".git").unwrap_or(repo); | |
| 101 | + | let Some(user) = basic_user(app, headers).await else { | |
| 102 | + | return Err(auth_challenge()); | |
| 103 | + | }; | |
| 104 | + | match repos::create_on_push(&app.db, &app.config.repositories_dir(), owner, name, &user) | |
| 105 | + | .await | |
| 106 | + | { | |
| 107 | + | Ok(Some(meta)) => { | |
| 108 | + | let path = | |
| 109 | + | anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name); | |
| 110 | + | Ok((path, meta)) | |
| 111 | + | } | |
| 112 | + | Ok(None) => Err((StatusCode::NOT_FOUND, "repository not found").into_response()), | |
| 113 | + | Err(anvil_core::Error::Invalid(m)) => { | |
| 114 | + | Err((StatusCode::BAD_REQUEST, m).into_response()) | |
| 115 | + | } | |
| 116 | + | Err(e) => Err(internal(e)), | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | other => other, | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// The authenticated Basic user, if any. | |
| 124 | + | async fn basic_user(app: &App, headers: &HeaderMap) -> Option<anvil_core::User> { | |
| 125 | + | let authorization = headers | |
| 126 | + | .get(header::AUTHORIZATION) | |
| 127 | + | .and_then(|v| v.to_str().ok()); | |
| 128 | + | crate::auth::basic_auth_user(app, authorization).await | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// `401` with a `WWW-Authenticate` challenge so the git client prompts. | |
| 132 | + | fn auth_challenge() -> Response { | |
| 133 | + | Response::builder() | |
| 134 | + | .status(StatusCode::UNAUTHORIZED) | |
| 135 | + | .header(header::WWW_AUTHENTICATE, "Basic realm=\"anvil\"") | |
| 136 | + | .body(Body::from("authentication required")) | |
| 137 | + | .unwrap() | |
| 138 | + | } | |
| 139 | + | ||
| 85 | 140 | /// Enforce access for a git request: anonymous reads are allowed for public | |
| 86 | 141 | /// repos; private reads and all writes require valid Basic credentials. On | |
| 87 | 142 | /// failure, returns a `401` with a `WWW-Authenticate` challenge so the git | |
| ⋯ 4 unchanged lines | |||
| 92 | 147 | repo: &Repository, | |
| 93 | 148 | need_write: bool, | |
| 94 | 149 | ) -> Result<(), Response> { | |
| 95 | - | let authorization = headers | |
| 96 | - | .get(header::AUTHORIZATION) | |
| 97 | - | .and_then(|v| v.to_str().ok()); | |
| 98 | - | let user = crate::auth::basic_auth_user(app, authorization).await; | |
| 150 | + | let user = basic_user(app, headers).await; | |
| 99 | 151 | let allowed = if need_write { | |
| 100 | 152 | access::can_write(repo, user.as_ref()) | |
| 101 | 153 | } else { | |
| ⋯ 2 unchanged lines | |||
| 104 | 156 | if allowed { | |
| 105 | 157 | Ok(()) | |
| 106 | 158 | } else { | |
| 107 | - | Err(Response::builder() | |
| 108 | - | .status(StatusCode::UNAUTHORIZED) | |
| 109 | - | .header(header::WWW_AUTHENTICATE, "Basic realm=\"anvil\"") | |
| 110 | - | .body(Body::from("authentication required")) | |
| 111 | - | .unwrap()) | |
| 159 | + | Err(auth_challenge()) | |
| 112 | 160 | } | |
| 113 | 161 | } | |
| 114 | 162 | ||
| ⋯ 27 unchanged lines | |||
| 142 | 190 | Query(query): Query<HashMap<String, String>>, | |
| 143 | 191 | headers: HeaderMap, | |
| 144 | 192 | ) -> Response { | |
| 145 | - | let (path, meta) = match load_repo(&app, &owner, &repo).await { | |
| 146 | - | Ok(v) => v, | |
| 147 | - | Err(resp) => return resp, | |
| 148 | - | }; | |
| 149 | 193 | let Some(service) = query.get("service").and_then(|s| Service::from_query(s)) else { | |
| 150 | 194 | return (StatusCode::BAD_REQUEST, "missing or unsupported service").into_response(); | |
| 151 | 195 | }; | |
| 152 | 196 | let need_write = service == Service::ReceivePack; | |
| 197 | + | // A push may target a repo that doesn't exist yet (push-to-create). | |
| 198 | + | let loaded = if need_write { | |
| 199 | + | load_repo_for_push(&app, &headers, &owner, &repo).await | |
| 200 | + | } else { | |
| 201 | + | load_repo(&app, &owner, &repo).await | |
| 202 | + | }; | |
| 203 | + | let (path, meta) = match loaded { | |
| 204 | + | Ok(v) => v, | |
| 205 | + | Err(resp) => return resp, | |
| 206 | + | }; | |
| 153 | 207 | if let Err(resp) = authorize(&app, &headers, &meta, need_write).await { | |
| 154 | 208 | return resp; | |
| 155 | 209 | } | |
| ⋯ 49 unchanged lines | |||
| 205 | 259 | headers: HeaderMap, | |
| 206 | 260 | body: Bytes, | |
| 207 | 261 | ) -> Response { | |
| 208 | - | let (path, meta) = match load_repo(&app, &owner, &repo).await { | |
| 262 | + | let (path, meta) = match load_repo_for_push(&app, &headers, &owner, &repo).await { | |
| 209 | 263 | Ok(v) => v, | |
| 210 | 264 | Err(resp) => return resp, | |
| 211 | 265 | }; | |
| ⋯ 11 unchanged lines | |||
| 223 | 277 | { | |
| 224 | 278 | app.notify_ci(run_id); | |
| 225 | 279 | } | |
| 280 | + | if !meta.mirror_url.is_empty() { | |
| 281 | + | anvil_git::mirror::spawn_push(path.clone(), meta.mirror_url.clone()); | |
| 282 | + | } | |
| 226 | 283 | rpc_response(Service::ReceivePack.result_content_type(), Body::from(b)) | |
| 227 | 284 | } | |
| 228 | 285 | Err(e) => internal(e), | |
| ⋯ 2 unchanged lines | |||
addedcrates/anvil-web/src/issues.rs+422 −0
| 1 | + | //! Per-repository issue tracker UI: list, new-issue form, detail page with | |
| 2 | + | //! comments, and open/close. | |
| 3 | + | //! | |
| 4 | + | //! Access mirrors the rest of the forge: anyone who can read the repo can | |
| 5 | + | //! read its issues; any logged-in reader can open issues and comment; | |
| 6 | + | //! closing/reopening is for the issue author or anyone with write access. | |
| 7 | + | //! Bodies are markdown, rendered with the same pipeline as file views. | |
| 8 | + | ||
| 9 | + | use std::collections::HashMap; | |
| 10 | + | ||
| 11 | + | use anvil_core::{ | |
| 12 | + | App, | |
| 13 | + | Issue, | |
| 14 | + | User, | |
| 15 | + | access, | |
| 16 | + | issues, | |
| 17 | + | users, | |
| 18 | + | }; | |
| 19 | + | use axum::{ | |
| 20 | + | Form, | |
| 21 | + | Router, | |
| 22 | + | extract::{ | |
| 23 | + | Path, | |
| 24 | + | Query, | |
| 25 | + | State, | |
| 26 | + | }, | |
| 27 | + | response::{ | |
| 28 | + | IntoResponse, | |
| 29 | + | Redirect, | |
| 30 | + | Response, | |
| 31 | + | }, | |
| 32 | + | routing::get, | |
| 33 | + | }; | |
| 34 | + | use maud::{ | |
| 35 | + | Markup, | |
| 36 | + | html, | |
| 37 | + | }; | |
| 38 | + | use serde::Deserialize; | |
| 39 | + | ||
| 40 | + | use crate::{ | |
| 41 | + | auth::{ | |
| 42 | + | Csrf, | |
| 43 | + | CurrentUser, | |
| 44 | + | verify_csrf, | |
| 45 | + | }, | |
| 46 | + | ui::{ | |
| 47 | + | csrf_input, | |
| 48 | + | fmt_relative, | |
| 49 | + | fmt_time, | |
| 50 | + | layout, | |
| 51 | + | not_found, | |
| 52 | + | render_markdown, | |
| 53 | + | resolve_repo, | |
| 54 | + | server_error, | |
| 55 | + | }, | |
| 56 | + | }; | |
| 57 | + | ||
| 58 | + | /// Mount the issue routes. | |
| 59 | + | pub fn routes(router: Router<App>) -> Router<App> { | |
| 60 | + | router | |
| 61 | + | .route("/{owner}/{repo}/issues", get(list)) | |
| 62 | + | .route("/{owner}/{repo}/issues/new", get(new_form).post(new_submit)) | |
| 63 | + | .route("/{owner}/{repo}/issues/{number}", get(detail)) | |
| 64 | + | .route( | |
| 65 | + | "/{owner}/{repo}/issues/{number}/comment", | |
| 66 | + | axum::routing::post(comment_submit), | |
| 67 | + | ) | |
| 68 | + | .route( | |
| 69 | + | "/{owner}/{repo}/issues/{number}/state", | |
| 70 | + | axum::routing::post(state_submit), | |
| 71 | + | ) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | #[derive(Deserialize)] | |
| 75 | + | struct ListQuery { | |
| 76 | + | #[serde(default)] | |
| 77 | + | state: String, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// `GET /{owner}/{repo}/issues` — open issues, with a closed tab. | |
| 81 | + | async fn list( | |
| 82 | + | State(app): State<App>, | |
| 83 | + | CurrentUser(user): CurrentUser, | |
| 84 | + | Path((owner, repo)): Path<(String, String)>, | |
| 85 | + | Query(q): Query<ListQuery>, | |
| 86 | + | ) -> Result<Markup, Response> { | |
| 87 | + | let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 88 | + | let state = if q.state == issues::state::CLOSED { | |
| 89 | + | issues::state::CLOSED | |
| 90 | + | } else { | |
| 91 | + | issues::state::OPEN | |
| 92 | + | }; | |
| 93 | + | let items = issues::list(&app.db, meta.id, state) | |
| 94 | + | .await | |
| 95 | + | .map_err(server_error)?; | |
| 96 | + | let (open, closed) = issues::counts(&app.db, meta.id) | |
| 97 | + | .await | |
| 98 | + | .map_err(server_error)?; | |
| 99 | + | let names = usernames(&app, items.iter().map(|i| i.author_id)).await; | |
| 100 | + | ||
| 101 | + | Ok(layout( | |
| 102 | + | &format!("{owner}/{repo}: issues"), | |
| 103 | + | user.as_ref(), | |
| 104 | + | html! { | |
| 105 | + | div style="display:flex;align-items:center;gap:8px" { | |
| 106 | + | h1 style="margin-right:auto" { | |
| 107 | + | a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · issues" | |
| 108 | + | } | |
| 109 | + | @if user.is_some() { | |
| 110 | + | a.btn href=(format!("/{owner}/{repo}/issues/new")) { "New issue" } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | p { | |
| 114 | + | span.pill-group { | |
| 115 | + | a.pill.active[state == issues::state::OPEN] | |
| 116 | + | href=(format!("/{owner}/{repo}/issues")) { (open) " open" } | |
| 117 | + | a.pill.active[state == issues::state::CLOSED] | |
| 118 | + | href=(format!("/{owner}/{repo}/issues?state=closed")) { (closed) " closed" } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | @if items.is_empty() { | |
| 122 | + | p.muted { | |
| 123 | + | @if state == issues::state::OPEN { "No open issues." } | |
| 124 | + | @else { "No closed issues." } | |
| 125 | + | } | |
| 126 | + | } @else { | |
| 127 | + | div.box { | |
| 128 | + | @for issue in &items { | |
| 129 | + | div.row { | |
| 130 | + | a.entry href=(format!("/{owner}/{repo}/issues/{}", issue.number)) { | |
| 131 | + | (state_dot(&issue.state)) | |
| 132 | + | (issue.title) | |
| 133 | + | } | |
| 134 | + | span.muted style="white-space:nowrap" { | |
| 135 | + | "#" (issue.number) | |
| 136 | + | " · " (names.get(&issue.author_id).map(String::as_str).unwrap_or("?")) | |
| 137 | + | " · " span title=(fmt_time(issue.updated_at)) { (fmt_relative(issue.updated_at)) } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | }, | |
| 144 | + | )) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// `GET /{owner}/{repo}/issues/new` — the new-issue form (login required). | |
| 148 | + | async fn new_form( | |
| 149 | + | State(app): State<App>, | |
| 150 | + | CurrentUser(user): CurrentUser, | |
| 151 | + | csrf: Csrf, | |
| 152 | + | Path((owner, repo)): Path<(String, String)>, | |
| 153 | + | ) -> Result<Markup, Response> { | |
| 154 | + | resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 155 | + | let Some(user) = user else { | |
| 156 | + | return Err(Redirect::to("/-/login").into_response()); | |
| 157 | + | }; | |
| 158 | + | Ok(new_issue_page(&owner, &repo, Some(&user), None, &csrf.0)) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[derive(Deserialize)] | |
| 162 | + | struct NewIssueForm { | |
| 163 | + | #[serde(default)] | |
| 164 | + | title: String, | |
| 165 | + | #[serde(default)] | |
| 166 | + | body: String, | |
| 167 | + | #[serde(default)] | |
| 168 | + | csrf: String, | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// `POST /{owner}/{repo}/issues/new` | |
| 172 | + | async fn new_submit( | |
| 173 | + | State(app): State<App>, | |
| 174 | + | CurrentUser(user): CurrentUser, | |
| 175 | + | csrf: Csrf, | |
| 176 | + | Path((owner, repo)): Path<(String, String)>, | |
| 177 | + | Form(form): Form<NewIssueForm>, | |
| 178 | + | ) -> Response { | |
| 179 | + | let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await { | |
| 180 | + | Ok(v) => v, | |
| 181 | + | Err(resp) => return resp, | |
| 182 | + | }; | |
| 183 | + | let Some(user) = user else { | |
| 184 | + | return Redirect::to("/-/login").into_response(); | |
| 185 | + | }; | |
| 186 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 187 | + | return resp; | |
| 188 | + | } | |
| 189 | + | match issues::create(&app.db, meta.id, user.id, &form.title, &form.body).await { | |
| 190 | + | Ok(issue) => { | |
| 191 | + | Redirect::to(&format!("/{owner}/{repo}/issues/{}", issue.number)).into_response() | |
| 192 | + | } | |
| 193 | + | Err(anvil_core::Error::Invalid(m)) => { | |
| 194 | + | new_issue_page(&owner, &repo, Some(&user), Some(&m), &csrf.0).into_response() | |
| 195 | + | } | |
| 196 | + | Err(e) => server_error(e), | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | fn new_issue_page( | |
| 201 | + | owner: &str, | |
| 202 | + | repo: &str, | |
| 203 | + | user: Option<&User>, | |
| 204 | + | error: Option<&str>, | |
| 205 | + | csrf: &str, | |
| 206 | + | ) -> Markup { | |
| 207 | + | layout( | |
| 208 | + | &format!("{owner}/{repo}: new issue"), | |
| 209 | + | user, | |
| 210 | + | html! { | |
| 211 | + | h1 { | |
| 212 | + | a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } | |
| 213 | + | " · " a href=(format!("/{owner}/{repo}/issues")) { "issues" } | |
| 214 | + | " · new" | |
| 215 | + | } | |
| 216 | + | @if let Some(error) = error { p style="color:#cf222e" { (error) } } | |
| 217 | + | form.stack method="post" action=(format!("/{owner}/{repo}/issues/new")) { | |
| 218 | + | (csrf_input(csrf)) | |
| 219 | + | p { label { "Title" br; input type="text" name="title" required; } } | |
| 220 | + | p { label { "Description (markdown)" br; textarea name="body" rows="8" {} } } | |
| 221 | + | p { button.btn type="submit" { "Open issue" } } | |
| 222 | + | } | |
| 223 | + | }, | |
| 224 | + | ) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// `GET /{owner}/{repo}/issues/{number}` — one issue with its comments. | |
| 228 | + | async fn detail( | |
| 229 | + | State(app): State<App>, | |
| 230 | + | CurrentUser(user): CurrentUser, | |
| 231 | + | csrf: Csrf, | |
| 232 | + | Path((owner, repo, number)): Path<(String, String, i64)>, | |
| 233 | + | ) -> Result<Markup, Response> { | |
| 234 | + | let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 235 | + | let issue = issues::find(&app.db, meta.id, number) | |
| 236 | + | .await | |
| 237 | + | .map_err(server_error)? | |
| 238 | + | .ok_or_else(|| not_found("no such issue"))?; | |
| 239 | + | let comments = issues::comments(&app.db, issue.id) | |
| 240 | + | .await | |
| 241 | + | .map_err(server_error)?; | |
| 242 | + | let names = usernames( | |
| 243 | + | &app, | |
| 244 | + | std::iter::once(issue.author_id).chain(comments.iter().map(|c| c.author_id)), | |
| 245 | + | ) | |
| 246 | + | .await; | |
| 247 | + | let name = |id: &i64| names.get(id).map(String::as_str).unwrap_or("?").to_string(); | |
| 248 | + | let may_toggle = user | |
| 249 | + | .as_ref() | |
| 250 | + | .is_some_and(|u| u.id == issue.author_id || access::can_write(&meta, Some(u))); | |
| 251 | + | let open = issue.state == issues::state::OPEN; | |
| 252 | + | ||
| 253 | + | Ok(layout( | |
| 254 | + | &format!("{owner}/{repo}: {} (#{})", issue.title, issue.number), | |
| 255 | + | user.as_ref(), | |
| 256 | + | html! { | |
| 257 | + | h1 { | |
| 258 | + | a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } | |
| 259 | + | " · " a href=(format!("/{owner}/{repo}/issues")) { "issues" } | |
| 260 | + | } | |
| 261 | + | h2 style="font-size:18px;margin:12px 0 4px" { (issue.title) " " span.muted { "#" (issue.number) } } | |
| 262 | + | p { | |
| 263 | + | (state_badge(&issue.state)) | |
| 264 | + | " " span.muted { | |
| 265 | + | (name(&issue.author_id)) " opened " | |
| 266 | + | span title=(fmt_time(issue.created_at)) { (fmt_relative(issue.created_at)) } | |
| 267 | + | " · " (comments.len()) " comment" @if comments.len() != 1 { "s" } | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | div.box.issue-post { | |
| 271 | + | div.issue-head { | |
| 272 | + | (name(&issue.author_id)) | |
| 273 | + | " · " span title=(fmt_time(issue.created_at)) { (fmt_relative(issue.created_at)) } | |
| 274 | + | } | |
| 275 | + | div.md-body { | |
| 276 | + | @if issue.body.is_empty() { p.muted { "No description." } } | |
| 277 | + | @else { (render_markdown(&issue.body)) } | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | @for c in &comments { | |
| 281 | + | div.box.issue-post { | |
| 282 | + | div.issue-head { | |
| 283 | + | (name(&c.author_id)) | |
| 284 | + | " · " span title=(fmt_time(c.created_at)) { (fmt_relative(c.created_at)) } | |
| 285 | + | } | |
| 286 | + | div.md-body { (render_markdown(&c.body)) } | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | @if user.is_some() { | |
| 290 | + | form.stack method="post" action=(format!("/{owner}/{repo}/issues/{}/comment", issue.number)) { | |
| 291 | + | (csrf_input(&csrf.0)) | |
| 292 | + | p { label { "Comment (markdown)" br; textarea name="body" rows="4" required {} } } | |
| 293 | + | p { | |
| 294 | + | button.btn type="submit" { "Comment" } | |
| 295 | + | @if may_toggle { | |
| 296 | + | " " | |
| 297 | + | button.btn.btn-secondary type="submit" | |
| 298 | + | formaction=(format!("/{owner}/{repo}/issues/{}/state", issue.number)) | |
| 299 | + | formnovalidate | |
| 300 | + | name="state" value=(if open { "closed" } else { "open" }) { | |
| 301 | + | @if open { "Close issue" } @else { "Reopen issue" } | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } @else { | |
| 307 | + | p.muted { a href="/-/login" { "Log in" } " to comment." } | |
| 308 | + | } | |
| 309 | + | }, | |
| 310 | + | )) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | #[derive(Deserialize)] | |
| 314 | + | struct CommentForm { | |
| 315 | + | #[serde(default)] | |
| 316 | + | body: String, | |
| 317 | + | #[serde(default)] | |
| 318 | + | csrf: String, | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /// `POST /{owner}/{repo}/issues/{number}/comment` | |
| 322 | + | async fn comment_submit( | |
| 323 | + | State(app): State<App>, | |
| 324 | + | CurrentUser(user): CurrentUser, | |
| 325 | + | csrf: Csrf, | |
| 326 | + | Path((owner, repo, number)): Path<(String, String, i64)>, | |
| 327 | + | Form(form): Form<CommentForm>, | |
| 328 | + | ) -> Response { | |
| 329 | + | let (meta, mut issue, user) = match load_for_update(&app, user, &owner, &repo, number).await { | |
| 330 | + | Ok(v) => v, | |
| 331 | + | Err(resp) => return resp, | |
| 332 | + | }; | |
| 333 | + | let _ = meta; | |
| 334 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 335 | + | return resp; | |
| 336 | + | } | |
| 337 | + | match issues::comment(&app.db, &mut issue, user.id, &form.body).await { | |
| 338 | + | Ok(_) | Err(anvil_core::Error::Invalid(_)) => { | |
| 339 | + | Redirect::to(&format!("/{owner}/{repo}/issues/{number}")).into_response() | |
| 340 | + | } | |
| 341 | + | Err(e) => server_error(e), | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[derive(Deserialize)] | |
| 346 | + | struct StateForm { | |
| 347 | + | #[serde(default)] | |
| 348 | + | state: String, | |
| 349 | + | #[serde(default)] | |
| 350 | + | csrf: String, | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /// `POST /{owner}/{repo}/issues/{number}/state` — close or reopen. | |
| 354 | + | async fn state_submit( | |
| 355 | + | State(app): State<App>, | |
| 356 | + | CurrentUser(user): CurrentUser, | |
| 357 | + | csrf: Csrf, | |
| 358 | + | Path((owner, repo, number)): Path<(String, String, i64)>, | |
| 359 | + | Form(form): Form<StateForm>, | |
| 360 | + | ) -> Response { | |
| 361 | + | let (meta, mut issue, user) = match load_for_update(&app, user, &owner, &repo, number).await { | |
| 362 | + | Ok(v) => v, | |
| 363 | + | Err(resp) => return resp, | |
| 364 | + | }; | |
| 365 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 366 | + | return resp; | |
| 367 | + | } | |
| 368 | + | let may_toggle = user.id == issue.author_id || access::can_write(&meta, Some(&user)); | |
| 369 | + | if !may_toggle { | |
| 370 | + | return not_found("no such issue"); | |
| 371 | + | } | |
| 372 | + | let new_state = if form.state == issues::state::CLOSED { | |
| 373 | + | issues::state::CLOSED | |
| 374 | + | } else { | |
| 375 | + | issues::state::OPEN | |
| 376 | + | }; | |
| 377 | + | match issues::set_state(&app.db, &mut issue, new_state).await { | |
| 378 | + | Ok(()) => Redirect::to(&format!("/{owner}/{repo}/issues/{number}")).into_response(), | |
| 379 | + | Err(e) => server_error(e), | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | /// Shared resolve for the mutating endpoints: readable repo, existing issue, | |
| 384 | + | /// logged-in user. | |
| 385 | + | async fn load_for_update( | |
| 386 | + | app: &App, | |
| 387 | + | user: Option<User>, | |
| 388 | + | owner: &str, | |
| 389 | + | repo: &str, | |
| 390 | + | number: i64, | |
| 391 | + | ) -> Result<(anvil_core::Repository, Issue, User), Response> { | |
| 392 | + | let (_, meta) = resolve_repo(app, user.as_ref(), owner, repo).await?; | |
| 393 | + | let issue = issues::find(&app.db, meta.id, number) | |
| 394 | + | .await | |
| 395 | + | .map_err(server_error)? | |
| 396 | + | .ok_or_else(|| not_found("no such issue"))?; | |
| 397 | + | let user = user.ok_or_else(|| Redirect::to("/-/login").into_response())?; | |
| 398 | + | Ok((meta, issue, user)) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | /// author_id → username for the ids in `ids`. | |
| 402 | + | async fn usernames(app: &App, ids: impl Iterator<Item = i64>) -> HashMap<i64, String> { | |
| 403 | + | let mut out = HashMap::new(); | |
| 404 | + | for id in ids { | |
| 405 | + | if let std::collections::hash_map::Entry::Vacant(entry) = out.entry(id) | |
| 406 | + | && let Ok(Some(u)) = users::find_by_id(&app.db, id).await | |
| 407 | + | { | |
| 408 | + | entry.insert(u.username); | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | out | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | /// Small open/closed indicator for list rows. | |
| 415 | + | fn state_dot(state: &str) -> Markup { | |
| 416 | + | html! { span class=(format!("issue-dot {state}")) {} } | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | /// Open/closed pill for the detail page. | |
| 420 | + | fn state_badge(state: &str) -> Markup { | |
| 421 | + | html! { span class=(format!("st issue-{state}")) { (state) } } | |
| 422 | + | } |
modifiedcrates/anvil-web/src/lib.rs+4 −0
| ⋯ 19 unchanged lines | |||
| 20 | 20 | }, | |
| 21 | 21 | }; | |
| 22 | 22 | ||
| 23 | + | pub mod artifacts; | |
| 23 | 24 | pub mod auth; | |
| 24 | 25 | pub mod git_http; | |
| 26 | + | pub mod issues; | |
| 25 | 27 | pub mod pages; | |
| 26 | 28 | pub mod ui; | |
| 27 | 29 | ||
| ⋯ 5 unchanged lines | |||
| 33 | 35 | .route("/-/logout", post(auth::logout)); | |
| 34 | 36 | router = ui::routes(router); // web UI, including `/` | |
| 35 | 37 | router = pages::routes(router); // static sites from `pages` branches | |
| 38 | + | router = artifacts::routes(router); // CI artifact downloads + sites | |
| 39 | + | router = issues::routes(router); // per-repo issue tracker | |
| 36 | 40 | router = git_http::routes(router); // smart-HTTP git endpoints | |
| 37 | 41 | router | |
| 38 | 42 | // Derives the per-request CSRF token so the layout can attach it to | |
| ⋯ 22 unchanged lines | |||
modifiedcrates/anvil-web/src/pages.rs+11 −8
| ⋯ 32 unchanged lines | |||
| 33 | 33 | html, | |
| 34 | 34 | }; | |
| 35 | 35 | ||
| 36 | - | use crate::auth::CurrentUser; | |
| 37 | - | use crate::ui::{ | |
| 38 | - | layout, | |
| 39 | - | not_found, | |
| 40 | - | resolve_repo, | |
| 41 | - | server_error, | |
| 36 | + | use crate::{ | |
| 37 | + | auth::CurrentUser, | |
| 38 | + | ui::{ | |
| 39 | + | entry_icon, | |
| 40 | + | layout, | |
| 41 | + | not_found, | |
| 42 | + | resolve_repo, | |
| 43 | + | server_error, | |
| 44 | + | }, | |
| 42 | 45 | }; | |
| 43 | 46 | ||
| 44 | 47 | /// The branch pages are served from. | |
| ⋯ 33 unchanged lines | |||
| 78 | 81 | @for e in &entries { | |
| 79 | 82 | div.row { | |
| 80 | 83 | a.entry href=(format!("/{owner}/{repo}/pages/{}{}", e.name, if e.is_dir { "/" } else { "" })) { | |
| 81 | - | span.icon { (if e.is_dir { "[ ]" } else { "·" }) } | |
| 84 | + | (entry_icon(e.is_dir)) | |
| 82 | 85 | (e.name) @if e.is_dir { "/" } | |
| 83 | 86 | } | |
| 84 | 87 | } | |
| ⋯ 41 unchanged lines | |||
| 126 | 129 | ||
| 127 | 130 | /// Raw file response with a guessed content type. `nosniff` keeps browsers | |
| 128 | 131 | /// from second-guessing it. | |
| 129 | - | fn file_response(path: &str, bytes: Vec<u8>) -> Response { | |
| 132 | + | pub(crate) fn file_response(path: &str, bytes: Vec<u8>) -> Response { | |
| 130 | 133 | ( | |
| 131 | 134 | [ | |
| 132 | 135 | (header::CONTENT_TYPE, content_type(path)), | |
| ⋯ 52 unchanged lines | |||
modifiedcrates/anvil-web/src/ui.rs+213 −40
| ⋯ 1 unchanged line | |||
| 2 | 2 | //! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based | |
| 3 | 3 | //! progressive enhancement is a follow-up. | |
| 4 | 4 | ||
| 5 | - | use std::collections::{ | |
| 6 | - | BTreeMap, | |
| 7 | - | HashMap, | |
| 8 | - | }; | |
| 9 | - | use std::path::PathBuf; | |
| 10 | - | use std::sync::{ | |
| 11 | - | Arc, | |
| 12 | - | Mutex, | |
| 13 | - | OnceLock, | |
| 5 | + | use std::{ | |
| 6 | + | collections::{ | |
| 7 | + | BTreeMap, | |
| 8 | + | HashMap, | |
| 9 | + | }, | |
| 10 | + | path::PathBuf, | |
| 11 | + | sync::{ | |
| 12 | + | Arc, | |
| 13 | + | Mutex, | |
| 14 | + | OnceLock, | |
| 15 | + | }, | |
| 14 | 16 | }; | |
| 15 | 17 | ||
| 16 | 18 | use anvil_core::{ | |
| ⋯ 45 unchanged lines | |||
| 62 | 64 | ChangeTag, | |
| 63 | 65 | TextDiff, | |
| 64 | 66 | }; | |
| 65 | - | use syntect::easy::HighlightLines; | |
| 66 | - | use syntect::highlighting::{ | |
| 67 | - | Theme, | |
| 68 | - | ThemeSet, | |
| 69 | - | }; | |
| 70 | - | use syntect::html::{ | |
| 71 | - | IncludeBackground, | |
| 72 | - | styled_line_to_highlighted_html, | |
| 67 | + | use syntect::{ | |
| 68 | + | easy::HighlightLines, | |
| 69 | + | highlighting::{ | |
| 70 | + | Theme, | |
| 71 | + | ThemeSet, | |
| 72 | + | }, | |
| 73 | + | html::{ | |
| 74 | + | IncludeBackground, | |
| 75 | + | styled_line_to_highlighted_html, | |
| 76 | + | }, | |
| 77 | + | parsing::SyntaxSet, | |
| 73 | 78 | }; | |
| 74 | - | use syntect::parsing::SyntaxSet; | |
| 75 | 79 | use time::OffsetDateTime; | |
| 76 | 80 | ||
| 77 | 81 | use crate::auth::{ | |
| ⋯ 72 unchanged lines | |||
| 150 | 154 | form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); } | |
| 151 | 155 | form.stack input[type=text], form.stack textarea { width:100%; max-width:480px; padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; } | |
| 152 | 156 | form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; } | |
| 157 | + | .issue-dot { width:10px; height:10px; border-radius:50%; flex:none; } | |
| 158 | + | .issue-dot.open { background:#1a7f37; } | |
| 159 | + | .issue-dot.closed { background:#8250df; } | |
| 160 | + | .st.issue-open { background:#dafbe1; color:#1a7f37; } | |
| 161 | + | .st.issue-closed { background:#fbefff; color:#8250df; } | |
| 162 | + | .issue-post { margin:12px 0; } | |
| 163 | + | .issue-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; color:var(--muted); } | |
| 164 | + | .btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); } | |
| 165 | + | .readme { margin-top:16px; } | |
| 166 | + | .readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; } | |
| 153 | 167 | .latest-commit { display:flex; gap:10px; align-items:baseline; background:var(--code-bg); border:1px solid var(--border); border-radius:6px 6px 0 0; border-bottom:0; padding:8px 16px; } | |
| 154 | 168 | .latest-commit + .box { border-radius:0 0 6px 6px; } | |
| 155 | 169 | .commit-list { list-style:none; padding:0; margin:0; } | |
| ⋯ 148 unchanged lines | |||
| 304 | 318 | } | |
| 305 | 319 | } } | |
| 306 | 320 | main { div.container { (body) } } | |
| 307 | - | footer { div.container { "anvil — a minimal git forge" } } | |
| 321 | + | footer { div.container { "anvil — a git forge" } } | |
| 308 | 322 | script src="/-/static/htmx.min.js" {} | |
| 309 | 323 | script { (PreEscaped(CLONE_JS)) } | |
| 310 | 324 | } | |
| ⋯ 300 unchanged lines | |||
| 611 | 625 | description: String, | |
| 612 | 626 | private: Option<String>, | |
| 613 | 627 | #[serde(default)] | |
| 628 | + | mirror_url: String, | |
| 629 | + | #[serde(default)] | |
| 614 | 630 | csrf: String, | |
| 615 | 631 | } | |
| 616 | 632 | ||
| ⋯ 124 unchanged lines | |||
| 741 | 757 | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 742 | 758 | return resp; | |
| 743 | 759 | } | |
| 744 | - | if let Err(e) = | |
| 745 | - | repos::update_settings(&app.db, meta.id, &form.description, form.private.is_some()).await | |
| 760 | + | if let Err(e) = repos::update_settings( | |
| 761 | + | &app.db, | |
| 762 | + | meta.id, | |
| 763 | + | &form.description, | |
| 764 | + | form.private.is_some(), | |
| 765 | + | &form.mirror_url, | |
| 766 | + | ) | |
| 767 | + | .await | |
| 746 | 768 | { | |
| 747 | 769 | return server_error(e); | |
| 748 | 770 | } | |
| ⋯ 18 unchanged lines | |||
| 767 | 789 | (csrf_input(csrf)) | |
| 768 | 790 | p { label { "Description" br; input type="text" name="description" value=(meta.description); } } | |
| 769 | 791 | p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } } | |
| 792 | + | p { | |
| 793 | + | label { | |
| 794 | + | "Mirror push URL" br; | |
| 795 | + | input type="text" name="mirror_url" value=(meta.mirror_url) | |
| 796 | + | placeholder="https://x-access-token:<token>@github.com/you/repo.git"; | |
| 797 | + | } | |
| 798 | + | br; | |
| 799 | + | span.muted style="font-size:12px" { | |
| 800 | + | "After every push here, all refs are mirrored to this remote (" | |
| 801 | + | code { "git push --mirror" } | |
| 802 | + | "). Stored as-is — use a scoped token. Empty disables it." | |
| 803 | + | } | |
| 804 | + | } | |
| 770 | 805 | p { button.btn type="submit" { "Save changes" } } | |
| 771 | 806 | } | |
| 772 | 807 | }, | |
| ⋯ 7 unchanged lines | |||
| 780 | 815 | .ssh | |
| 781 | 816 | .enabled | |
| 782 | 817 | .then(|| app.config.ssh_clone_url(owner, name)); | |
| 818 | + | // SSH first and preselected when available — it's the protocol that can | |
| 819 | + | // push without a credential prompt. | |
| 820 | + | let default_cmd = format!("git clone {}", ssh.as_deref().unwrap_or(&http)); | |
| 783 | 821 | html! { | |
| 784 | 822 | div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] { | |
| 785 | 823 | div.clone-head { | |
| 786 | 824 | span.muted { "Clone" } | |
| 787 | 825 | div.clone-tabs { | |
| 788 | - | button.clone-tab.active type="button" data-proto="http" { "HTTP" } | |
| 789 | 826 | @if ssh.is_some() { | |
| 790 | - | button.clone-tab type="button" data-proto="ssh" { "SSH" } | |
| 827 | + | button.clone-tab.active type="button" data-proto="ssh" { "SSH" } | |
| 828 | + | button.clone-tab type="button" data-proto="http" { "HTTP" } | |
| 829 | + | } @else { | |
| 830 | + | button.clone-tab.active type="button" data-proto="http" { "HTTP" } | |
| 791 | 831 | } | |
| 792 | 832 | } | |
| 793 | 833 | } | |
| 794 | 834 | div.clone-cmd { | |
| 795 | - | code { "git clone " (http) } | |
| 835 | + | code { (default_cmd) } | |
| 796 | 836 | button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" { | |
| 797 | 837 | (PreEscaped(CLIPBOARD_SVG)) | |
| 798 | 838 | } | |
| ⋯ 20 unchanged lines | |||
| 819 | 859 | @if meta.is_private { " " span.pill { "private" } } | |
| 820 | 860 | } | |
| 821 | 861 | span.repo-nav { | |
| 862 | + | a href=(format!("/{owner}/{repo}/issues")) { "Issues" } | |
| 863 | + | " · " | |
| 822 | 864 | a href=(format!("/{owner}/{repo}/ci")) { "CI" } | |
| 823 | 865 | " · " | |
| 824 | 866 | a href=(format!("/{owner}/{repo}/pages")) { "Pages" } | |
| ⋯ 36 unchanged lines | |||
| 861 | 903 | let entry_commits = | |
| 862 | 904 | browse::latest_entry_commits(&path, &rev, "", ENTRY_LOG_WALK).unwrap_or_default(); | |
| 863 | 905 | ||
| 906 | + | // A root README renders below the tree, GitHub-style. Best-effort: a | |
| 907 | + | // missing or unreadable file just omits the section. | |
| 908 | + | let readme = entries | |
| 909 | + | .iter() | |
| 910 | + | .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("readme.md")) | |
| 911 | + | .and_then(|e| { | |
| 912 | + | let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?; | |
| 913 | + | Some(( | |
| 914 | + | render_markdown(&String::from_utf8_lossy(&bytes)), | |
| 915 | + | e.name.clone(), | |
| 916 | + | )) | |
| 917 | + | }); | |
| 918 | + | ||
| 864 | 919 | Ok(layout( | |
| 865 | 920 | &format!("{owner}/{repo}"), | |
| 866 | 921 | user.as_ref(), | |
| ⋯ 8 unchanged lines | |||
| 875 | 930 | div.latest-commit { | |
| 876 | 931 | a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) } | |
| 877 | 932 | a href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.summary) } | |
| 878 | - | span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) } | |
| 933 | + | span.muted style="margin-left:auto" { | |
| 934 | + | (c.author) " · " | |
| 935 | + | span title=(fmt_time(c.time)) { (fmt_relative(c.time)) } | |
| 936 | + | } | |
| 879 | 937 | } | |
| 880 | 938 | } | |
| 881 | 939 | (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits)) | |
| 940 | + | @if let Some((rendered, name)) = &readme { | |
| 941 | + | div.box.readme { | |
| 942 | + | div.readme-head { | |
| 943 | + | a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) } | |
| 944 | + | } | |
| 945 | + | div.md-body { (rendered) } | |
| 946 | + | } | |
| 947 | + | } | |
| 882 | 948 | }, | |
| 883 | 949 | )) | |
| 884 | 950 | } | |
| ⋯ 115 unchanged lines | |||
| 1000 | 1066 | /// Repo content is untrusted, so this is a stored-XSS surface: raw HTML in the | |
| 1001 | 1067 | /// source is emitted as escaped literal text, and `javascript:`/`data:`-style | |
| 1002 | 1068 | /// link and image destinations are dropped. | |
| 1003 | - | fn render_markdown(text: &str) -> Markup { | |
| 1069 | + | pub(crate) fn render_markdown(text: &str) -> Markup { | |
| 1004 | 1070 | use pulldown_cmark::{ | |
| 1005 | 1071 | Event, | |
| 1006 | 1072 | Options, | |
| ⋯ 47 unchanged lines | |||
| 1054 | 1120 | /// beyond this many commits just lose the annotation. | |
| 1055 | 1121 | const ENTRY_LOG_WALK: usize = 400; | |
| 1056 | 1122 | ||
| 1123 | + | /// Folder or file icon for an entry row (tree listings, pages, artifacts). | |
| 1124 | + | pub(crate) fn entry_icon(is_dir: bool) -> Markup { | |
| 1125 | + | html! { | |
| 1126 | + | @if is_dir { | |
| 1127 | + | span.icon.dir { (PreEscaped(FOLDER_SVG)) } | |
| 1128 | + | } @else { | |
| 1129 | + | span.icon { (PreEscaped(FILE_SVG)) } | |
| 1130 | + | } | |
| 1131 | + | } | |
| 1132 | + | } | |
| 1133 | + | ||
| 1134 | + | /// Human-readable byte size (`482 B`, `1.2 KiB`, `34.0 MiB`). | |
| 1135 | + | pub(crate) fn fmt_size(bytes: i64) -> String { | |
| 1136 | + | let b = bytes.max(0) as f64; | |
| 1137 | + | match b { | |
| 1138 | + | b if b < 1024.0 => format!("{bytes} B"), | |
| 1139 | + | b if b < 1024.0 * 1024.0 => format!("{:.1} KiB", b / 1024.0), | |
| 1140 | + | b if b < 1024.0 * 1024.0 * 1024.0 => format!("{:.1} MiB", b / (1024.0 * 1024.0)), | |
| 1141 | + | b => format!("{:.1} GiB", b / (1024.0 * 1024.0 * 1024.0)), | |
| 1142 | + | } | |
| 1143 | + | } | |
| 1144 | + | ||
| 1057 | 1145 | /// Percent-encode a ref name for use as one path segment in a URL. Axum | |
| 1058 | 1146 | /// matches routes before decoding, so an encoded `/` keeps a branch like | |
| 1059 | 1147 | /// `feat/x` inside the single `{rev}` segment. | |
| 1060 | - | fn enc_ref(name: &str) -> String { | |
| 1148 | + | pub(crate) fn enc_ref(name: &str) -> String { | |
| 1061 | 1149 | name.replace('%', "%25") | |
| 1062 | 1150 | .replace('/', "%2F") | |
| 1063 | 1151 | .replace('?', "%3F") | |
| ⋯ 54 unchanged lines | |||
| 1118 | 1206 | @let kind = if e.is_dir { "tree" } else { "blob" }; | |
| 1119 | 1207 | div.row { | |
| 1120 | 1208 | a.entry href=(format!("/{owner}/{repo}/{kind}/{}/{child}", enc_ref(rev))) { | |
| 1121 | - | @if e.is_dir { | |
| 1122 | - | span.icon.dir { (PreEscaped(FOLDER_SVG)) } | |
| 1123 | - | } @else { | |
| 1124 | - | span.icon { (PreEscaped(FILE_SVG)) } | |
| 1125 | - | } | |
| 1209 | + | (entry_icon(e.is_dir)) | |
| 1126 | 1210 | (e.name) @if e.is_dir { "/" } | |
| 1127 | 1211 | } | |
| 1128 | 1212 | @if let Some(c) = latest.get(&e.name) { | |
| 1129 | 1213 | a.fc-msg href=(format!("/{owner}/{repo}/commit/{}", c.id)) title=(c.summary) { (c.summary) } | |
| 1130 | - | span.fc-time { (fmt_date(c.time)) } | |
| 1214 | + | span.fc-time title=(fmt_time(c.time)) { (fmt_relative(c.time)) } | |
| 1131 | 1215 | } | |
| 1132 | 1216 | } | |
| 1133 | 1217 | } | |
| ⋯ 70 unchanged lines | |||
| 1204 | 1288 | a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) } | |
| 1205 | 1289 | } | |
| 1206 | 1290 | span { (c.summary) } | |
| 1207 | - | span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) } | |
| 1291 | + | span.muted style="margin-left:auto" { | |
| 1292 | + | (c.author) " · " | |
| 1293 | + | span title=(fmt_time(c.time)) { (fmt_relative(c.time)) } | |
| 1294 | + | } | |
| 1208 | 1295 | } | |
| 1209 | 1296 | } | |
| 1210 | 1297 | } | |
| ⋯ 84 unchanged lines | |||
| 1295 | 1382 | .map_err(server_error)? | |
| 1296 | 1383 | .filter(|r| r.repo_id == meta.id) | |
| 1297 | 1384 | .ok_or_else(|| not_found("no such CI run"))?; | |
| 1385 | + | let artifacts = ci::artifacts_for_run(&app.db, run.id) | |
| 1386 | + | .await | |
| 1387 | + | .map_err(server_error)?; | |
| 1298 | 1388 | Ok(layout( | |
| 1299 | 1389 | &format!("{owner}/{repo}: CI #{}", run.id), | |
| 1300 | 1390 | user.as_ref(), | |
| ⋯ 15 unchanged lines | |||
| 1316 | 1406 | @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) } | |
| 1317 | 1407 | @if let Some(d) = run_duration(&run) { " · took " (d) } | |
| 1318 | 1408 | } | |
| 1409 | + | @if !artifacts.is_empty() { | |
| 1410 | + | h2 { "Artifacts" } | |
| 1411 | + | div.box { | |
| 1412 | + | @for a in &artifacts { | |
| 1413 | + | div.row { | |
| 1414 | + | a.entry href=(format!("/{owner}/{repo}/ci/{}/artifacts/{}", run.id, a.name)) { | |
| 1415 | + | (entry_icon(a.is_dir)) | |
| 1416 | + | (a.name) | |
| 1417 | + | @if a.browse { " " span.pill { "site" } } | |
| 1418 | + | @else if a.is_dir { ".tar.gz" } | |
| 1419 | + | } | |
| 1420 | + | span.muted { | |
| 1421 | + | (artifact_meta_chips(&a.meta)) | |
| 1422 | + | (fmt_size(a.size)) | |
| 1423 | + | } | |
| 1424 | + | } | |
| 1425 | + | } | |
| 1426 | + | } | |
| 1427 | + | } | |
| 1319 | 1428 | @if run.log.is_empty() { | |
| 1320 | 1429 | p.muted { "No output yet." } | |
| 1321 | 1430 | } @else { | |
| ⋯ 3 unchanged lines | |||
| 1325 | 1434 | )) | |
| 1326 | 1435 | } | |
| 1327 | 1436 | ||
| 1437 | + | /// Render an artifact's extractor metadata (a JSON object of key → value) as | |
| 1438 | + | /// inline `key: value` chips before the size. | |
| 1439 | + | fn artifact_meta_chips(meta: &str) -> Markup { | |
| 1440 | + | let map: BTreeMap<String, String> = serde_json::from_str(meta).unwrap_or_default(); | |
| 1441 | + | html! { | |
| 1442 | + | @for (k, v) in &map { | |
| 1443 | + | span.pill title=(k) { (k) ": " (v) } | |
| 1444 | + | " " | |
| 1445 | + | } | |
| 1446 | + | } | |
| 1447 | + | } | |
| 1448 | + | ||
| 1328 | 1449 | /// A coloured status pill for a CI run status string. | |
| 1329 | 1450 | fn status_badge(status: &str) -> Markup { | |
| 1330 | 1451 | html! { span class=(format!("st {status}")) { (status) } } | |
| ⋯ 228 unchanged lines | |||
| 1559 | 1680 | } | |
| 1560 | 1681 | ||
| 1561 | 1682 | /// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`. | |
| 1562 | - | fn fmt_time(secs: i64) -> String { | |
| 1683 | + | pub(crate) fn fmt_time(secs: i64) -> String { | |
| 1563 | 1684 | match OffsetDateTime::from_unix_timestamp(secs) { | |
| 1564 | 1685 | Ok(t) => format!( | |
| 1565 | 1686 | "{:04}-{:02}-{:02} {:02}:{:02} UTC", | |
| ⋯ 7 unchanged lines | |||
| 1573 | 1694 | } | |
| 1574 | 1695 | } | |
| 1575 | 1696 | ||
| 1576 | - | /// Format a Unix timestamp as a bare `YYYY-MM-DD` (for compact tree rows). | |
| 1577 | - | fn fmt_date(secs: i64) -> String { | |
| 1578 | - | match OffsetDateTime::from_unix_timestamp(secs) { | |
| 1579 | - | Ok(t) => format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()), | |
| 1580 | - | Err(_) => secs.to_string(), | |
| 1697 | + | /// Format a Unix timestamp relative to now (`2 hours ago`, `last month`). | |
| 1698 | + | pub(crate) fn fmt_relative(secs: i64) -> String { | |
| 1699 | + | relative_to(secs, OffsetDateTime::now_utc().unix_timestamp()) | |
| 1700 | + | } | |
| 1701 | + | ||
| 1702 | + | fn relative_to(secs: i64, now: i64) -> String { | |
| 1703 | + | fn ago(n: i64, one: &str, unit: &str) -> String { | |
| 1704 | + | if n == 1 { | |
| 1705 | + | one.to_string() | |
| 1706 | + | } else { | |
| 1707 | + | format!("{n} {unit}s ago") | |
| 1708 | + | } | |
| 1709 | + | } | |
| 1710 | + | let delta = now - secs; | |
| 1711 | + | if delta < 60 { | |
| 1712 | + | return "just now".to_string(); | |
| 1713 | + | } | |
| 1714 | + | let minutes = delta / 60; | |
| 1715 | + | if minutes < 60 { | |
| 1716 | + | return ago(minutes, "1 minute ago", "minute"); | |
| 1581 | 1717 | } | |
| 1718 | + | let hours = delta / 3600; | |
| 1719 | + | if hours < 24 { | |
| 1720 | + | return ago(hours, "1 hour ago", "hour"); | |
| 1721 | + | } | |
| 1722 | + | let days = delta / 86_400; | |
| 1723 | + | if days < 7 { | |
| 1724 | + | return ago(days, "yesterday", "day"); | |
| 1725 | + | } | |
| 1726 | + | let weeks = days / 7; | |
| 1727 | + | if weeks < 5 { | |
| 1728 | + | return ago(weeks, "last week", "week"); | |
| 1729 | + | } | |
| 1730 | + | let months = days / 30; | |
| 1731 | + | if months < 12 { | |
| 1732 | + | return ago(months, "last month", "month"); | |
| 1733 | + | } | |
| 1734 | + | ago(days / 365, "last year", "year") | |
| 1582 | 1735 | } | |
| 1583 | 1736 | ||
| 1584 | 1737 | /// Heuristic: treat content with a NUL in the first 8 KiB as binary. | |
| ⋯ 33 unchanged lines | |||
| 1618 | 1771 | "normal links survive: {out}" | |
| 1619 | 1772 | ); | |
| 1620 | 1773 | } | |
| 1774 | + | ||
| 1775 | + | #[test] | |
| 1776 | + | fn relative_time_buckets() { | |
| 1777 | + | const NOW: i64 = 1_000_000_000; | |
| 1778 | + | let at = |delta: i64| relative_to(NOW - delta, NOW); | |
| 1779 | + | assert_eq!(at(0), "just now"); | |
| 1780 | + | assert_eq!(at(59), "just now"); | |
| 1781 | + | assert_eq!(at(60), "1 minute ago"); | |
| 1782 | + | assert_eq!(at(45 * 60), "45 minutes ago"); | |
| 1783 | + | assert_eq!(at(3600), "1 hour ago"); | |
| 1784 | + | assert_eq!(at(23 * 3600), "23 hours ago"); | |
| 1785 | + | assert_eq!(at(86_400), "yesterday"); | |
| 1786 | + | assert_eq!(at(3 * 86_400), "3 days ago"); | |
| 1787 | + | assert_eq!(at(8 * 86_400), "last week"); | |
| 1788 | + | assert_eq!(at(20 * 86_400), "2 weeks ago"); | |
| 1789 | + | assert_eq!(at(40 * 86_400), "last month"); | |
| 1790 | + | assert_eq!(at(200 * 86_400), "6 months ago"); | |
| 1791 | + | assert_eq!(at(400 * 86_400), "last year"); | |
| 1792 | + | assert_eq!(at(900 * 86_400), "2 years ago"); | |
| 1793 | + | } | |
| 1621 | 1794 | } | |
modifieddocs/ci-artifacts.md+10 −7
| 1 | 1 | # CI artifacts — design | |
| 2 | 2 | ||
| 3 | - | Status: **design sketch, not implemented.** Companion to the CI runner in | |
| 3 | + | Status: **implemented** (2026-06-10). Companion to the CI runner in | |
| 4 | 4 | `crates/anvil-ci` and the threat model in `docs/untrusted-mode.md`. | |
| 5 | + | Deviations from the original sketch are noted inline. | |
| 5 | 6 | ||
| 6 | 7 | ## Goals | |
| 7 | 8 | ||
| ⋯ 63 unchanged lines | |||
| 71 | 72 | (1 KiB); the resulting key→value map is stored as JSON on the artifact row. | |
| 72 | 73 | A failed extractor stores nothing for that key and appends a note to the log. | |
| 73 | 74 | ||
| 74 | - | Implementation note: the extractor output travels in a well-known file the | |
| 75 | - | broker downloads (e.g. `/workspace/.anvil-meta.json`, written by a generated | |
| 76 | - | trailer in the script), so it rides the same archive mechanism as artifacts | |
| 77 | - | and needs no log parsing. | |
| 75 | + | Implementation note: each extractor's stdout lands in its own file under | |
| 76 | + | `/tmp/anvil-meta/<artifact>/<key>` (written by a generated script trailer that | |
| 77 | + | runs even when a step fails — the steps execute in a subshell whose exit code | |
| 78 | + | is preserved). The broker downloads that directory via the same archive | |
| 79 | + | mechanism as artifacts; file-per-value avoids shell JSON-escaping entirely. | |
| 78 | 80 | ||
| 79 | 81 | ## Storage | |
| 80 | 82 | ||
| ⋯ 54 unchanged lines | |||
| 135 | 137 | branch head, which are pinned. No background sweeper, no clocks to test; the | |
| 136 | 138 | invariant holds whenever an artifact lands. | |
| 137 | 139 | ||
| 138 | - | Orphan cleanup (repo deleted → remove `artifacts/{repo_id}`) hooks into repo | |
| 139 | - | deletion alongside the existing git-dir removal. | |
| 140 | + | Orphan cleanup (repo deleted → remove `artifacts/{repo_id}`) will hook into | |
| 141 | + | repo deletion **when that feature exists** — anvil currently has no way to | |
| 142 | + | delete a repository at all, so there is nothing to hook yet. | |
| 140 | 143 | ||
| 141 | 144 | ## Out of scope (deliberately) | |
| 142 | 145 | ||
| ⋯ 16 unchanged lines | |||