diff --git a/.gitignore b/.gitignore index 2aa272e..63a8a11 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ .pre-commit-config.flake.yaml result/ +__pycache__/ + diff --git a/Cargo.lock b/Cargo.lock index afcbef9..2ec488d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,6 +869,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1896,6 +1905,7 @@ dependencies = [ "futures", "futures-util", "httpmock", + "moka", "percent-encoding", "prometheus", "regex", @@ -1916,6 +1926,26 @@ dependencies = [ "url", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "mutually_exclusive_features" version = "0.1.0" @@ -2055,6 +2085,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -2873,6 +2909,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" diff --git a/Cargo.toml b/Cargo.toml index 311b034..c0c0dd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ bytes = "1.11" figment = { version = "0.10", features = ["toml"] } futures = "0.3" futures-util = "0.3" +moka = { version = "0.12.15", features = ["future"] } percent-encoding = "2.1" prometheus = "0.14" regex = "1" diff --git a/flake.nix b/flake.nix index 9d65444..0a93da9 100644 --- a/flake.nix +++ b/flake.nix @@ -49,16 +49,38 @@ ... }: let - inherit (pkgs) stdenv pkgsStatic; + inherit (pkgs) + stdenv + pkgsStatic + cacert + rust-jemalloc-sys + ; craneLib = (inputs.crane.mkLib pkgs).overrideToolchain (p: p.rustToolchain); - craneAttrs = import ./nix/crane.nix { inherit craneLib pkgs lib; }; - inherit (craneAttrs) - src - commonArgs - cargoArtifacts - mergeCraneArgs - ; + src = + let + root = ./.; + in + lib.fileset.toSource { + inherit root; + fileset = lib.fileset.unions [ + (craneLib.fileset.commonCargoSources root) + (lib.fileset.maybeMissing (root + "/tests")) + ]; + }; + + # NOTE: `buildInputs` and sometimes `nativeBuildInputs` + # should be explicitly overridden for cross compilation + commonArgs = { + inherit src; + strictDeps = true; + nativeBuildInputs = [ cacert ]; + buildInputs = [ rust-jemalloc-sys ]; + doCheck = false; # Test separately with cargo-nextest + }; + # Build *just* the cargo dependencies, so we can reuse + # all of that work (e.g. via cachix) when running in CI + cargoArtifacts = craneLib.buildDepsOnly commonArgs; defaultTarget = stdenv.hostPlatform.config; muslTarget = @@ -77,18 +99,20 @@ inherit cargoArtifacts; CARGO_PROFILE = "dev"; CARGO_BUILD_TARGET = defaultTarget; - buildInputs = [ pkgs.rust-jemalloc-sys ]; } ); my-crate-musl = craneLib.buildPackage ( - mergeCraneArgs commonArgs { + commonArgs + // { nativeBuildInputs = [ # Required by aws-lc-sys + cacert stdenv.cc pkgsStatic.stdenv.cc ]; buildInputs = [ pkgsStatic.rust-jemalloc-sys ]; + doCheck = true; # always run checkPhase for release artifact CARGO_PROFILE = "release"; CARGO_BUILD_TARGET = muslTarget; CARGO_BUILD_FLAGS = "-C target-feature=+crt-static"; diff --git a/nix/crane.nix b/nix/crane.nix deleted file mode 100644 index 315c3c0..0000000 --- a/nix/crane.nix +++ /dev/null @@ -1,65 +0,0 @@ -{ - craneLib, - pkgs, - lib, - ... -}: -let - inherit (pkgs) cacert; - - unfilteredRoot = ../.; # TODO: decouple with filepath - src = lib.fileset.toSource { - root = unfilteredRoot; - fileset = lib.fileset.unions [ - (craneLib.fileset.commonCargoSources unfilteredRoot) - (lib.fileset.maybeMissing (unfilteredRoot + "/tests")) - ]; - }; - - commonArgs = { - inherit src; - - strictDeps = true; - - nativeBuildInputs = [ cacert ]; - - doCheck = false; # Test separately with cargo-nextest - }; - - # Build *just* the cargo dependencies, so we can reuse - # all of that work (e.g. via cachix) when running in CI - cargoArtifacts = craneLib.buildDepsOnly commonArgs; - - # Merge crane arg sets while appending common list-valued inputs - # instead of letting `//` replace them. - mergeCraneArgs = - base: extra: - let - listMergeKeys = [ - "nativeBuildInputs" - "buildInputs" - "propagatedBuildInputs" - "propagatedNativeBuildInputs" - "checkInputs" - ]; - mergedListAttrs = builtins.foldl' ( - acc: key: - if (builtins.hasAttr key base) || (builtins.hasAttr key extra) then - acc - // { - ${key} = (base.${key} or [ ]) ++ (extra.${key} or [ ]); - } - else - acc - ) { } listMergeKeys; - in - (base // extra) // mergedListAttrs; -in -{ - inherit - src - commonArgs - cargoArtifacts - mergeCraneArgs - ; -} diff --git a/src/artifacts.rs b/src/artifacts.rs index 0766acf..5c2618f 100644 --- a/src/artifacts.rs +++ b/src/artifacts.rs @@ -67,7 +67,7 @@ async fn remove_buffer_file(path: &Path) { async fn download_to_memory(response: Response, max_size: u64) -> Result { let body = response.bytes().await?; if body.len() as u64 > max_size { - return Err(Error::TooLarge(())); + return Err(Error::TooLarge); } Ok(UploadPayload::Memory(body)) } @@ -99,7 +99,7 @@ async fn download_to_file( })?; content_length += chunk.len() as u64; if content_length > max_size { - return Err(Error::TooLarge(())); + return Err(Error::TooLarge); } file.write_all(&chunk).await?; } @@ -237,12 +237,12 @@ impl DownloadCtx<'static> { std::time::Duration::from_secs(config.download_timeout), task_fut, ); - if let Err(err) = task_fut.await.unwrap_or(Err(Error::Timeout(()))) { + if let Err(err) = task_fut.await.unwrap_or(Err(Error::Timeout)) { warn!(error=?err, ttl=task_new.retry_limit, "failed to download task"); task_new.retry_limit -= 1; self.metrics.failed_download_counter.inc(); - if !matches!(err, Error::HTTPError(_)) && !matches!(err, Error::TooLarge(_)) { + if !matches!(err, Error::Http(_)) && !matches!(err, Error::TooLarge) { self.fail_tx.send(task_new).unwrap(); self.metrics.task_in_queue.inc(); } @@ -302,7 +302,7 @@ async fn cache_task(task: Task, client: Client, config: &Config) -> Result<()> { Ok(stream) => stream, Err(err) => { remove_buffer_file(&path).await; - return Err(Error::CustomError(format!( + return Err(Error::Custom(format!( "failed to open buffered artifact {}: {:?}", path.display(), err @@ -325,14 +325,14 @@ async fn download_payload(client: &Client, url: Url, config: &Config) -> Result< let response = client.get(url).send().await?; let status = response.status(); if !status.is_success() { - return Err(Error::HTTPError(status)); + return Err(Error::Http(status)); } let max_size = max_download_size(config); let file_threshold = file_threshold(config); match response.content_length() { - Some(content_length) if content_length > max_size => Err(Error::TooLarge(())), + Some(content_length) if content_length > max_size => Err(Error::TooLarge), Some(content_length) if content_length > file_threshold => { info!("stream mode: file backend"); download_to_file(response, config, max_size).await @@ -489,6 +489,6 @@ mod tests { &config, ) .await; - assert!(matches!(result, Err(Error::TooLarge(())))); + assert!(matches!(result, Err(Error::TooLarge))); } } diff --git a/src/browse.rs b/src/browse.rs index 51e0670..ac2212c 100644 --- a/src/browse.rs +++ b/src/browse.rs @@ -66,7 +66,7 @@ pub async fn list( .send(), ) .await - .map_err(|_| Error::Timeout(()))?; + .map_err(|_| Error::Timeout)?; if result.is_ok() { return Ok(Redirect::Permanent(format!("{}{}", real_endpoint, mirror_clone_list)).into()); @@ -85,7 +85,7 @@ pub async fn list( .send(), ) .await - .map_err(|_| Error::Timeout(()))??; + .map_err(|_| Error::Timeout)??; let mut body = r#" .. diff --git a/src/common.rs b/src/common.rs index 6196a5e..cc54b16 100644 --- a/src/common.rs +++ b/src/common.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use tokio::sync::mpsc::Sender; use url::Url; +use crate::s3_cache::PrefetchCache; use crate::{Error, Result}; /// A cache task. @@ -57,7 +58,7 @@ impl Task { self.storage, percent_decode(self.path.as_bytes()) .decode_utf8() - .map_err(|_| Error::DecodePathError(()))? + .map_err(|_| Error::DecodePath)? )) } @@ -149,12 +150,16 @@ pub struct IntelMission { pub tx: Option>, /// Reqwest client. pub client: Client, + /// Reqwest client for HEAD prefetch probes. + pub prefetch_client: Client, /// Prometheus metrics. pub metrics: Arc, /// S3 client. /// /// This is an anonymous client. pub s3_client: Arc, + /// Positive HEAD prefetch cache for `RouteAction::Cache`. + pub prefetch_cache: Arc, } /// An upstream endpoint override rule. @@ -273,6 +278,8 @@ pub struct Config { pub buffer_path: PathBuf, /// Worker tasks to serve requests. pub workers: Option, + /// TTL in seconds for positive S3/upstream HEAD prefetch entries. + pub head_prefetch_cache_ttl_secs: Option, } /// An empty redirect response to a given URL. @@ -457,6 +464,7 @@ mod tests { }, buffer_path: "/mnt/cache/".into(), workers: None, + head_prefetch_cache_ttl_secs: None, }; assert_eq!(config, expected); Ok(()) diff --git a/src/error.rs b/src/error.rs index a512632..f17da59 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,29 +15,29 @@ type ListObjectsSdkError = #[derive(Debug, Error)] pub enum Error { #[error("Failed to decode path")] - DecodePathError(()), + DecodePath, #[error("Failed to send task to pending queue")] - SendError(()), + Send, #[error("IO Error {0}")] Io(#[from] std::io::Error), #[error("Reqwest Error {0}")] Reqwest(#[from] reqwest::Error), #[error("HTTP Error {0}")] - HTTPError(reqwest::StatusCode), + Http(reqwest::StatusCode), #[error("{0}")] - CustomError(String), + Custom(String), #[error("Too Large")] - TooLarge(()), + TooLarge, #[error("Invalid Request")] - InvalidRequest(()), + InvalidRequest, #[error("Put Object Error {0}")] - PutObjectError(Box), + PutObject(Box), #[error("Get Object Error {0}")] - GetObjectsError(Box), + GetObjects(Box), #[error("List Objects Error {0}")] - ListObjectsError(Box), + ListObjects(Box), #[error("Timeout")] - Timeout(()), + Timeout, } impl ResponseError for Error {} @@ -45,17 +45,17 @@ impl ResponseError for Error {} // Fix clippy "the `Err`-variant returned from this function is very large" impl From for Error { fn from(error: PutObjectSdkError) -> Self { - Self::PutObjectError(Box::new(error)) + Self::PutObject(Box::new(error)) } } impl From for Error { fn from(error: GetObjectSdkError) -> Self { - Self::GetObjectsError(Box::new(error)) + Self::GetObjects(Box::new(error)) } } impl From for Error { fn from(error: ListObjectsSdkError) -> Self { - Self::ListObjectsError(Box::new(error)) + Self::ListObjects(Box::new(error)) } } diff --git a/src/main.rs b/src/main.rs index f4b08b7..d8fbdfd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,3 @@ -#![allow( - clippy::future_not_send, - clippy::cast_possible_truncation, - clippy::module_name_repetitions, - clippy::enum_variant_names, - clippy::case_sensitive_file_extension_comparisons, - clippy::cast_possible_wrap, - clippy::missing_errors_doc -)] - use std::sync::Arc; use actix_web::{App, HttpServer, guard, web}; @@ -26,6 +16,7 @@ use common::{Config, IntelMission, Metrics}; use error::{Error, Result}; use queue::queue_length; use repos::{configure_repo_routes, index}; +use s3_cache::{PrefetchCache, default_head_prefetch_ttl}; use storage::check_s3; use utils::not_found; @@ -38,6 +29,7 @@ mod error; mod intel_path; mod queue; mod repos; +mod s3_cache; mod storage; mod utils; @@ -54,24 +46,23 @@ fn setup_log() -> impl Drop { .unwrap_or_default() .to_lowercase(); - let (json, after) = match (rust_log_format.as_str(), cfg!(debug_assertions)) { - ("plain", _) => (false, None), - ("json", _) => (true, None), - ("", dev) => (!dev, None), // release defaults to json and debug to plain - (format, dev) => ( - !dev, - Some(move || { - warn!( - "RUST_LOG_FORMAT is set to '{}', but mirror-intel is in {} mode. Using '{}'", - format, - if dev { "debug" } else { "release" }, - if dev { "plain" } else { "json" } - ); - }), - ), + let use_json = match rust_log_format.as_str() { + "plain" => false, + "json" => true, + "" => !cfg!(debug_assertions), + format => { + let dev = cfg!(debug_assertions); + warn!( + "RUST_LOG_FORMAT is set to '{}', but mirror-intel is in {} mode. Using '{}'", + format, + if dev { "debug" } else { "release" }, + if dev { "plain" } else { "json" } + ); + !dev + } }; - if json { + if use_json { tracing::subscriber::set_global_default(registry.with(JsonStorageLayer).with( BunyanFormattingLayer::new("mirror-intel".to_string(), writer), )) @@ -82,21 +73,18 @@ fn setup_log() -> impl Drop { ) .expect("Unable to set logger"); }; - if let Some(after) = after { - after(); - } + guard } /// Metrics endpoint. -#[allow(clippy::unused_async)] pub async fn metrics_endpoint(intel_mission: web::Data) -> Result> { let mut buffer = vec![]; let encoder = TextEncoder::new(); let metric_families = intel_mission.metrics.gather(); encoder .encode(&metric_families, &mut buffer) - .map_err(|err| Error::CustomError(format!("failed to encode metrics: {:?}", err)))?; + .map_err(|err| Error::Custom(format!("failed to encode metrics: {:?}", err)))?; Ok(buffer) } @@ -126,8 +114,8 @@ async fn main() { let metrics = Arc::new(Metrics::default()); let metrics_download = metrics.clone(); let tx = (!config.read_only).then(|| { - // TODO so we are now having a global bounded queue, which will be easily blocked if there're - // too many requests to large files. See issue #24. + // NOTE: The global bounded channel (`max_pending_task` capacity) can deadlock + // under heavy load from many large-file requests. See issue #24. let (tx, rx) = channel(config.max_pending_task); let config_download = config.clone(); @@ -144,12 +132,27 @@ async fn main() { .user_agent(&config.user_agent) .build() .unwrap(); + let prefetch_client = ClientBuilder::new() + .user_agent(&config.user_agent) + .build() + .unwrap(); + let head_prefetch_ttl = match config.head_prefetch_cache_ttl_secs { + Some(0) => { + warn!("head_prefetch_cache_ttl_secs must be greater than 0; using default"); + default_head_prefetch_ttl() + } + Some(ttl) => std::time::Duration::from_secs(ttl), + None => default_head_prefetch_ttl(), + }; + let prefetch_cache = Arc::new(PrefetchCache::new(head_prefetch_ttl)); let mission = IntelMission { tx, client, + prefetch_client, metrics, s3_client: Arc::new(storage::get_anonymous_s3_client(&config.s3)), + prefetch_cache, }; let addr = config.address.clone(); diff --git a/src/queue.rs b/src/queue.rs index 8cbadf2..8b5c5c5 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -41,6 +41,7 @@ where #[cfg(test)] mod tests { use std::sync::Arc; + use std::time::Duration; use actix_http::Request; use actix_web::dev::{Service, ServiceResponse}; @@ -49,6 +50,7 @@ mod tests { use reqwest::Client; use crate::common::S3Config; + use crate::s3_cache::PrefetchCache; use crate::storage::get_anonymous_s3_client; use crate::{IntelMission, Metrics, queue_length}; @@ -59,8 +61,10 @@ mod tests { let mission = IntelMission { tx: None, client: Client::new(), + prefetch_client: Client::new(), metrics, s3_client: Arc::new(get_anonymous_s3_client(&s3_config)), + prefetch_cache: Arc::new(PrefetchCache::new(Duration::from_secs(60))), }; let app = App::new() diff --git a/src/repos.rs b/src/repos.rs index af59589..141c522 100644 --- a/src/repos.rs +++ b/src/repos.rs @@ -9,31 +9,36 @@ use crate::intel_path::IntelPath; use crate::{ Error, common::{Config, Endpoints, IntelMission, IntelResponse, Redirect, Task}, + s3_cache::PreCacheStatus, utils, }; /// Routing decision returned by a `classify` closure in [`simple_intel`]. pub enum RouteAction { /// Reverse-proxy the request to upstream. + /// NOTE: This should only be used for frequently-updated files, + /// such as HTML index, in case of restricted network traffic. Proxy, /// Follow the smart-cache strategy (redirect HEAD, stream or cache GET). + /// NOTE: Due to S3 API restriction, this is often paired with a + /// prefetch cache to fix response code inconsistency. Cache, /// Permanently redirect (301) to the upstream URL. Redirect, } pub fn simple_intel( - origin_injection: impl FnMut(&Endpoints) -> &str + Clone + Send + Sync + 'static, + origin_injection: impl Fn(&Endpoints) -> &str + Clone + Send + Sync + 'static, route: &'static str, - classify: impl FnMut(&Config, &str) -> RouteAction + Clone + Send + 'static, + classify: impl Fn(&Config, &str) -> RouteAction + Clone + Send + 'static, ) -> Route { let handler = move |path: IntelPath, method: Method, uri: Uri, intel_mission: web::Data, config: web::Data| { - let mut origin_injection = origin_injection.clone(); - let mut classify = classify.clone(); + let origin_injection = origin_injection.clone(); + let classify = classify.clone(); async move { let origin = origin_injection(&config.endpoints).to_string(); let path = path.to_string(); @@ -53,6 +58,8 @@ pub fn simple_intel( } match classify(&config, &task.path) { + // Reverse-proxy: HEAD returns 200 OK with no body; GET fetches + // upstream and streams the response back. RouteAction::Proxy => { let resp = if method == Method::HEAD { HttpResponse::Ok().finish().into() @@ -64,7 +71,20 @@ pub fn simple_intel( }; Ok(resp) } + // Smart-cache: consults the prefetch cache first. If no entry exists, + // returns 404. HEAD requests redirect to origin; GET requests either stream + // small cached objects directly or redirect for larger ones. RouteAction::Cache => { + if matches!( + intel_mission + .prefetch_cache + .prefetch_cache_action(&task, &intel_mission, &config) + .await, + PreCacheStatus::None + ) { + return Ok(HttpResponse::NotFound().finish().into()); + } + let resp = if method == Method::HEAD { task.resolve_no_content(&intel_mission, &config) .await? @@ -82,13 +102,14 @@ pub fn simple_intel( }; Ok(resp) } + // Permanent redirect (301) to the upstream URL. RouteAction::Redirect => { Ok(Redirect::Permanent(task.upstream_url().to_string()).into()) } } } }; - // Route with handler for GET and HEAD, otherwise 404 + // Route with handler for GET and HEAD methods, otherwise 404 web::route() .guard(guard::Any(guard::Get()).or(guard::Head())) .to(handler) @@ -107,14 +128,11 @@ pub fn classify_cache_all(_config: &Config, _path: &str) -> RouteAction { /// Paths that pass the filter get [`RouteAction::Cache`]; /// all others get [`RouteAction::Redirect`]. pub fn classify_with( - mut filter: impl FnMut(&Config, &str) -> bool + Clone + Send + 'static, -) -> impl FnMut(&Config, &str) -> RouteAction + Clone + Send + 'static { - move |config: &Config, path: &str| { - if filter(config, path) { - RouteAction::Cache - } else { - RouteAction::Redirect - } + filter: impl Fn(&Config, &str) -> bool + Clone + Send + 'static, +) -> impl Fn(&Config, &str) -> RouteAction + Clone + Send + 'static { + move |config: &Config, path: &str| match filter(config, path) { + true => RouteAction::Cache, + false => RouteAction::Redirect, } } @@ -466,7 +484,6 @@ pub fn nix_intel( web::get().to(handler) } -#[allow(clippy::unused_async)] pub async fn index(path: IntelPath, config: web::Data) -> IntelResponse { if config .endpoints @@ -486,6 +503,7 @@ pub async fn index(path: IntelPath, config: web::Data) -> IntelResponse #[cfg(test)] mod tests { use std::sync::Arc; + use std::time::Duration; use actix_http::{Request, body}; use actix_web::App; @@ -503,6 +521,7 @@ mod tests { use url::Url; use crate::common::{Config, EndpointOverride, IntelMission, Metrics}; + use crate::s3_cache::PrefetchCache; use crate::{list, not_found, queue_length, storage::get_anonymous_s3_client}; use super::*; @@ -526,15 +545,27 @@ mod tests { .path("/bucket/sjtug-internal/mirror-clone/releases/download/v0.1.7/mirror-clone.tar.gz"); then.status(200).body(""); }).await; + let _mock_3 = server + .mock_async(|when, then| { + when.method(httpmock::Method::HEAD) + .path("/mirror-clone/releases/download/v0.1.7/mirror-clone-2333.tar.gz"); + then.status(200).body(""); + }) + .await; + let _mock_4 = server + .mock_async(|when, then| { + when.method(httpmock::Method::HEAD) + .path("/mirror-clone/releases/download/v0.1.7/mirror%2B%2B%2B-clone.tar.gz"); + then.status(200).body(""); + }) + .await; + let sjtug_internal = server.base_url(); let figment = Figment::new() .join(("address", "127.0.0.1")) .join(("port", 8000)) .join(("concurrent_download", 512)) .join(("max_pending_task", 16384)) - .join(( - "endpoints", - map!["sjtug_internal" => "https://github.com/sjtug"], - )) + .join(("endpoints", map!["sjtug_internal" => sjtug_internal])) .join(("s3.name", "Placeholder S3")) .join(("s3.endpoint", server.base_url())) .join(("s3.website_endpoint", server.base_url())) @@ -554,8 +585,13 @@ mod tests { let mission = IntelMission { tx: Some(tx), client, + prefetch_client: ClientBuilder::new() + .user_agent(&config.user_agent) + .build() + .unwrap(), metrics: Arc::new(Metrics::default()), s3_client: Arc::new(get_anonymous_s3_client(&config.s3)), + prefetch_cache: Arc::new(PrefetchCache::new(Duration::from_secs(60))), }; let app = App::new() @@ -621,6 +657,15 @@ mod tests { } } + fn upstream_url(task: &Task, config: &Config) -> Url { + Url::parse(&format!( + "{}/{}", + config.endpoints.sjtug_internal, task.path + )) + .expect("invalid test upstream url") + } + + // NOTE: Set `#[serial(cwd_env)]` to avoid race condition between testcases #[rstest] #[case( Method::GET, @@ -634,10 +679,20 @@ mod tests { StatusCode::MOVED_PERMANENTLY, Task::cached_url )] - #[case(Method::GET, missing_object(), StatusCode::FOUND, | o: & Task, _c: & Config | o.upstream_url())] - #[case(Method::HEAD, missing_object(), StatusCode::FOUND, | o: & Task, _c: & Config | o.upstream_url())] - #[case(Method::GET, forbidden_object(), StatusCode::MOVED_PERMANENTLY, | o: & Task, _c: & Config | o.upstream_url())] - #[case(Method::HEAD, forbidden_object(), StatusCode::MOVED_PERMANENTLY, | o: & Task, _c: & Config | o.upstream_url())] + #[case(Method::GET, missing_object(), StatusCode::FOUND, upstream_url)] + #[case(Method::HEAD, missing_object(), StatusCode::FOUND, upstream_url)] + #[case( + Method::GET, + forbidden_object(), + StatusCode::MOVED_PERMANENTLY, + upstream_url + )] + #[case( + Method::HEAD, + forbidden_object(), + StatusCode::MOVED_PERMANENTLY, + upstream_url + )] #[serial(cwd_env)] #[tokio::test] async fn test_get_head( @@ -695,7 +750,7 @@ mod tests { #[tokio::test] async fn test_url_segment() { // this case is to test if we could process escaped URL correctly - let (service, _, _rx, _server) = make_service().await; + let (service, config, _rx, _server) = make_service().await; let object = Task { storage: "sjtug-internal", origin: "https://github.com/sjtug".to_string(), @@ -710,7 +765,7 @@ mod tests { assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!( resp.headers().get("Location").unwrap().to_str().unwrap(), - object.upstream_url().as_str() + upstream_url(&object, &config).as_str() ); } @@ -737,7 +792,7 @@ mod tests { #[tokio::test] async fn test_url_segment_query() { // this case is to test if we could process escaped URL correctly - let (service, _, _rx, _server) = make_service().await; + let (service, config, _rx, _server) = make_service().await; let object = Task { storage: "sjtug-internal", origin: "https://github.com/sjtug".to_string(), @@ -753,7 +808,7 @@ mod tests { assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!( resp.headers().get("Location").unwrap(), - object.upstream_url().as_str() + upstream_url(&object, &config).as_str() ); } diff --git a/src/s3_cache.rs b/src/s3_cache.rs new file mode 100644 index 0000000..35fd553 --- /dev/null +++ b/src/s3_cache.rs @@ -0,0 +1,311 @@ +use std::time::{Duration, Instant}; + +use moka::Expiry; +use moka::future::Cache; +use tracing::debug; + +use crate::common::{Config, IntelMission, Task}; + +const PREFETCH_CACHE_MAX_CAPACITY: u64 = 10_000; +const DEFAULT_HEAD_PREFETCH_TTL_SECS: u64 = 60; +const HEAD_PREFETCH_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PreCacheStatus { + // S3 object exists, or already known to exist + S3, + // Upstream object exists, or already known to exist + Upstream, + // Neither S3 nor upstream could confirm the object; caller returns 404 + None, +} + +#[derive(Clone)] +pub struct PrefetchCache { + cache: Cache, +} + +impl PrefetchCache { + pub fn new(ttl: Duration) -> Self { + let ttl = non_zero_ttl(ttl); + let negative_ttl = non_zero_ttl(ttl / 10); + + Self { + cache: Cache::builder() + .max_capacity(PREFETCH_CACHE_MAX_CAPACITY) + .expire_after(PrefetchCacheExpiry { ttl, negative_ttl }) + .build(), + } + } + + pub async fn prefetch_cache_action( + &self, + task: &Task, + mission: &IntelMission, + config: &Config, + ) -> PreCacheStatus { + let key = task.root_path(); + + self.cache + .get_with(key, direct_prefetch_cache_action(task, mission, config)) + .await + } +} + +pub const fn default_head_prefetch_ttl() -> Duration { + Duration::from_secs(DEFAULT_HEAD_PREFETCH_TTL_SECS) +} + +struct PrefetchCacheExpiry { + ttl: Duration, + negative_ttl: Duration, +} + +impl Expiry for PrefetchCacheExpiry { + fn expire_after_create( + &self, + _key: &String, + value: &PreCacheStatus, + _created_at: Instant, + ) -> Option { + match value { + PreCacheStatus::S3 | PreCacheStatus::Upstream => Some(self.ttl), + PreCacheStatus::None => Some(self.negative_ttl), + } + } +} + +fn non_zero_ttl(ttl: Duration) -> Duration { + ttl.max(Duration::from_secs(1)) +} + +async fn direct_prefetch_cache_action( + task: &Task, + mission: &IntelMission, + config: &Config, +) -> PreCacheStatus { + // Probe S3 first and only fall back to upstream on an S3 miss. This runs + // in the request-hot path for `RouteAction::Cache` (`repos.rs`), so the + // sequential ordering is a deliberate performance tradeoff. + if head_req(&mission.prefetch_client, task.cached_url(config)).await { + return PreCacheStatus::S3; + } + + // Mirror the download worker (`artifacts::run`) and rewrite the origin via + // `endpoints.overrides` before probing, otherwise the HEAD targets the + // configured origin that may be unreachable without the rewrite, causing + // false-negative `None` results (and spurious 404s in the request path). + let mut upstream_task = task.clone(); + upstream_task.apply_override(&config.endpoints.overrides); + + if head_req(&mission.prefetch_client, upstream_task.upstream_url()).await { + PreCacheStatus::Upstream + } else { + PreCacheStatus::None + } +} + +async fn head_req(client: &reqwest::Client, url: url::Url) -> bool { + match client + .head(url.clone()) + .timeout(HEAD_PREFETCH_TIMEOUT) + .send() + .await + { + Ok(resp) => resp.status().is_success(), + Err(error) => { + debug!(?error, %url, "HEAD prefetch failed"); + false + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use futures::future::join_all; + use httpmock::{Method, MockServer}; + use reqwest::Client; + use tokio::sync::mpsc::channel; + + use crate::common::{Config, EndpointOverride, IntelMission, Metrics, S3Config, Task}; + use crate::storage::get_anonymous_s3_client; + + use super::{PreCacheStatus, PrefetchCache}; + + fn make_task(server: &MockServer) -> Task { + Task { + storage: "storage", + origin: server.base_url(), + path: "missing".to_string(), + retry_limit: 3, + } + } + + fn make_config(server: &MockServer) -> Config { + Config { + s3: S3Config { + name: "test".to_string(), + endpoint: server.base_url(), + website_endpoint: server.base_url(), + bucket: "bucket".to_string(), + sentinel_object_key: None, + }, + ..Default::default() + } + } + + fn make_mission(config: &Config, prefetch_cache: Arc) -> IntelMission { + let (tx, _rx) = channel(1024); + + IntelMission { + tx: Some(tx), + client: Client::new(), + prefetch_client: Client::new(), + metrics: Arc::new(Metrics::default()), + s3_client: Arc::new(get_anonymous_s3_client(&config.s3)), + prefetch_cache, + } + } + + #[tokio::test] + async fn must_cache_negative_prefetch_result() { + let server = MockServer::start_async().await; + let s3_head = server + .mock_async(|when, then| { + when.method(Method::HEAD).path("/bucket/storage/missing"); + then.status(404); + }) + .await; + let upstream_head = server + .mock_async(|when, then| { + when.method(Method::HEAD).path("/missing"); + then.status(404); + }) + .await; + let config = make_config(&server); + let cache = Arc::new(PrefetchCache::new(Duration::from_secs(60))); + let mission = make_mission(&config, cache.clone()); + let task = make_task(&server); + + assert_eq!( + cache.prefetch_cache_action(&task, &mission, &config).await, + PreCacheStatus::None + ); + assert_eq!( + cache.prefetch_cache_action(&task, &mission, &config).await, + PreCacheStatus::None + ); + + s3_head.assert_calls_async(1).await; + upstream_head.assert_calls_async(1).await; + } + + #[tokio::test] + async fn must_coalesce_concurrent_negative_prefetches() { + let server = MockServer::start_async().await; + let s3_head = server + .mock_async(|when, then| { + when.method(Method::HEAD).path("/bucket/storage/missing"); + then.status(404); + }) + .await; + let upstream_head = server + .mock_async(|when, then| { + when.method(Method::HEAD).path("/missing"); + then.status(404); + }) + .await; + let config = make_config(&server); + let cache = Arc::new(PrefetchCache::new(Duration::from_secs(60))); + let mission = make_mission(&config, cache.clone()); + let task = make_task(&server); + + let statuses = + join_all((0..10).map(|_| cache.prefetch_cache_action(&task, &mission, &config))).await; + + assert!( + statuses + .iter() + .all(|status| *status == PreCacheStatus::None) + ); + s3_head.assert_calls_async(1).await; + upstream_head.assert_calls_async(1).await; + } + + #[tokio::test] + async fn must_skip_upstream_probe_when_s3_hits() { + let server = MockServer::start_async().await; + let s3_head = server + .mock_async(|when, then| { + when.method(Method::HEAD).path("/bucket/storage/missing"); + then.status(200); + }) + .await; + let upstream_head = server + .mock_async(|when, then| { + when.method(Method::HEAD).path("/missing"); + then.status(200); + }) + .await; + let config = make_config(&server); + let cache = Arc::new(PrefetchCache::new(Duration::from_secs(60))); + let mission = make_mission(&config, cache.clone()); + let task = make_task(&server); + + assert_eq!( + cache.prefetch_cache_action(&task, &mission, &config).await, + PreCacheStatus::S3 + ); + + s3_head.assert_calls_async(1).await; + // S3 already had the object, so upstream must not be contacted. + upstream_head.assert_calls_async(0).await; + } + + #[tokio::test] + async fn must_apply_endpoint_overrides_to_upstream_probe() { + let server = MockServer::start_async().await; + // S3 miss forces the upstream probe to run. + let s3_head = server + .mock_async(|when, then| { + when.method(Method::HEAD).path("/bucket/storage/missing"); + then.status(404); + }) + .await; + // Upstream is reachable only because the override rewrites the origin + // onto the mock server. Without the rewrite the probe would target an + // unroutable host and report `None`. + let upstream_head = server + .mock_async(|when, then| { + when.method(Method::HEAD).path("/missing"); + then.status(200); + }) + .await; + + let mut config = make_config(&server); + config.endpoints.overrides = vec![EndpointOverride { + name: "rewrite-blocked".to_string(), + pattern: "https://blocked.invalid".to_string(), + replace: server.base_url(), + }]; + let cache = Arc::new(PrefetchCache::new(Duration::from_secs(60))); + let mission = make_mission(&config, cache.clone()); + let task = Task { + storage: "storage", + origin: "https://blocked.invalid".to_string(), + path: "missing".to_string(), + retry_limit: 3, + }; + + assert_eq!( + cache.prefetch_cache_action(&task, &mission, &config).await, + PreCacheStatus::Upstream + ); + + s3_head.assert_calls_async(1).await; + upstream_head.assert_calls_async(1).await; + } +} diff --git a/src/storage.rs b/src/storage.rs index cd97f90..10760b8 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -91,5 +91,5 @@ pub async fn check_s3(s3_config: &S3Config) -> Result<()> { Ok::<(), Error>(()) }) .await - .map_err(|err| Error::CustomError(format!("failed to check s3 storage {:?}", err)))? + .map_err(|err| Error::Custom(format!("failed to check s3 storage {:?}", err)))? } diff --git a/src/utils.rs b/src/utils.rs index 0d59543..d1db78f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -35,7 +35,7 @@ impl Task { tx.clone() .send(self.clone()) .await - .map_err(|_| Error::SendError(()))?; + .map_err(|_| Error::Send)?; } Ok(IntelObject::Origin { task: self }) } @@ -189,7 +189,6 @@ impl IntelObject { } /// 404 page. -#[allow(clippy::unused_async)] pub async fn not_found(uri: Uri) -> impl Responder { no_route_for(&uri.to_string()) } @@ -249,6 +248,7 @@ impl StatusCodeExt for reqwest::StatusCode { mod tests { use std::future::Future; use std::sync::Arc; + use std::time::Duration; use actix_http::body::to_bytes; use httpmock::{Method, MockServer}; @@ -256,6 +256,7 @@ mod tests { use tokio::sync::mpsc::{Receiver, channel}; use crate::common::{IntelObject, IntelResponse, S3Config, Task}; + use crate::s3_cache::PrefetchCache; use crate::storage::get_anonymous_s3_client; use crate::{Config, IntelMission, Metrics}; @@ -283,8 +284,10 @@ mod tests { let mission = IntelMission { tx: Some(tx), client, + prefetch_client: Client::new(), metrics: Arc::new(Metrics::default()), s3_client: Arc::new(get_anonymous_s3_client(&config.s3)), + prefetch_cache: Arc::new(PrefetchCache::new(Duration::from_secs(60))), }; f(server, config, mission, rx).await; diff --git a/tests/e2e/fake_services.py b/tests/e2e/fake_services.py index d2df988..d41418a 100644 --- a/tests/e2e/fake_services.py +++ b/tests/e2e/fake_services.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse import signal import sys import threading @@ -22,9 +22,23 @@ b"""cu130 torch cached directory index""" b"""torch""" ) +UPSTREAM_ONLY = b"upstream-only cache fixture" objects = {"sentinel": b"0"} objects_lock = threading.Lock() +upstream_counts = {} +upstream_counts_lock = threading.Lock() + + +def record_upstream_request(method, path): + key = (method, path) + with upstream_counts_lock: + upstream_counts[key] = upstream_counts.get(key, 0) + 1 + + +def get_upstream_count(method, path): + with upstream_counts_lock: + return upstream_counts.get((method, path), 0) class QuietHandler(BaseHTTPRequestHandler): @@ -48,18 +62,33 @@ def do_HEAD(self): self.do_GET() def do_GET(self): - path = urlparse(self.path).path + parsed = urlparse(self.path) + path = parsed.path if path == "/health": self.send_bytes(200, b"ok") + elif path == "/__count": + query = parse_qs(parsed.query) + method = query.get("method", [""])[0] + counted_path = query.get("path", [""])[0] + count = str(get_upstream_count(method, counted_path)).encode() + self.send_bytes(200, count, {"Content-Type": "text/plain"}) elif path in ("/whl", "/whl/"): + record_upstream_request(self.command, "/whl") self.send_bytes(200, UPSTREAM_ROOT, {"Content-Type": "text/html"}) elif path in ("/whl/cu130", "/whl/cu130/"): + record_upstream_request(self.command, "/whl/cu130") self.send_bytes(200, UPSTREAM_CU130, {"Content-Type": "text/html"}) elif path in ("/whl/torch", "/whl/torch/"): + record_upstream_request(self.command, "/whl/torch") self.send_bytes(200, UPSTREAM_TORCH, {"Content-Type": "text/html"}) elif path in ("/whl/cu130/torch", "/whl/cu130/torch/"): + record_upstream_request(self.command, "/whl/cu130/torch") self.send_bytes(200, UPSTREAM_CU130_TORCH, {"Content-Type": "text/html"}) + elif path in ("/whl/upstream-only", "/whl/upstream-only/"): + record_upstream_request(self.command, "/whl/upstream-only") + self.send_bytes(200, UPSTREAM_ONLY, {"Content-Type": "text/plain"}) else: + record_upstream_request(self.command, path) self.send_bytes(404, b"not found") diff --git a/tests/e2e/simple.sh b/tests/e2e/simple.sh index d855256..4bccf38 100644 --- a/tests/e2e/simple.sh +++ b/tests/e2e/simple.sh @@ -5,7 +5,10 @@ wait-for-connection() { local url="$1" timeout 10s \ retry --until=success --delay "1" -- \ - curl --silent --show-error --fail --output /dev/null "$url" + curl --silent --show-error --fail --output /dev/null "$url" || { + echo "FAIL wait-for-connection: url=$url" >&2 + return 1 + } } assert-status() { @@ -20,10 +23,13 @@ assert-status() { --request "$method" \ --output /dev/null \ --write-out '%{http_code}' \ - "$url" + "$url" || true )" - test "$status" = "$expected_status" + if test "$status" != "$expected_status"; then + echo "FAIL assert-status: method=$method url=$url expected=$expected_status actual=$status" >&2 + return 1 + fi } wait-for-status() { @@ -48,7 +54,7 @@ wait-for-status() { return 0 fi if test "$SECONDS" -ge "$deadline"; then - echo "expected $method $url to return $expected_status, got $status" >&2 + echo "FAIL wait-for-status: method=$method url=$url expected=$expected_status actual=$status" >&2 return 1 fi sleep 1 @@ -59,7 +65,10 @@ assert-body-contains() { local url="$1" local expected_content="$2" - curl --silent --show-error "$url" | grep --fixed-strings --quiet -- "$expected_content" + if ! curl --silent --show-error "$url" | grep --fixed-strings --quiet -- "$expected_content"; then + echo "FAIL assert-body-contains: url=$url expected-substring=$expected_content" >&2 + return 1 + fi } wait-for-body-contains() { @@ -73,7 +82,7 @@ wait-for-body-contains() { return 0 fi if test "$SECONDS" -ge "$deadline"; then - echo "expected $url body to contain $expected_content" >&2 + echo "FAIL wait-for-body-contains: url=$url expected-substring=$expected_content" >&2 return 1 fi sleep 1 @@ -90,10 +99,71 @@ assert-location() { grep --ignore-case '^location:' | head --lines 1 | tr -d '\r' | - sed --quiet --expression 's/^[Ll]ocation: //p' + sed --quiet --expression 's/^[Ll]ocation: //p' || true + )" + + if test "$location" != "$expected_location"; then + echo "FAIL assert-location: url=$url expected=$expected_location actual=$location" >&2 + return 1 + fi +} + +assert-status-location() { + local method="$1" + local url="$2" + local expected_status="$3" + local expected_location="$4" + local status + local location + local headers + + headers="$(mktemp)" + status="$( + curl \ + --silent --show-error \ + --request "$method" \ + --dump-header "$headers" \ + --output /dev/null \ + --write-out '%{http_code}' \ + "$url" || true + )" + location="$( + grep --ignore-case '^location:' "$headers" | + head --lines 1 | + tr -d '\r' | + sed --quiet --expression 's/^[Ll]ocation: //p' || true + )" + rm -f "$headers" + + if test "$status" != "$expected_status"; then + echo "FAIL assert-status-location: method=$method url=$url expected-status=$expected_status actual-status=$status expected-location=$expected_location actual-location=$location" >&2 + return 1 + fi + + if test "$location" != "$expected_location"; then + echo "FAIL assert-status-location: method=$method url=$url expected-status=$expected_status actual-status=$status expected-location=$expected_location actual-location=$location" >&2 + return 1 + fi +} + +assert-upstream-count() { + local method="$1" + local path="$2" + local expected_count="$3" + local count + + count="$( + curl \ + --silent --show-error --get \ + --data-urlencode "method=$method" \ + --data-urlencode "path=$path" \ + "$upstream_url/__count" || true )" - test "$location" = "$expected_location" + if test "$count" != "$expected_count"; then + echo "FAIL assert-upstream-count: method=$method path=$path expected=$expected_count actual=$count" >&2 + return 1 + fi } cleanup() { @@ -116,7 +186,7 @@ upstream_url="http://127.0.0.1:18080" nix_store_url="http://127.0.0.1:18082" s3_url="http://127.0.0.1:18081" rocket_toml_path="${ROCKET_TOML_PATH:-Rocket.toml}" -script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +script_dir="$(CDPATH="" cd -- "$(dirname -- "$0")" && pwd)" mirror_intel_pid="" fake_services_pid="" nix_serve_pid="" @@ -178,14 +248,16 @@ assert-body-contains "$base_url/metrics" "resolve_counter" assert-status GET "$base_url/pytorch-wheels/" 200 assert-body-contains "$base_url/pytorch-wheels/" "No route for pytorch-wheels." -assert-status GET "$base_url/pytorch-wheels/torch/?mirror_intel_e2e=1" 302 -assert-location \ +assert-status-location \ + GET \ "$base_url/pytorch-wheels/torch/?mirror_intel_e2e=1" \ + 302 \ "$upstream_url/whl/torch?mirror_intel_e2e=1" -assert-status GET "$base_url/pytorch-wheels/cu130/torch/?mirror_intel_e2e=1" 302 -assert-location \ +assert-status-location \ + GET \ "$base_url/pytorch-wheels/cu130/torch/?mirror_intel_e2e=1" \ + 302 \ "$upstream_url/whl/cu130/torch?mirror_intel_e2e=1" wait-for-status GET "$base_url/pytorch-wheels/cu130/torch/" 302 @@ -196,12 +268,49 @@ assert-body-contains "$base_url/pytorch-wheels/cu130/torch/" "cu130 torch cached assert-body-contains "$s3_url/bucket/pytorch-wheels/cu130/torch" "cu130 torch cached directory index" assert-status HEAD "$base_url/pytorch-wheels/cu130/torch/" 301 +# Cache-classified paths should be guarded by HEAD prefetch before the existing +# smart-cache path is allowed to enqueue downloads or redirect to upstream. +assert-status GET "$base_url/pytorch-wheels/missing-cache-path/" 404 +assert-upstream-count HEAD "/whl/missing-cache-path" 1 +assert-upstream-count GET "/whl/missing-cache-path" 0 + +assert-status-location \ + GET \ + "$base_url/pytorch-wheels/upstream-only/" \ + 302 \ + "$upstream_url/whl/upstream-only" +assert-upstream-count HEAD "/whl/upstream-only" 1 +wait-for-body-contains "$s3_url/bucket/pytorch-wheels/upstream-only" "upstream-only cache fixture" + +# Query-string requests intentionally bypass classification and cache prefetch. +assert-status-location \ + GET \ + "$base_url/pytorch-wheels/missing-query/?mirror_intel_e2e=1" \ + 302 \ + "$upstream_url/whl/missing-query?mirror_intel_e2e=1" +assert-upstream-count HEAD "/whl/missing-query" 0 +assert-upstream-count GET "/whl/missing-query" 0 + +# Redirect-classified paths remain unconditional upstream redirects. +assert-status-location \ + GET \ + "$base_url/pytorch-wheels/missing-redirect.tar.gz" \ + 301 \ + "$upstream_url/whl/missing-redirect.tar.gz" +assert-upstream-count HEAD "/whl/missing-redirect.tar.gz" 0 +assert-upstream-count GET "/whl/missing-redirect.tar.gz" 0 + +# Proxy HEAD is currently synthetic and does not contact upstream. +assert-status HEAD "$base_url/pytorch-wheels/missing-proxy.html" 200 +assert-upstream-count HEAD "/whl/missing-proxy.html" 0 + assert-status GET "$base_url/nix-channels/store/nix-cache-info" 200 assert-body-contains "$base_url/nix-channels/store/nix-cache-info" "StoreDir: /nix/store" -assert-status GET "$base_url/nix-channels/store/$nix_cache_narinfo?mirror_intel_e2e=1" 302 -assert-location \ +assert-status-location \ + GET \ "$base_url/nix-channels/store/$nix_cache_narinfo?mirror_intel_e2e=1" \ + 302 \ "$nix_store_url/$nix_cache_narinfo?mirror_intel_e2e=1" assert-status GET "$base_url/nix-channels/store/$nix_cache_narinfo" 200 @@ -219,5 +328,6 @@ assert-status GET "$base_url/nix-channels/store/$nix_cache_nar_path" 200 wait-for-status GET "$s3_url/bucket/nix-channels/store/$nix_cache_nar_path" 200 assert-status GET "$base_url/nix-channels/store/$nix_cache_nar_path" 200 +# suppress '$out is referenced but not assigned' (Nix setup hooks assigns it) # shellcheck disable=SC2154 touch "$out"