max / makenotwork
13 files changed,
+692 insertions,
-0 deletions
| @@ -282,9 +282,91 @@ | |||
| 282 | 282 | let ctx = ctx.clone(); | |
| 283 | 283 | tokio::spawn(async move { | |
| 284 | 284 | run_build(&ctx, &build, &config).await; | |
| 285 | + | // After the run rather than inside it, and from a re-read row rather | |
| 286 | + | // than the one above: `run_build` has several exits and the note has to | |
| 287 | + | // say what the build finally was, whichever one it took. A path added | |
| 288 | + | // later is covered without anybody remembering to cover it. | |
| 289 | + | annotate_build(&ctx, build.id, &config).await; | |
| 285 | 290 | }); | |
| 286 | 291 | } | |
| 287 | 292 | ||
| 293 | + | /// Mirror a finished build's outcome into `refs/notes/mnw/builds`. | |
| 294 | + | /// | |
| 295 | + | /// Every failure here is a warning and nothing more. The build result is in | |
| 296 | + | /// Postgres and served from there; the note is a copy of it that the creator | |
| 297 | + | /// keeps when they leave. | |
| 298 | + | async fn annotate_build(ctx: &BuildCtx, build_id: db::BuildId, config: &DbBuildConfig) { | |
| 299 | + | let build = match db::builds::get_build(&ctx.db, build_id).await { | |
| 300 | + | Ok(Some(b)) => b, | |
| 301 | + | Ok(None) => return, | |
| 302 | + | Err(e) => { | |
| 303 | + | tracing::warn!(build_id = %build_id, error = ?e, "could not re-read build to annotate it"); | |
| 304 | + | return; | |
| 305 | + | } | |
| 306 | + | }; | |
| 307 | + | ||
| 308 | + | let Some((owner, repo_name, repo_id)) = repo_for_build(ctx, config.repo_id).await else { | |
| 309 | + | return; | |
| 310 | + | }; | |
| 311 | + | ||
| 312 | + | // The tag is what triggered the build; the note goes on the commit it names, | |
| 313 | + | // which is the page somebody actually opens. Peeling also covers an | |
| 314 | + | // annotated tag, where the ref points at a tag object rather than a commit. | |
| 315 | + | let Some(target) = crate::routes::git::notes_server::resolve_tag_commit( | |
| 316 | + | &ctx.config, | |
| 317 | + | &owner, | |
| 318 | + | &repo_name, | |
| 319 | + | &build.tag, | |
| 320 | + | ) | |
| 321 | + | .await | |
| 322 | + | else { | |
| 323 | + | tracing::warn!( | |
| 324 | + | build_id = %build_id, tag = %build.tag, | |
| 325 | + | "build tag does not resolve to a commit; no note written" | |
| 326 | + | ); | |
| 327 | + | return; | |
| 328 | + | }; | |
| 329 | + | ||
| 330 | + | crate::routes::git::notes_server::note_build( | |
| 331 | + | &crate::routes::git::notes_server::OwnedRepo { | |
| 332 | + | db: &ctx.db, | |
| 333 | + | config: &ctx.config, | |
| 334 | + | id: repo_id, | |
| 335 | + | owner: &owner, | |
| 336 | + | name: &repo_name, | |
| 337 | + | }, | |
| 338 | + | target, | |
| 339 | + | &build, | |
| 340 | + | &config.targets, | |
| 341 | + | ) | |
| 342 | + | .await; | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// Resolve a build config's repo to the `(owner, name, id)` the notes writer | |
| 346 | + | /// needs. | |
| 347 | + | async fn repo_for_build( | |
| 348 | + | ctx: &BuildCtx, | |
| 349 | + | repo_id: db::GitRepoId, | |
| 350 | + | ) -> Option<(String, String, db::GitRepoId)> { | |
| 351 | + | let repo = match db::git_repos::get_repo_by_id(&ctx.db, repo_id).await { | |
| 352 | + | Ok(Some(r)) => r, | |
| 353 | + | Ok(None) => return None, | |
| 354 | + | Err(e) => { | |
| 355 | + | tracing::warn!(error = ?e, "could not load the repo behind a build"); | |
| 356 | + | return None; | |
| 357 | + | } | |
| 358 | + | }; | |
| 359 | + | let owner = match db::users::get_user_by_id(&ctx.db, repo.user_id).await { | |
| 360 | + | Ok(Some(u)) => u, | |
| 361 | + | Ok(None) => return None, | |
| 362 | + | Err(e) => { | |
| 363 | + | tracing::warn!(error = ?e, "could not load the owner of a build's repo"); | |
| 364 | + | return None; | |
| 365 | + | } | |
| 366 | + | }; | |
| 367 | + | Some((owner.username.to_string(), repo.name.clone(), repo.id)) | |
| 368 | + | } | |
| 369 | + | ||
| 288 | 370 | fn build_failure_message(succeeded: usize, failed: usize, first_error: Option<&str>) -> String { | |
| 289 | 371 | if succeeded == 0 { | |
| 290 | 372 | first_error |
| @@ -593,6 +593,7 @@ | |||
| 593 | 593 | cdn_base_url: std::sync::Arc::from(state.config.cdn_base_url.as_str()), | |
| 594 | 594 | synckit_s3: state.storage.synckit_s3.clone(), | |
| 595 | 595 | public_s3: state.storage.public_s3.clone(), | |
| 596 | + | config: state.config.clone(), | |
| 596 | 597 | }); | |
| 597 | 598 | let worker_count = makenotwork::constants::SCAN_WORKER_COUNT; | |
| 598 | 599 | let worker_shutdown_rx = shutdown_tx.subscribe(); |
| @@ -7245,6 +7245,10 @@ | |||
| 7245 | 7245 | font-size: var(--text-fine); | |
| 7246 | 7246 | } | |
| 7247 | 7247 | .git-note-namespace { font-family: var(--font-mono); } | |
| 7248 | + | /* Sits beside the namespace rather than being spread across the header: the | |
| 7249 | + | auto margin absorbs the free space `space-between` would otherwise put | |
| 7250 | + | between the two, leaving the attribution on the right where it was. */ | |
| 7251 | + | .git-note-owned { margin-right: auto; opacity: 0.6; } | |
| 7248 | 7252 | .git-note-attribution { opacity: 0.6; } | |
| 7249 | 7253 | .git-note-namespaces { | |
| 7250 | 7254 | display: flex; |
| @@ -345,6 +345,24 @@ | |||
| 345 | 345 | ||
| 346 | 346 | /// Set the release ID on a build (after successful artifact upload). | |
| 347 | 347 | #[tracing::instrument(skip_all)] | |
| 348 | + | /// The build that produced a release, if one did. | |
| 349 | + | /// | |
| 350 | + | /// `None` for a release uploaded by hand rather than built by the pipeline, | |
| 351 | + | /// which is an ordinary case and not an error: there is no tag to annotate. | |
| 352 | + | pub(crate) async fn get_build_by_release( | |
| 353 | + | pool: &PgPool, | |
| 354 | + | release_id: OtaReleaseId, | |
| 355 | + | ) -> Result<Option<DbBuild>> { | |
| 356 | + | let build = sqlx::query_as::<_, DbBuild>( | |
| 357 | + | "SELECT * FROM ota_builds WHERE release_id = $1 ORDER BY created_at DESC LIMIT 1", | |
| 358 | + | ) | |
| 359 | + | .bind(release_id) | |
| 360 | + | .fetch_optional(pool) | |
| 361 | + | .await?; | |
| 362 | + | ||
| 363 | + | Ok(build) | |
| 364 | + | } | |
| 365 | + | ||
| 348 | 366 | pub(crate) async fn set_build_release( | |
| 349 | 367 | pool: &PgPool, | |
| 350 | 368 | build_id: BuildId, |
| @@ -243,6 +243,36 @@ | |||
| 243 | 243 | ||
| 244 | 244 | /// Get an artifact by release, target, and arch. | |
| 245 | 245 | #[tracing::instrument(skip_all)] | |
| 246 | + | /// One artifact by id. What the scan worker has after it finishes: it is told | |
| 247 | + | /// which artifact it scanned, not which release the artifact belongs to. | |
| 248 | + | pub(crate) async fn get_artifact_by_id( | |
| 249 | + | pool: &PgPool, | |
| 250 | + | artifact_id: OtaArtifactId, | |
| 251 | + | ) -> Result<Option<DbOtaArtifact>> { | |
| 252 | + | let artifact = sqlx::query_as::<_, DbOtaArtifact>("SELECT * FROM ota_artifacts WHERE id = $1") | |
| 253 | + | .bind(artifact_id) | |
| 254 | + | .fetch_optional(pool) | |
| 255 | + | .await?; | |
| 256 | + | ||
| 257 | + | Ok(artifact) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /// Every artifact in a release, ordered so a rendering of them is stable | |
| 261 | + | /// between two reads. | |
| 262 | + | pub(crate) async fn list_artifacts( | |
| 263 | + | pool: &PgPool, | |
| 264 | + | release_id: OtaReleaseId, | |
| 265 | + | ) -> Result<Vec<DbOtaArtifact>> { | |
| 266 | + | let artifacts = sqlx::query_as::<_, DbOtaArtifact>( | |
| 267 | + | "SELECT * FROM ota_artifacts WHERE release_id = $1 ORDER BY target, arch", | |
| 268 | + | ) | |
| 269 | + | .bind(release_id) | |
| 270 | + | .fetch_all(pool) | |
| 271 | + | .await?; | |
| 272 | + | ||
| 273 | + | Ok(artifacts) | |
| 274 | + | } | |
| 275 | + | ||
| 246 | 276 | pub(crate) async fn get_artifact( | |
| 247 | 277 | pool: &PgPool, | |
| 248 | 278 | release_id: OtaReleaseId, |
| @@ -108,6 +108,10 @@ | |||
| 108 | 108 | /// the public bucket. `None` when the public bucket isn't configured (image | |
| 109 | 109 | /// promotes then fail closed, leaving the entity on its unserved staging key). | |
| 110 | 110 | pub public_s3: Option<Arc<dyn StorageBackend>>, | |
| 111 | + | /// Carried for one reason: an OTA artifact's verdict is mirrored into | |
| 112 | + | /// `refs/notes/mnw/scan` on the commit its release was built from, and | |
| 113 | + | /// finding the repository on disk needs `build.git_repos_path`. | |
| 114 | + | pub config: crate::config::Config, | |
| 111 | 115 | } | |
| 112 | 116 | ||
| 113 | 117 | /// Decide the final status for a non-quarantined scan. | |
| @@ -310,6 +314,13 @@ | |||
| 310 | 314 | // row was stamped by the promote or the held-image branch above.) | |
| 311 | 315 | update_entity_status(&ctx.db, kind, target_id, entity_status).await?; | |
| 312 | 316 | ||
| 317 | + | // An OTA artifact's verdict belongs in the repository it was built from, not | |
| 318 | + | // only in a table. Runs after the stamp above so the release's artifacts are | |
| 319 | + | // read back with this one's verdict already in them. | |
| 320 | + | if kind == ScanTargetKind::OtaArtifact { | |
| 321 | + | annotate_release_scan(ctx, db::OtaArtifactId::from(target_id)).await; | |
| 322 | + | } | |
| 323 | + | ||
| 313 | 324 | // Verdict + duration metrics (Run 20 Observability): quarantine/hold/error | |
| 314 | 325 | // rates and scan latency are now graphable at /metrics. | |
| 315 | 326 | let verdict_label = match entity_status { | |
| @@ -693,6 +704,80 @@ | |||
| 693 | 704 | /// results, but there's no status to flip on those entities (the `ItemImage` | |
| 694 | 705 | /// cover shares the `items` row but must NOT flip `items.scan_status`, which | |
| 695 | 706 | /// gates the audio/video, not the cover). | |
| 707 | + | /// Mirror a release's scan verdicts into `refs/notes/mnw/scan`, once every | |
| 708 | + | /// artifact in it has one. | |
| 709 | + | /// | |
| 710 | + | /// Waiting for the last artifact is the point. A note saying one of three is | |
| 711 | + | /// clean is not a verdict, and writing it per artifact would leave two | |
| 712 | + | /// misleading intermediate states in the notes ref's history for anyone reading | |
| 713 | + | /// it later. Every early return here is an ordinary case: a release nobody | |
| 714 | + | /// built from a tag has no commit to annotate. | |
| 715 | + | async fn annotate_release_scan(ctx: &WorkerContext, artifact_id: db::OtaArtifactId) { | |
| 716 | + | let Ok(Some(artifact)) = db::ota::get_artifact_by_id(&ctx.db, artifact_id).await else { | |
| 717 | + | return; | |
| 718 | + | }; | |
| 719 | + | let Ok(artifacts) = db::ota::list_artifacts(&ctx.db, artifact.release_id).await else { | |
| 720 | + | return; | |
| 721 | + | }; | |
| 722 | + | if artifacts.is_empty() | |
| 723 | + | || artifacts.iter().any(|a| { | |
| 724 | + | matches!( | |
| 725 | + | a.scan_status, | |
| 726 | + | FileScanStatus::Pending | FileScanStatus::Scanning | |
| 727 | + | ) | |
| 728 | + | }) | |
| 729 | + | { | |
| 730 | + | return; | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | // The build is what ties a release to a tag, and so to a commit. A release | |
| 734 | + | // uploaded by hand has none. | |
| 735 | + | let Ok(Some(build)) = db::builds::get_build_by_release(&ctx.db, artifact.release_id).await | |
| 736 | + | else { | |
| 737 | + | return; | |
| 738 | + | }; | |
| 739 | + | let Ok(Some(config)) = db::builds::get_build_config_by_app(&ctx.db, build.app_id).await else { | |
| 740 | + | return; | |
| 741 | + | }; | |
| 742 | + | let Ok(Some(repo)) = db::git_repos::get_repo_by_id(&ctx.db, config.repo_id).await else { | |
| 743 | + | return; | |
| 744 | + | }; | |
| 745 | + | let Ok(Some(owner)) = db::users::get_user_by_id(&ctx.db, repo.user_id).await else { | |
| 746 | + | return; | |
| 747 | + | }; | |
| 748 | + | let owner = owner.username.to_string(); | |
| 749 | + | ||
| 750 | + | let Some(target) = crate::routes::git::notes_server::resolve_tag_commit( | |
| 751 | + | &ctx.config, | |
| 752 | + | &owner, | |
| 753 | + | &repo.name, | |
| 754 | + | &build.tag, | |
| 755 | + | ) | |
| 756 | + | .await | |
| 757 | + | else { | |
| 758 | + | return; | |
| 759 | + | }; | |
| 760 | + | ||
| 761 | + | let verdicts: Vec<(String, FileScanStatus)> = artifacts | |
| 762 | + | .iter() | |
| 763 | + | .map(|a| (format!("{}/{}", a.target, a.arch), a.scan_status)) | |
| 764 | + | .collect(); | |
| 765 | + | ||
| 766 | + | crate::routes::git::notes_server::note_scan( | |
| 767 | + | &crate::routes::git::notes_server::OwnedRepo { | |
| 768 | + | db: &ctx.db, | |
| 769 | + | config: &ctx.config, | |
| 770 | + | id: repo.id, | |
| 771 | + | owner: &owner, | |
| 772 | + | name: &repo.name, | |
| 773 | + | }, | |
| 774 | + | target, | |
| 775 | + | &build.version, | |
| 776 | + | &verdicts, | |
| 777 | + | ) | |
| 778 | + | .await; | |
| 779 | + | } | |
| 780 | + | ||
| 696 | 781 | async fn update_entity_status( | |
| 697 | 782 | db: &PgPool, | |
| 698 | 783 | kind: ScanTargetKind, |
| @@ -13,7 +13,11 @@ | |||
| 13 | 13 | </p> | |
| 14 | 14 | {% endif %} | |
| 15 | 15 | ||
| 16 | + | {# A note in a namespace the write routes refuse gets no form. makenot.work | |
| 17 | + | writes refs/notes/mnw/*, and offering an edit box that saves to a 422 is | |
| 18 | + | worse than offering nothing. #} | |
| 16 | 19 | {% for note in notes %} | |
| 20 | + | {% if !note.read_only %} | |
| 17 | 21 | <form class="git-note-form" method="post" action="/git/{{ owner }}/{{ repo_name }}/commit/{{ detail.oid }}/notes"> | |
| 18 | 22 | <input type="hidden" name="_csrf" value="{{ token }}"> | |
| 19 | 23 | <input type="hidden" name="namespace" value="{{ note.namespace }}"> | |
| @@ -28,6 +32,7 @@ | |||
| 28 | 32 | <input type="hidden" name="namespace" value="{{ note.namespace }}"> | |
| 29 | 33 | <button type="submit">Remove the note in {{ note.namespace }}</button> | |
| 30 | 34 | </form> | |
| 35 | + | {% endif %} | |
| 31 | 36 | {% endfor %} | |
| 32 | 37 | ||
| 33 | 38 | <form class="git-note-form" method="post" action="/git/{{ owner }}/{{ repo_name }}/commit/{{ detail.oid }}/notes"> |
| @@ -7,6 +7,11 @@ | |||
| 7 | 7 | <div class="git-note"> | |
| 8 | 8 | <div class="git-note-header"> | |
| 9 | 9 | <span class="git-note-namespace">{{ note.namespace }}</span> | |
| 10 | + | {% if note.read_only %} | |
| 11 | + | {# Says where it came from, so a note nobody on the repo wrote does | |
| 12 | + | not read as one of theirs they have forgotten. #} | |
| 13 | + | <span class="git-note-owned">written by makenot.work</span> | |
| 14 | + | {% endif %} | |
| 10 | 15 | {% if let Some(a) = note.attribution %} | |
| 11 | 16 | <span class="git-note-attribution"> | |
| 12 | 17 | {% if a.exact %} |