Skip to main content

max / makenotwork

Make the RPM repo endpoint one artifact store Alloy needs three things hosted, ruled separately: the hotfix RPM repo (86cb87b9), the Fedora base image mirror (ebf30337), and the locked layered RPM set (7ee5a694). Two of those were ruled the same day and named different stores. Three stores means three uploaders, three retention policies and three signing keys, and the migration to undo that costs more once anything has published. One store under three prefixes instead, while nothing has published: - routes::rpm becomes routes::artifacts, /api/v1/admin/rpm/uploads becomes /api/v1/admin/artifacts/uploads. Nothing has ever called it, which is what makes the rename free now and not later. - The mutable-index rule replaces the repomd.xml special case. Every format here has one entry point at a fixed name over content-addressed objects, so it is one list rather than a policy per prefix. - generate_artifact_key admits OCI blobs by the shape of their path rather than by extension. A blob is named by its own digest, so the allowlist could never have passed the mirror. - mnw-cli rpm publish becomes artifact publish, with --kind rpm|oci deciding what goes up last. The same filename is content under one kind and the index under the other, so it is a flag, not a guess. Placement is a policy now, not a preference: one outward-facing purpose per production box, so a pull spike from Alloy users never lands on the machine serving paying creators. Filed as infra 72c334cc. Staging stays the exception. Wiki mnw-package-hosting.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01J1yAFMokhGYCs3UT8UaCZr
Author: Max Johnson <me@maxj.phd> · 2026-09-07 20:50 UTC
Signed with PGP, not checked
Commit: c548eb484063ba6e449170dcd426d715b5656186
Parent: a0465a1
20 files changed, +522 insertions, -294 deletions
M mnw-cli/Cargo.lock +60 -60
@@ -1837,6 +1837,8 @@
1837 1837 [[package]]
1838 1838 name = "makeover-layout"
1839 1839 version = "0.44.0"
1840 + source = "registry+https://github.com/rust-lang/crates.io-index"
1841 + checksum = "f4c5dd2718ddfe428033a2c55d810e3b567edf9273f148875c1aa0291735ae12"
1840 1842
1841 1843 [[package]]
1842 1844 name = "makeover-timing"
@@ -1850,6 +1852,8 @@
1850 1852 [[package]]
1851 1853 name = "makeover-tui"
1852 1854 version = "0.44.0"
1855 + source = "registry+https://github.com/rust-lang/crates.io-index"
1856 + checksum = "a39ca8938ca1e082ce16ac772592bc81aa0a14c461c5ec0b60bf55b0728ae7e5"
1853 1857 dependencies = [
1854 1858 "makeover",
1855 1859 "makeover-layout",
@@ -4773,6 +4777,62 @@
4773 4777 source = "registry+https://github.com/rust-lang/crates.io-index"
4774 4778 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
4775 4779
4780 + [[patch.unused]]
4781 + name = "docengine"
4782 + version = "0.7.0"
4783 +
4784 + [[patch.unused]]
4785 + name = "quasi-axum"
4786 + version = "0.103.0"
4787 +
4788 + [[patch.unused]]
4789 + name = "quasi-basics"
4790 + version = "0.103.0"
4791 +
4792 + [[patch.unused]]
4793 + name = "quasi-declare"
4794 + version = "0.1.11"
4795 +
4796 + [[patch.unused]]
4797 + name = "quasi-http"
4798 + version = "0.103.0"
4799 +
4800 + [[patch.unused]]
4801 + name = "quasi-immediate"
4802 + version = "0.103.0"
4803 +
4804 + [[patch.unused]]
4805 + name = "quasi-notifs"
4806 + version = "0.103.0"
4807 +
4808 + [[patch.unused]]
4809 + name = "quasi-router"
4810 + version = "0.103.0"
4811 +
4812 + [[patch.unused]]
4813 + name = "quasi-store"
4814 + version = "0.1.0"
4815 +
4816 + [[patch.unused]]
4817 + name = "quasi-tauri"
4818 + version = "0.103.0"
4819 +
4820 + [[patch.unused]]
4821 + name = "quasi-webview"
4822 + version = "0.103.0"
4823 +
4824 + [[patch.unused]]
4825 + name = "makeover-build"
4826 + version = "0.67.0"
4827 +
4828 + [[patch.unused]]
4829 + name = "makeover-webview"
4830 + version = "0.74.0"
4831 +
4832 + [[patch.unused]]
4833 + name = "quasi-type"
4834 + version = "0.1.3"
4835 +
4776 4836 [[patch.unused]]
4777 4837 name = "kberg"
4778 4838 version = "0.1.0"
@@ -4789,66 +4849,6 @@
4789 4849 name = "tagtree"
4790 4850 version = "0.4.1"
4791 4851
4792 - [[patch.unused]]
4793 - name = "quasi-axum"
4794 - version = "0.99.0"
4795 -
4796 - [[patch.unused]]
4797 - name = "quasi-basics"
4798 - version = "0.99.0"
4799 -
4800 - [[patch.unused]]
4801 - name = "quasi-http"
4802 - version = "0.99.0"
4803 -
4804 - [[patch.unused]]
4805 - name = "quasi-immediate"
4806 - version = "0.99.0"
4807 -
4808 - [[patch.unused]]
4809 - name = "quasi-notifs"
4810 - version = "0.99.0"
4811 -
4812 - [[patch.unused]]
4813 - name = "quasi-router"
4814 - version = "0.99.0"
4815 -
4816 - [[patch.unused]]
4817 - name = "quasi-store"
4818 - version = "0.1.0"
4819 -
4820 - [[patch.unused]]
4821 - name = "quasi-tauri"
4822 - version = "0.99.0"
4823 -
4824 - [[patch.unused]]
4825 - name = "quasi-webview"
4826 - version = "0.99.0"
4827 -
4828 - [[patch.unused]]
4829 - name = "docengine"
4830 - version = "0.7.0"
4831 -
4832 - [[patch.unused]]
4833 - name = "quasi-type"
4834 - version = "0.1.3"
4835 -
4836 - [[patch.unused]]
4837 - name = "makeover-build"
4838 - version = "0.66.0"
4839 -
4840 - [[patch.unused]]
4841 - name = "makeover-immediate"
4842 - version = "0.45.0"
4843 -
4844 - [[patch.unused]]
4845 - name = "makeover-touch"
4846 - version = "0.34.0"
4847 -
4848 - [[patch.unused]]
4849 - name = "makeover-webview"
4850 - version = "0.73.0"
4851 -
4852 4852 [[patch.unused]]
4853 4853 name = "synckit-config"
4854 4854 version = "0.2.0"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "mnw-cli"
3 - version = "0.1.4"
3 + version = "0.1.5"
4 4 edition = "2024"
5 5 license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
6 6
M server/Cargo.lock +17 -17
@@ -5270,7 +5270,7 @@
5270 5270
5271 5271 [[package]]
5272 5272 name = "makenotwork"
5273 - version = "0.16.4"
5273 + version = "0.16.5"
5274 5274 dependencies = [
5275 5275 "anyhow",
5276 5276 "apple-codesign",
@@ -10767,6 +10767,22 @@
10767 10767 "pkg-config",
10768 10768 ]
10769 10769
10770 + [[patch.unused]]
10771 + name = "quasi-immediate"
10772 + version = "0.103.0"
10773 +
10774 + [[patch.unused]]
10775 + name = "quasi-notifs"
10776 + version = "0.103.0"
10777 +
10778 + [[patch.unused]]
10779 + name = "quasi-store"
10780 + version = "0.1.0"
10781 +
10782 + [[patch.unused]]
10783 + name = "quasi-tauri"
10784 + version = "0.103.0"
10785 +
10770 10786 [[patch.unused]]
10771 10787 name = "kberg"
10772 10788 version = "0.1.0"
@@ -10786,19 +10802,3 @@
10786 10802 [[patch.unused]]
10787 10803 name = "synckit-config"
10788 10804 version = "0.2.0"
10789 -
10790 - [[patch.unused]]
10791 - name = "quasi-immediate"
10792 - version = "0.103.0"
10793 -
10794 - [[patch.unused]]
10795 - name = "quasi-notifs"
10796 - version = "0.103.0"
10797 -
10798 - [[patch.unused]]
10799 - name = "quasi-store"
10800 - version = "0.1.0"
10801 -
10802 - [[patch.unused]]
10803 - name = "quasi-tauri"
10804 - version = "0.103.0"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.16.4"
3 + version = "0.16.5"
4 4 edition = "2024"
5 5 license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
6 6 # Server binary: never published to a registry. Marks the crate private so
@@ -6,7 +6,7 @@
6 6 "license": {
7 7 "name": "PolyForm Noncommercial 1.0.0"
8 8 },
9 - "version": "0.16.4"
9 + "version": "0.16.5"
10 10 },
11 11 "paths": {
12 12 "/api/git/{owner}/{repo}/notes": {
@@ -11,13 +11,13 @@
11 11 //! <!-- wiki: mnw-cli-overview -->
12 12
13 13 mod api;
14 + mod artifact;
14 15 mod commands;
15 16 mod config;
16 17 mod currency;
17 18 mod format;
18 19 mod ota;
19 20 mod rate_limit;
20 - mod rpm;
21 21 mod ssh;
22 22 mod staging;
23 23 mod tls;
@@ -63,8 +63,8 @@
63 63 }
64 64 // Same shape as `ota` above: an operator one-shot, routed off argv before
65 65 // the SSH daemon starts.
66 - if argv.get(1).map(String::as_str) == Some("rpm") {
67 - return rpm::run(&argv[2..]).await;
66 + if argv.get(1).map(String::as_str) == Some("artifact") {
67 + return artifact::run(&argv[2..]).await;
68 68 }
69 69
70 70 tracing_subscriber::fmt()
@@ -1042,8 +1042,8 @@
1042 1042 storage: None,
1043 1043 synckit_storage: None,
1044 1044 public_storage: None,
1045 - rpm_storage: None,
1046 - rpm_base_url: None,
1045 + artifact_storage: None,
1046 + artifact_base_url: None,
1047 1047 stripe: None,
1048 1048 admin_user_id: Some(user.id),
1049 1049 synckit_jwt_secret: None,
@@ -1128,8 +1128,8 @@
1128 1128 storage: None,
1129 1129 synckit_storage: None,
1130 1130 public_storage: None,
1131 - rpm_storage: None,
1132 - rpm_base_url: None,
1131 + artifact_storage: None,
1132 + artifact_base_url: None,
1133 1133 stripe: None,
1134 1134 admin_user_id: None,
1135 1135 synckit_jwt_secret: None,
@@ -28,22 +28,29 @@
28 28 /// overridden by `S3_PUBLIC_BUCKET`. Required in production (the CDN serves
29 29 /// ONLY this bucket); `None` in dev when `S3_PUBLIC_BUCKET` is unset.
30 30 pub public_storage: Option<StorageConfig>,
31 - /// Bucket holding the Alloy hotfix RPM repository: the `.rpm` files and the
32 - /// `createrepo_c` metadata that `dnf`/`rpm-ostree` fetch by path. Served
33 - /// beside the server by a GET/HEAD-only Caddy block, exactly as the CDN
34 - /// bucket is, so a published fix reaches machines with no deploy at all.
31 + /// Bucket holding the artifact store. Three prefixes, one bucket: the Alloy
32 + /// hotfix RPM repository that `dnf`/`rpm-ostree` fetch by path, the mirror
33 + /// of the Fedora base images, and the locked snapshot of the layered RPM
34 + /// set. Served by a GET/HEAD-only Caddy block, exactly as the CDN bucket is,
35 + /// so a published fix reaches machines with no deploy at all.
35 36 ///
36 - /// Resolved from `RPM_S3_*` when a dedicated credential is provisioned, and
37 + /// That Caddy block belongs on a box that serves nothing else. One
38 + /// outward-facing purpose per production machine, so a pull spike here
39 + /// never lands on the machine serving paying creators. See wiki
40 + /// `mnw-package-hosting`.
41 + ///
42 + /// Resolved from `ARTIFACT_S3_*` when a dedicated credential is provisioned, and
37 43 /// otherwise from the main storage with the bucket overridden by
38 - /// `S3_RPM_BUCKET`. `None` when neither is set, which is every dev
44 + /// `S3_ARTIFACT_BUCKET`. `None` when neither is set, which is every dev
39 45 /// environment; the publish endpoint then answers 503 rather than 404, so
40 46 /// "not configured here" never reads as "the route is gone".
41 - pub rpm_storage: Option<StorageConfig>,
42 - /// Public render base for [`Self::rpm_storage`] (e.g.
43 - /// `https://rpm.makenot.work`), the host the Caddy block answers on. Only
47 + pub artifact_storage: Option<StorageConfig>,
48 + /// Public render base for [`Self::artifact_storage`] (e.g.
49 + /// `https://artifacts.makenot.work`), the host the Caddy block answers on.
50 + /// Only
44 51 /// used to tell an operator where a published object landed; nothing
45 - /// durable is written from it. `None` when `RPM_BASE_URL` is unset.
46 - pub rpm_base_url: Option<String>,
52 + /// durable is written from it. `None` when `ARTIFACT_BASE_URL` is unset.
53 + pub artifact_base_url: Option<String>,
47 54 /// Stripe payment configuration (optional)
48 55 pub stripe: Option<StripeConfig>,
49 56 /// Admin user ID for waitlist management (optional)
@@ -280,13 +287,13 @@
280 287 })
281 288 });
282 289
283 - // The RPM repo bucket. Two ways in, and the prefixed one wins: a
284 - // dedicated `RPM_S3_*` credential is the shape the provisioning task
285 - // (alloy `23f599d9`) hands over, and `S3_RPM_BUCKET` over the main
290 + // The artifact store bucket. Two ways in, and the prefixed one wins: a
291 + // dedicated `ARTIFACT_S3_*` credential is the shape the provisioning task
292 + // (alloy `23f599d9`) hands over, and `S3_ARTIFACT_BUCKET` over the main
286 293 // credentials is the same fallback `public_storage` takes above, so a
287 294 // bucket in the same project needs one variable rather than five.
288 - let rpm_storage = StorageConfig::from_env_prefixed("RPM_S3_").or_else(|| {
289 - std::env::var("S3_RPM_BUCKET")
295 + let artifact_storage = StorageConfig::from_env_prefixed("ARTIFACT_S3_").or_else(|| {
296 + std::env::var("S3_ARTIFACT_BUCKET")
290 297 .ok()
291 298 .filter(|s| !s.is_empty())
292 299 .and_then(|bucket| {
@@ -297,7 +304,7 @@
297 304 })
298 305 });
299 306
300 - let rpm_base_url = std::env::var("RPM_BASE_URL")
307 + let artifact_base_url = std::env::var("ARTIFACT_BASE_URL")
301 308 .ok()
302 309 .filter(|s| !s.is_empty())
303 310 .map(|s| s.trim_end_matches('/').to_string());
@@ -524,8 +531,8 @@
524 531 storage,
525 532 synckit_storage,
526 533 public_storage,
527 - rpm_storage,
528 - rpm_base_url,
534 + artifact_storage,
535 + artifact_base_url,
529 536 stripe,
530 537 admin_user_id,
531 538 synckit_jwt_secret,
@@ -443,26 +443,30 @@
443 443 pub const OTA_READ_RATE_LIMIT_MS: u64 = 100;
444 444 pub const OTA_READ_RATE_LIMIT_BURST: u32 = 30;
445 445
446 - // Alloy hotfix RPM repo publishing (routes::rpm).
447 - /// Presign lifetime for an RPM/repodata PUT. Matches the OTA artifact window:
448 - /// the object is uploaded immediately after the mint, and a short window bounds
446 + // Artifact store publishing (routes::artifacts).
447 + /// Presign lifetime for one object's PUT. Matches the OTA artifact window: the
448 + /// object is uploaded immediately after the mint, and a short window bounds
449 449 /// what a leaked URL is worth.
450 - pub const RPM_PRESIGN_EXPIRY_SECS: u64 = 3600; // 1 hour
451 - /// Largest object the RPM publish endpoint will sign. A single unresumable PUT,
452 - /// so it stays modest; the biggest thing the repo carries is one package, and a
453 - /// package near this size is a packaging mistake rather than a hotfix.
454 - pub const RPM_MAX_OBJECT_BYTES: i64 = 2 * 1024 * 1024 * 1024; // 2 GB
450 + pub const ARTIFACT_PRESIGN_EXPIRY_SECS: u64 = 3600; // 1 hour
451 + /// Largest object the publish endpoint will sign. A single unresumable PUT, so
452 + /// it stays modest. The biggest thing the store carries is one package or one
453 + /// image layer, and either near this size is a packaging mistake rather than a
454 + /// hotfix.
455 + pub const ARTIFACT_MAX_OBJECT_BYTES: i64 = 2 * 1024 * 1024 * 1024; // 2 GB
455 456 /// Longest object path the endpoint will accept, counted in bytes over the
456 457 /// whole key. Well under S3's 1024-byte key limit and far past any real
457 - /// `repodata/<sha256>-primary.xml.zst`.
458 - pub const RPM_MAX_KEY_BYTES: usize = 255;
459 - /// Most path segments an RPM object key may carry: `alloy/f43/x86_64/repodata/repomd.xml`
460 - /// is five.
461 - pub const RPM_MAX_KEY_SEGMENTS: usize = 8;
462 - // RPM publish: burst 20, then 4/sec. A repodata push is several objects back to
463 - // back, so the burst is wider than OTA's while the steady rate stays low.
464 - pub const RPM_WRITE_RATE_LIMIT_MS: u64 = 250;
465 - pub const RPM_WRITE_RATE_LIMIT_BURST: u32 = 20;
458 + /// `repodata/<sha256>-primary.xml.zst` or `blobs/sha256/<digest>`.
459 + pub const ARTIFACT_MAX_KEY_BYTES: usize = 255;
460 + /// Most path segments an object key may carry.
461 + /// `alloy/hotfix/f43/x86_64/repodata/repomd.xml` is six, and an OCI layout's
462 + /// `alloy/base/fedora-bootc/43/x86_64/blobs/sha256/<digest>` is eight, which is
463 + /// what sets this rather than the RPM side.
464 + pub const ARTIFACT_MAX_KEY_SEGMENTS: usize = 10;
465 + // Artifact publish: burst 20, then 4/sec. A repodata push or a layout's blob
466 + // set is several objects back to back, so the burst is wider than OTA's while
467 + // the steady rate stays low.
468 + pub const ARTIFACT_WRITE_RATE_LIMIT_MS: u64 = 250;
469 + pub const ARTIFACT_WRITE_RATE_LIMIT_BURST: u32 = 20;
466 470
467 471 // Build pipeline
468 472 pub const BUILD_TIMEOUT_SECS: u64 = 1800; // 30 min
M server/src/lib.rs +14 -12
@@ -103,8 +103,8 @@
103 103 use email::EmailClient;
104 104 use payments::{PaymentCapabilities, PaymentProvider};
105 105 use routes::{
106 - admin_routes, api_routes, auth_routes, build_routes, git_issue_routes, git_routes,
107 - git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes, rpm_routes,
106 + admin_routes, api_routes, artifact_routes, auth_routes, build_routes, git_issue_routes,
107 + git_routes, git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes,
108 108 sso_routes, storage_routes, stripe_routes, synckit_routes,
109 109 };
110 110 use scanning::ScanPipeline;
@@ -184,11 +184,13 @@
184 184 /// gallery, item/project images); the scan worker copies Clean image
185 185 /// objects here cross-bucket. `None` when `S3_PUBLIC_BUCKET` is unset.
186 186 pub public_s3: Option<Arc<dyn StorageBackend>>,
187 - /// Bucket holding the Alloy hotfix RPM repository. Written only by the
188 - /// admin publish endpoint (`routes::rpm`); read by nobody here, because the
189 - /// repo is fetched straight off the bucket through Caddy. `None` when the
190 - /// bucket is unconfigured, which is every dev environment.
191 - pub rpm_s3: Option<Arc<dyn StorageBackend>>,
187 + /// Bucket holding the artifact store: the Alloy hotfix RPM repository, the
188 + /// Fedora base image mirror, and the locked layered RPM set, one prefix
189 + /// each. Written only by the admin publish endpoint (`routes::artifacts`);
190 + /// read by nobody here, because a client fetches straight off the bucket
191 + /// through Caddy. `None` when the bucket is unconfigured, which is every
192 + /// dev environment.
193 + pub artifact_s3: Option<Arc<dyn StorageBackend>>,
192 194 }
193 195
194 196 /// Derived in-memory caches held by [`AppState`]. All are `Arc<DashMap>` so a
@@ -458,10 +460,10 @@
458 460 })
459 461 }
460 462
461 - /// Get the RPM-repo S3 storage backend, or error if not configured.
462 - pub fn require_rpm_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
463 - self.rpm_s3.as_ref().ok_or_else(|| {
464 - error::AppError::ServiceUnavailable("RPM bucket is not configured".to_string())
463 + /// Get the artifact-store S3 storage backend, or error if not configured.
464 + pub fn require_artifact_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
465 + self.artifact_s3.as_ref().ok_or_else(|| {
466 + error::AppError::ServiceUnavailable("artifact bucket is not configured".to_string())
465 467 })
466 468 }
467 469 }
@@ -582,7 +584,7 @@
582 584 .merge(git_issue_routes())
583 585 .merge(git_write_routes())
584 586 .merge(ota_routes())
585 - .merge(rpm_routes())
587 + .merge(artifact_routes())
586 588 .merge(build_routes());
587 589 // The description layer, when a screen is switched on. Inside the CSRF tree
588 590 // rather than beside it, so a described write is covered by the same
M server/src/main.rs +16 -19
@@ -394,25 +394,22 @@
394 394 // Initialize the RPM-repo bucket client if configured. Written only by the
395 395 // admin publish endpoint; served to dnf/rpm-ostree by Caddy off the bucket,
396 396 // so nothing here reads it back.
397 - let rpm_s3: Option<std::sync::Arc<dyn makenotwork::storage::StorageBackend>> = if let Some(
398 - ref rpm_storage_config,
399 - ) =
400 - config.rpm_storage
401 - {
402 - match S3Client::new(rpm_storage_config, &config.host_url).await {
403 - Ok(client) => {
404 - tracing::info!(bucket = %rpm_storage_config.bucket, "RPM S3 bucket initialized");
405 - Some(std::sync::Arc::new(client))
397 + let artifact_s3: Option<std::sync::Arc<dyn makenotwork::storage::StorageBackend>> =
398 + if let Some(ref rpm_storage_config) = config.artifact_storage {
399 + match S3Client::new(rpm_storage_config, &config.host_url).await {
400 + Ok(client) => {
401 + tracing::info!(bucket = %rpm_storage_config.bucket, "RPM S3 bucket initialized");
402 + Some(std::sync::Arc::new(client))
403 + }
404 + Err(e) => {
405 + tracing::warn!(error = ?e, "Failed to initialize RPM S3 bucket");
406 + None
407 + }
406 408 }
407 - Err(e) => {
408 - tracing::warn!(error = ?e, "Failed to initialize RPM S3 bucket");
409 - None
410 - }
411 - }
412 - } else {
413 - tracing::info!("RPM S3 bucket not configured");
414 - None
415 - };
409 + } else {
410 + tracing::info!("RPM S3 bucket not configured");
411 + None
412 + };
416 413
417 414 // Initialize Stripe client if configured. Stripe implements every
418 415 // capability extension, so one `Arc` becomes both the base provider and
@@ -574,7 +571,7 @@
574 571 s3,
575 572 synckit_s3,
576 573 public_s3,
577 - rpm_s3,
574 + artifact_s3,
578 575 },
579 576 payments: stripe,
580 577 payment_caps,