diff --git a/CHANGELOG.md b/CHANGELOG.md index c41cd4df78..a8657b3c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking for crates outside core:** an adapter answers how it names a backend and bounds its transport timers through `PlatformBackend` alone, which gains `auction_backend_budget` and `transport_timeout_bucket_count` with defaults, and describes itself to auction plan validation through an `AuctionTargetDescriptor` it builds from its name, its backend implementation and its `AuctionTargetCapabilities`. `AuctionPlan::validate_for_target` takes that descriptor, and `BackendNamingPolicy`, `AuctionTargetId`, `PredictedBackend`, `BackendNamingError` and `PlatformBackend::naming_policy` are gone from core, because core names no hosting platform. Each adapter carries its own naming policy beside the code that registers its backends, and the names a deployment sees are unchanged. - Core names no platform's cache header. It removes from a response it makes private only `Surrogate-Control`, `CDN-Cache-Control` and a platform header it wrote its own policy in, so on every adapter `fastly-surrogate-control` and `cloudflare-cdn-cache-control` stay where an origin or an operator wrote them, until a fetch middleware removes them, which none in the stack does yet. The Fastly and Cloudflare adapters declare their own platform's header, which keeps an origin response with a policy in it out of the stored copies on that adapter alone, and each adapter writes its policy in the header it wrote before. **For crates outside core**, `EdgeCacheHeader::CloudflareCdnCacheControl` is `EdgeCacheHeader::Platform(name)`, `EDGE_CACHE_HEADER_NAMES` lists only the two standard headers, the two platform header constants leave core, an adapter declares its platform's headers with `platform::declare_platform_cache_headers`, and a policy written with `CachePolicy::apply_to_response` is removed by `cache_policy::remove_owned_cache_headers` when the response is made private. - Auction telemetry is an analytics module from a crate of its own. `[analytics]` is read as every module type's section is, and the Tinybird module, its settings and its checks live in `crates/analytics/tinybird`, which each adapter and the `ts` tool list among the modules they link, where the Fastly adapter alone built it. The Fastly adapter alone hands the module's sink to the auction, so the Axum, Cloudflare and Spin adapters link the module and still send nothing. The settings a deployment writes do not change. A module named in full in its own type's section, as `analytics.tinybird` in `[analytics]`, reads its table without the type folder, at `[analytics.tinybird]`, when the settings load, at deploy and when a stored configuration loads. A table missing its host or token, a key the table does not know and a reserved setting turned on are refused when the deployment starts and by `ts config validate`, naming the table, where they were refused as the settings loaded. A `[tinybird]` table at the top level is refused as the section of a module type nothing supplies. **For crates outside core**, a registration declares the sink it supplies with `IntegrationRegistrationBuilder::with_analytics_module`, and `IntegrationRegistry::analytics_module` gives the one `[analytics] module` selected. `Settings::analytics_module`, `AnalyticsConfig` and `TinybirdSettings` leave core. - **Breaking for crates outside core:** core's auction names no protocol. A demand source implements `CompiledDemand`, which builds its own request from the routed input (`build_request`, answering the body, the headers, the slot ids and any state kept for the answer) and reads its own answer, and core sends what it built. OpenRTB 2.6 is the protocol module `crates/auction-protocol/openrtb`, where the request builder, the response reader, the wire types with their extension objects, and `OpenRtbDemand`, the hook trait a demand implementation that speaks OpenRTB fills in, now live, run through core's seam by `OpenRtbDriver`. `auction::openrtb` and core's `openrtb` module are gone, and core's own manifest keeps the generated types only for the response `POST /auction` returns. The dimension admission helpers are `auction::admission`, `apply_notification_policy` and `ignored_bidder_params_count` are `auction::provider`, and `Eid` and `Uid` are `ec::eids`. A deployment's settings do not change. @@ -118,6 +119,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The origin's address in a React Server Components Flight response is now moved in one pass. A request host that begins with the origin host was matched again inside its own replacement, so `https://publisher.example/` served on `publisher.example.localhost` over `http` came out as `http://publisher.example.localhost.localhost.localhost/`, and on `publisher.example:8443` over `https` as `https://publisher.example:8443:8443/`. - Server-side ad template bids now always carry `hb_adid` in `window.tsjs.bids`. Bidders that return neither a Prebid Cache UUID nor an `adid` previously produced no `hb_adid` at all, so no `hb_adid` GPT targeting key was set and the Universal Creative render bridge had nothing to match — the winning creative never rendered. The OpenRTB bid `id`, which is mandatory per spec, is now the last-resort source; `cache_id` and `adid` still take priority where present. Blank `cacheId`/`adid` values no longer win that precedence and emit an unusable empty `hb_adid`, and `hb_cache_host`/`hb_cache_path` are now emitted only alongside a real Prebid Cache UUID — without one they pointed the Universal Creative at a guaranteed cache miss instead of letting it fall through to the inline creative. - The origin's address in a CSS, JavaScript, JSON or other text response that core rewrites without parsing it is now replaced in one pass. A request host that contains the origin host was matched again inside its own replacement, so `https://example.com/` served on `www.example.com` came out as `https://www.www.example.com/`, and `https://publisher.example/` served on `publisher.example.localhost` over `http` as `http://publisher.example.localhost.localhost.localhost.localhost/`. +- The Axum adapter warns once at startup when `proxy.certificate_check` is `false`, which it does not honor because its HTTP client always verifies HTTPS origin certificates. The configuration and Axum guides say that only the Fastly adapter honors `false`, and that the Cloudflare and Spin adapters use the setting only in a backend's name ([#1132](https://github.com/IABTechLab/trusted-server/issues/1132)). - A request that names its host only in its URI's authority, as an HTTP/2 request with no `Host` header may, is now processed. The request host was read from `Forwarded`, `X-Forwarded-Host` and `Host` alone, so such a request had an empty host and its page was returned unmodified, with no URL rewriting, middleware, injected script or auction. The authority is read last, so each of those headers still takes precedence ([#1118](https://github.com/IABTechLab/trusted-server/issues/1118)). ### Added diff --git a/Cargo.lock b/Cargo.lock index 009ce86797..832f667968 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6738,6 +6738,7 @@ dependencies = [ "log-fastly", "serde", "serde_json", + "sha2 0.11.1", "toml", "trusted-server-ad-server-iabtl", "trusted-server-ad-tag-google", diff --git a/crates/analytics/tinybird/src/lib.rs b/crates/analytics/tinybird/src/lib.rs index 3c11292ca0..55e43ce583 100644 --- a/crates/analytics/tinybird/src/lib.rs +++ b/crates/analytics/tinybird/src/lib.rs @@ -443,9 +443,9 @@ mod tests { use trusted_server_core::config_payload::settings_from_config_blob_with; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, + ClientInfo, PlatformBackend, PlatformConfigStore, PlatformError, PlatformGeo, + PlatformHttpClient, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, + PlatformSelectResult, RuntimeServices, StoreId, StoreName, }; use trusted_server_core::test_support::template::{ template_with_resolved_required_secrets, uncomment_block, @@ -539,10 +539,6 @@ mod tests { } impl PlatformBackend for RecordingBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Fastly - } - fn predict_name( &self, _spec: &PlatformBackendSpec, diff --git a/crates/auction/aps/src/lib.rs b/crates/auction/aps/src/lib.rs index d271baf327..5dbe9c76ab 100644 --- a/crates/auction/aps/src/lib.rs +++ b/crates/auction/aps/src/lib.rs @@ -3627,10 +3627,10 @@ mod engine_tests { AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, MediaType, PublisherInfo, UserInfo, }; + use trusted_server_core::platform::PlatformResponse; use trusted_server_core::platform::test_support::{ NamingBackend, StubHttpClient, build_services_with_backend_and_http_client, }; - use trusted_server_core::platform::{BackendNamingPolicy, PlatformResponse}; use trusted_server_core::test_support::tests::create_test_settings; use super::{APS_RENDERER_TYPE, ApsRendererV1, MODULE, builder}; @@ -4131,7 +4131,7 @@ mod engine_tests { async fn planned_aps_transport_omits_accept_header() { let http = Arc::new(StubHttpClient::new()); http.push_response(400, Vec::new()); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -4212,7 +4212,7 @@ mod engine_tests { ("authorization", "fictional-secret"), ], ); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -4336,7 +4336,7 @@ mod engine_tests { .expect("should serialize APS instance response"), ); } - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -4573,7 +4573,7 @@ mod engine_tests { let (profile, provider_id) = ("aps", "aps_instance"); let http = Arc::new(StubHttpClient::new()); http.push_response(200, vec![b'x'; 1024 * 1024 + 1]); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, diff --git a/crates/auction/prebid-server/src/lib.rs b/crates/auction/prebid-server/src/lib.rs index 2de674fa9f..e2f3d1cc27 100644 --- a/crates/auction/prebid-server/src/lib.rs +++ b/crates/auction/prebid-server/src/lib.rs @@ -2898,10 +2898,6 @@ mod tests { struct PredictOnlyBackend; impl PlatformBackend for PredictOnlyBackend { - fn naming_policy(&self) -> trusted_server_core::platform::BackendNamingPolicy { - trusted_server_core::platform::BackendNamingPolicy::Axum - } - fn predict_name( &self, spec: &PlatformBackendSpec, @@ -7449,7 +7445,6 @@ mod engine_tests { }; use trusted_server_core::consent::jurisdiction::Jurisdiction; use trusted_server_core::consent::{ConsentContext, ConsentSource, SignalRecord, SignalStatus}; - use trusted_server_core::platform::BackendNamingPolicy; use trusted_server_core::platform::test_support::{ NamingBackend, StubHttpClient, build_services_with_backend_and_http_client, }; @@ -8089,7 +8084,7 @@ mod engine_tests { })) .expect("should serialize PBS response b"), ); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, diff --git a/crates/shared/51degrees/src/test_services.rs b/crates/shared/51degrees/src/test_services.rs index d278f8799a..db13b013cc 100644 --- a/crates/shared/51degrees/src/test_services.rs +++ b/crates/shared/51degrees/src/test_services.rs @@ -15,8 +15,8 @@ use edgezero_core::body::Body as EdgeBody; use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; use error_stack::Report; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeoModule, PlatformHttpClient, PlatformHttpRequest, + ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformGeoModule, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, PlatformValueCache, RuntimeServices, StoreId, StoreName, UnavailableKvStore, }; @@ -71,10 +71,6 @@ impl PlatformSecretStore for StubStore { pub struct ResolvingBackend; impl PlatformBackend for ResolvingBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Axum - } - fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { Ok("fiftyone_degrees_test".to_owned()) } diff --git a/crates/testing/iabtl/src/lib.rs b/crates/testing/iabtl/src/lib.rs index ca9276b2b9..455af7c029 100644 --- a/crates/testing/iabtl/src/lib.rs +++ b/crates/testing/iabtl/src/lib.rs @@ -1097,9 +1097,8 @@ mod tests { use sha2::{Digest as _, Sha256}; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformSecretStore, StoreId, StoreName, UnavailableHttpClient, - UnavailableKvStore, + ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformSecretStore, StoreId, StoreName, UnavailableHttpClient, UnavailableKvStore, }; /// Config store that answers nothing, so a test can build @@ -1162,10 +1161,6 @@ mod tests { struct StubBackend; impl PlatformBackend for StubBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Axum - } - fn predict_name( &self, _spec: &PlatformBackendSpec, diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index cea59103b6..e0b2e5d773 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -244,7 +244,7 @@ fn build_state_with_registrations_and_services( // here. Compiling without them would drop the implementation and report the // name as one no builder registers. let plan = Arc::new(compile_auction_plan_with(&settings, integrations)?); - plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; + plan.validate_for_target(&crate::platform::auction_target())?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan))?; let registry = IntegrationRegistry::with_plan_and_registrations(&settings, plan, integrations)? .with_platform(PlatformIdentity::named("axum")); @@ -291,6 +291,7 @@ fn build_state_with_registrations_and_services( "Axum", &UNSENT_ANALYTICS_LOGGED, ); + warn_when_certificate_check_is_off(&settings); let permission_policy = settings.required_permission_policy()?; Ok(Arc::new(AppState { @@ -304,6 +305,21 @@ fn build_state_with_registrations_and_services( })) } +/// Warns that `proxy.certificate_check = false` is not honored here. +/// +/// This adapter's HTTP client always verifies an HTTPS origin's certificate, +/// so a deployment that turned the check off for a self-signed origin would +/// otherwise see only failed requests. The state is built once for the +/// process, so this warns once, at startup. +fn warn_when_certificate_check_is_off(settings: &Settings) { + if !settings.proxy.certificate_check { + log::warn!( + "proxy.certificate_check is false, but the Axum adapter does not honor it \ + and still verifies every HTTPS origin's certificate" + ); + } +} + /// Refuses `[geo] module = "platform"`, because this host has no geo lookup. /// /// The selection would locate no request and still count as a geo module, @@ -1054,6 +1070,96 @@ mod tests { assert_eq!(services.public_prefix().as_str(), "ts"); } + /// What `run` returns, and each line it logs on this thread at warning + /// level or above, as the level and then the message. + fn warnings_logged_by(run: impl FnOnce() -> T) -> (T, Vec) { + use std::cell::RefCell; + use std::sync::Once; + + thread_local! { + static CAPTURED: RefCell>> = const { RefCell::new(None) }; + } + + struct ThreadLogger; + + impl log::Log for ThreadLogger { + fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool { + CAPTURED.with(|captured| captured.borrow().is_some()) + } + + fn log(&self, record: &log::Record<'_>) { + CAPTURED.with(|captured| { + if let Some(lines) = captured.borrow_mut().as_mut() { + lines.push(format!("{} {}", record.level(), record.args())); + } + }); + } + + fn flush(&self) {} + } + + static LOGGER: ThreadLogger = ThreadLogger; + static INSTALL: Once = Once::new(); + + INSTALL.call_once(|| { + log::set_logger(&LOGGER).expect("should be the only logger these tests install"); + log::set_max_level(log::LevelFilter::Warn); + }); + CAPTURED.with(|captured| *captured.borrow_mut() = Some(Vec::new())); + let output = run(); + let lines = CAPTURED.with(|captured| captured.borrow_mut().take().unwrap_or_default()); + (output, lines) + } + + /// This adapter's HTTP client always verifies an origin's certificate, so + /// startup with `proxy.certificate_check = false` warns once that the + /// setting is not honored, and startup with the check on warns nothing. + #[test] + fn startup_warns_once_that_turning_the_certificate_check_off_is_not_honored() { + let warnings_naming_the_check = |certificate_check: bool| { + let settings = Settings::from_toml(&format!( + r#" + [source] + module = "origin" + + [source.origin] + url = "https://origin.publisher.example" + + [publisher] + domain = "www.publisher.example" + cookie_domain = ".publisher.example" + proxy_secret = "fictional-proxy-secret-32-bytes-ok" + + [proxy] + certificate_check = {certificate_check} + + [geo] + assume_single_jurisdiction = true + "# + )) + .expect("should parse the settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); + let (state, lines) = warnings_logged_by(|| build_state_with_settings(settings)); + state.expect("should build the state"); + lines + .into_iter() + .filter(|line| line.contains("proxy.certificate_check")) + .collect::>() + }; + + let off = warnings_naming_the_check(false); + assert_eq!(off.len(), 1, "should warn once: {off:?}"); + assert!( + off[0].starts_with("WARN ") && off[0].contains("still verifies"), + "should say certificates are still verified: {off:?}" + ); + assert!( + warnings_naming_the_check(true).is_empty(), + "should not warn while the check is on" + ); + } + /// Builds application state directly, bypassing the composition root's /// startup check, so the per-request behavior can be exercised with a /// selection the adapter cannot supply. diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 935e5c1d21..30dbe3997b 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -11,10 +11,10 @@ use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; use error_stack::{Report, ResultExt as _}; use trusted_server_core::permissions::PolicyReference; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, - PlatformPendingRequest, PlatformPolicySource, PlatformResponse, PlatformSecretStore, - PlatformSelectResult, RuntimeServices, StoreId, StoreName, + AuctionTargetCapabilities, AuctionTargetDescriptor, ClientInfo, GeoInfo, PlatformBackend, + PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, + PlatformHttpRequest, PlatformPendingRequest, PlatformPolicySource, PlatformResponse, + PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -233,19 +233,34 @@ fn is_plain_file_stem(name: &str) -> bool { /// No-op backend for the Axum dev server. /// /// Returns a deterministic name; `ensure` is a no-op returning the same name. -/// The Axum HTTP client sends directly to URIs and ignores backend names. +/// The Axum HTTP client sends directly to URIs and ignores backend names, so +/// the name is a correlation key alone, in the shape of an environment +/// variable segment, and the transport timer is the exact bounded value. pub struct AxumPlatformBackend; -impl PlatformBackend for AxumPlatformBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Axum - } +/// The correlation name for a backend: scheme, host and port as environment +/// segments, then the discriminator so two providers behind one host differ. +fn backend_name(spec: &PlatformBackendSpec) -> String { + let port = spec + .port + .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); + let discriminator = spec + .discriminator + .as_deref() + .map(|value| format!("_p_{}", normalize_env_segment(value))) + .unwrap_or_default(); + format!( + "{}_{}_{}{}", + normalize_env_segment(&spec.scheme), + normalize_env_segment(&spec.host), + port, + discriminator + ) +} +impl PlatformBackend for AxumPlatformBackend { fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { - self.naming_policy() - .predict(spec) - .map(|prediction| prediction.name) - .change_context(PlatformError::Backend) + Ok(backend_name(spec)) } fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { @@ -253,6 +268,17 @@ impl PlatformBackend for AxumPlatformBackend { } } +/// This adapter as auction plan validation sees it. The dev server can fan +/// out to several providers at once, and its transport timeout is not a +/// total request deadline. +pub(crate) fn auction_target() -> AuctionTargetDescriptor { + AuctionTargetDescriptor::new( + "axum", + Arc::new(AxumPlatformBackend), + AuctionTargetCapabilities::new(true, false), + ) +} + // --------------------------------------------------------------------------- // PlatformGeo // --------------------------------------------------------------------------- @@ -802,9 +828,14 @@ mod tests { #[test] fn auction_http_capabilities_are_explicit() { let client = AxumPlatformHttpClient::new(); - let capabilities = trusted_server_core::platform::AuctionTargetId::Axum - .descriptor() - .capabilities(); + let target = auction_target(); + let capabilities = target.capabilities(); + assert_eq!(target.id(), "axum"); + assert_eq!( + target.backend().auction_backend_budget(), + None, + "a correlation name is not a limited resource" + ); assert!(client.supports_concurrent_fanout()); assert!(capabilities.supports_concurrent_provider_fanout()); assert!(!client.has_enforceable_total_request_deadline()); @@ -814,6 +845,34 @@ mod tests { ); } + #[test] + fn the_backend_name_and_transport_timer_are_pinned() { + let backend = AxumPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_owned(), + host: "origin.example.com".to_owned(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(1500), + between_bytes_timeout: Duration::from_millis(1500), + discriminator: Some("provider-one".to_owned()), + }; + assert_eq!( + backend + .predict_name(&spec) + .expect("should name the backend"), + "HTTPS_ORIGIN_EXAMPLE_COM_443_p_PROVIDER_ONE" + ); + assert_eq!( + backend.ensure(&spec).expect("should register nothing"), + "HTTPS_ORIGIN_EXAMPLE_COM_443_p_PROVIDER_ONE" + ); + assert_eq!(backend.canonicalize_transport_timeout_ms(999, 2000), 999); + assert_eq!(backend.canonicalize_transport_timeout_ms(2000, 100), 100); + assert_eq!(backend.transport_timeout_bucket_count(2000), 1); + } + #[test] fn config_env_var_normalizes_store_and_key() { assert_eq!( diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 2a8b23bb5f..7e8f1eb18e 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -25,9 +25,9 @@ use trusted_server_core::integrations::{ }; use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeoModule, PlatformSecretStore, RuntimeServices, - StoreId, StoreName, UnavailableHttpClient, UnavailableKvStore, + ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformGeoModule, PlatformSecretStore, RuntimeServices, StoreId, StoreName, + UnavailableHttpClient, UnavailableKvStore, }; use trusted_server_core::settings::Settings; use trusted_server_core::tsjs::tsjs_script_src; @@ -1593,10 +1593,6 @@ impl PlatformSecretStore for StubSecretStore { struct StubBackend; impl PlatformBackend for StubBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Axum - } - fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { Err(Report::new(PlatformError::Unsupported)) } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 9f27551663..7aaf5a2db1 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -244,7 +244,7 @@ fn build_state_with_registrations_and_services( // here. Compiling without them would drop the implementation and report the // name as one no builder registers. let plan = Arc::new(compile_auction_plan_with(&settings, integrations)?); - plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?; + plan.validate_for_target(&crate::platform::auction_target())?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan))?; let registry = IntegrationRegistry::with_plan_and_registrations(&settings, plan, integrations)? .with_platform(PlatformIdentity::named("cloudflare")); diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 90bb14533d..94332e9be3 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -5,11 +5,14 @@ use std::time::Duration; use bytes::Bytes; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; -use error_stack::{Report, ResultExt as _}; +use error_stack::Report; +#[cfg(target_arch = "wasm32")] +use error_stack::ResultExt as _; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, - PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, + AuctionTargetCapabilities, AuctionTargetDescriptor, ClientInfo, GeoInfo, KvError, + PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, + PlatformHttpClient, PlatformKvStore, PlatformSecretStore, RuntimeServices, StoreId, StoreName, + UnavailableKvStore, }; #[cfg(not(target_arch = "wasm32"))] @@ -56,18 +59,40 @@ impl PlatformSecretStore for NoopSecretStore { } } +/// Workers fetch registers no backend, so the name is a correlation key +/// alone, made from every field of the specification, and the transport +/// timer is the exact bounded value. struct NoopBackend; -impl PlatformBackend for NoopBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Cloudflare - } +/// The correlation name for a backend, carrying the first-byte timer so two +/// timers to one host are two backends, as the host's own naming would make +/// them. +fn backend_name(spec: &PlatformBackendSpec) -> String { + let port = spec + .port + .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); + let cert_suffix = if spec.certificate_check { + "" + } else { + "_nocert" + }; + let discriminator = spec + .discriminator + .as_deref() + .map(|value| format!("_p_{value}")) + .unwrap_or_default(); + format!( + "{}_{}_{}_{}ms{cert_suffix}{discriminator}", + spec.scheme, + spec.host, + port, + spec.first_byte_timeout.as_millis() + ) +} +impl PlatformBackend for NoopBackend { fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { - self.naming_policy() - .predict(spec) - .map(|prediction| prediction.name) - .change_context(PlatformError::Backend) + Ok(backend_name(spec)) } fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { @@ -75,6 +100,16 @@ impl PlatformBackend for NoopBackend { } } +/// This adapter as auction plan validation sees it. Workers fetch runs one +/// provider request at a time, and it exposes no total request deadline. +pub(crate) fn auction_target() -> AuctionTargetDescriptor { + AuctionTargetDescriptor::new( + "cloudflare", + Arc::new(NoopBackend), + AuctionTargetCapabilities::new(false, false), + ) +} + // --------------------------------------------------------------------------- // edgezero handle adapters — no #[cfg] needed; platform-specific store // construction is handled by EdgeZero's dispatch before we receive the ctx. @@ -813,15 +848,48 @@ mod tests { #[test] fn auction_http_capabilities_are_explicit() { - let capabilities = trusted_server_core::platform::AuctionTargetId::Cloudflare - .descriptor() - .capabilities(); + let target = auction_target(); + let capabilities = target.capabilities(); + assert_eq!(target.id(), "cloudflare"); + assert_eq!( + target.backend().auction_backend_budget(), + None, + "a correlation name is not a limited resource" + ); assert!(!capabilities.supports_concurrent_provider_fanout()); assert!( !capabilities.has_enforceable_total_request_deadline(), "Workers fetch does not expose an enforceable hard total request deadline" ); } + + #[test] + fn the_backend_name_and_transport_timer_are_pinned() { + let backend = NoopBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_owned(), + host: "origin.example.com".to_owned(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(1500), + between_bytes_timeout: Duration::from_millis(1500), + discriminator: Some("provider-one".to_owned()), + }; + assert_eq!( + backend + .predict_name(&spec) + .expect("should name the backend"), + "https_origin.example.com_443_1500ms_p_provider-one" + ); + assert_eq!( + backend.ensure(&spec).expect("should register nothing"), + "https_origin.example.com_443_1500ms_p_provider-one" + ); + assert_eq!(backend.canonicalize_transport_timeout_ms(999, 2000), 999); + assert_eq!(backend.canonicalize_transport_timeout_ms(2000, 100), 100); + assert_eq!(backend.transport_timeout_bucket_count(2000), 1); + } use edgezero_core::http::{HeaderValue, request_builder}; use edgezero_core::params::PathParams; diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index a2d33fdc2d..362a1fe4f8 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -33,6 +33,7 @@ log = { workspace = true } log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } trusted-server-ad-server-iabtl = { workspace = true } trusted-server-ad-tag-google = { workspace = true } trusted-server-ads-txt-iabtl = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index a25ac4965a..273d622ffc 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -344,7 +344,7 @@ pub(crate) fn build_state_with_registrations( // here. Compiling without them would drop the implementation and report the // name as one no builder registers. let plan = Arc::new(compile_auction_plan_with(&settings, integrations)?); - plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Fastly)?; + plan.validate_for_target(&crate::platform::auction_target())?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan))?; let registry = IntegrationRegistry::with_plan_and_registrations(&settings, plan, integrations)? .with_platform(fastly_platform_identity()); @@ -3115,10 +3115,6 @@ mod tests { struct FixedBackend; impl PlatformBackend for FixedBackend { - fn naming_policy(&self) -> trusted_server_core::platform::BackendNamingPolicy { - trusted_server_core::platform::BackendNamingPolicy::Fastly - } - fn predict_name( &self, spec: &PlatformBackendSpec, diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index d61d090ca8..94709ea855 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -1,15 +1,262 @@ +//! Fastly dynamic backends: how one is named and how it is registered. +//! +//! The naming policy calls no SDK, so startup validation predicts the same +//! names registration makes, and the transport timer a name carries is +//! rounded to a bounded ladder so budget-derived timers cannot register a +//! backend per request. + +use core::fmt::Write as _; use std::time::Duration; use error_stack::{Report, ResultExt as _}; use fastly::backend::Backend; +use sha2::{Digest as _, Sha256}; use trusted_server_core::error::TrustedServerError; -use trusted_server_core::platform::{BackendNamingPolicy, PlatformBackendSpec, PredictedBackend}; +use trusted_server_core::host_header::validate_host_header_override_value; +use trusted_server_core::platform::PlatformBackendSpec; -#[cfg(test)] +/// Fastly's documented limit on a dynamic backend name. const MAX_BACKEND_NAME_LEN: usize = 255; -#[cfg(test)] +/// The readable prefix is cut here so the name stays under the limit. +const MAX_READABLE_PREFIX_LEN: usize = 200; const SPEC_DIGEST_HEX_LEN: usize = 32; +pub(crate) const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; +const TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS: u32 = 2000; +pub(crate) const SUB_QUANTUM_LADDER_MS: [u32; 4] = [200, 150, 100, 50]; +const TRANSPORT_TIMEOUT_COARSE_LADDER_MS: [u32; 8] = + [2000, 3000, 5000, 10000, 20000, 30000, 45000, 60000]; +/// Fastly's documented per-service limit on dynamic backends. +const DYNAMIC_BACKEND_LIMIT: usize = 200; +/// Backend names kept for traffic that is not the auction. +const NON_AUCTION_BACKEND_RESERVE: usize = 40; +/// Backend names the auction may register on this service. +pub(crate) const AUCTION_BACKEND_BUDGET: usize = + DYNAMIC_BACKEND_LIMIT - NON_AUCTION_BACKEND_RESERVE; + +/// A backend name and port predicted without platform I/O. +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) struct PredictedBackend { + /// The dynamic backend name. + pub(crate) name: String, + /// Explicit or scheme-derived target port. + pub(crate) port: u16, +} + +/// Why a backend specification cannot be named. +#[derive(Debug, Clone, Eq, PartialEq, derive_more::Display)] +pub(crate) enum BackendNamingError { + /// The backend host is empty. + #[display("missing host")] + MissingHost, + /// A field contains a control character. + #[display("{field} contains control characters")] + ControlCharacters { field: &'static str }, + /// An outbound Host override is invalid. + #[display("host header override {reason}")] + InvalidHostHeaderOverride { reason: &'static str }, + /// A generated backend name exceeded the documented limit. + #[display("backend name exceeds {limit}-char limit ({actual} chars)")] + NameTooLong { limit: usize, actual: usize }, +} + +impl core::error::Error for BackendNamingError {} + +fn sanitize_component(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '_' + } + }) + .collect() +} + +/// A bracketed IPv6 literal and its bare form name the same backend. +fn canonical_host(host: &str) -> &str { + host.strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .filter(|value| value.parse::().is_ok()) + .unwrap_or(host) +} + +/// Every field of the specification, length-prefixed, so two specifications +/// that sanitize alike still digest apart. +fn canonical_spec(spec: &PlatformBackendSpec, target_port: u16) -> String { + fn push_field(buffer: &mut String, field: &str) { + buffer.push_str(&field.len().to_string()); + buffer.push(':'); + buffer.push_str(field); + } + + let mut buffer = String::new(); + push_field(&mut buffer, &spec.scheme); + push_field(&mut buffer, canonical_host(&spec.host)); + push_field(&mut buffer, &target_port.to_string()); + push_field(&mut buffer, if spec.certificate_check { "1" } else { "0" }); + match spec.host_header_override.as_deref() { + Some(value) => { + buffer.push('s'); + push_field(&mut buffer, value); + } + None => buffer.push('n'), + } + match spec.discriminator.as_deref() { + Some(value) => { + buffer.push('s'); + push_field(&mut buffer, value); + } + None => buffer.push('n'), + } + push_field( + &mut buffer, + &spec.first_byte_timeout.as_millis().to_string(), + ); + push_field( + &mut buffer, + &spec.between_bytes_timeout.as_millis().to_string(), + ); + buffer +} + +fn spec_digest(canonical: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + let mut hex = String::with_capacity(SPEC_DIGEST_HEX_LEN); + for byte in digest.iter().take(SPEC_DIGEST_HEX_LEN / 2) { + write!(hex, "{byte:02x}").expect("should write hex digit to string"); + } + hex +} + +/// Predict the dynamic backend name and resolved port for a specification +/// without registering anything. +/// +/// The name is a readable prefix of the specification, cut to a bound, and a +/// digest of the whole specification, so it stays within Fastly's limit and +/// two specifications never share a name. +pub(crate) fn predict_backend( + spec: &PlatformBackendSpec, +) -> Result> { + if spec.host.is_empty() { + return Err(Report::new(BackendNamingError::MissingHost)); + } + if spec.host.chars().any(char::is_control) { + return Err(Report::new(BackendNamingError::ControlCharacters { + field: "host", + })); + } + if spec.scheme.chars().any(char::is_control) { + return Err(Report::new(BackendNamingError::ControlCharacters { + field: "scheme", + })); + } + if let Some(host_header_override) = spec.host_header_override.as_deref() { + validate_host_header_override_value(host_header_override).map_err(|reason| { + Report::new(BackendNamingError::InvalidHostHeaderOverride { reason }) + })?; + } + + let port = spec + .port + .unwrap_or_else(|| default_port_for_scheme(&spec.scheme)); + let name_base = format!("{}_{}_{}", spec.scheme, canonical_host(&spec.host), port); + let host_override_suffix = spec + .host_header_override + .as_deref() + .map(|host| format!("_oh_{}", sanitize_component(host))) + .unwrap_or_default(); + let cert_suffix = if spec.certificate_check { + "" + } else { + "_nocert" + }; + let discriminator_suffix = spec + .discriminator + .as_deref() + .map(|value| format!("_p_{}", sanitize_component(value))) + .unwrap_or_default(); + let readable_full = format!( + "{}{}{}{}_fb{}_bb{}", + sanitize_component(&name_base), + host_override_suffix, + cert_suffix, + discriminator_suffix, + spec.first_byte_timeout.as_millis(), + spec.between_bytes_timeout.as_millis() + ); + let readable = readable_full + .chars() + .take(MAX_READABLE_PREFIX_LEN) + .collect::(); + let digest = spec_digest(&canonical_spec(spec, port)); + let name = format!("backend_{readable}_{digest}"); + if name.len() > MAX_BACKEND_NAME_LEN { + return Err(Report::new(BackendNamingError::NameTooLong { + limit: MAX_BACKEND_NAME_LEN, + actual: name.len(), + })); + } + + Ok(PredictedBackend { name, port }) +} + +/// Round a budget-bound transport timer to a bounded ladder without +/// extending it past the remaining budget. +/// +/// The timer is part of the backend name, so every distinct value is a +/// distinct registration. A configured timer the budget allows passes +/// through unchanged, a budget below it floors to a quantum, a budget under +/// one quantum snaps down to a short ladder, and a budget above the quantum +/// ceiling snaps down to a coarse ladder. +pub(crate) fn canonicalize_transport_timeout_ms(remaining_ms: u32, configured_ms: u32) -> u32 { + if remaining_ms >= configured_ms { + return configured_ms; + } + if remaining_ms >= TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS { + return TRANSPORT_TIMEOUT_COARSE_LADDER_MS + .into_iter() + .rev() + .find(|&rung| rung <= remaining_ms) + .unwrap_or(TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS); + } + let floored = (remaining_ms / TRANSPORT_TIMEOUT_QUANTUM_MS) * TRANSPORT_TIMEOUT_QUANTUM_MS; + if floored > 0 { + return floored; + } + SUB_QUANTUM_LADDER_MS + .into_iter() + .find(|&rung| rung <= remaining_ms) + .unwrap_or(0) +} + +/// Count every transport timer, and so every backend name, one provider +/// with this configured timer can reach through canonicalization. +pub(crate) fn transport_timeout_bucket_count(configured_ms: u32) -> usize { + if configured_ms == 0 { + return 1; + } + let mut buckets = std::collections::BTreeSet::new(); + buckets.insert(configured_ms); + let reachable = SUB_QUANTUM_LADDER_MS + .into_iter() + .chain( + (TRANSPORT_TIMEOUT_QUANTUM_MS..TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS) + .step_by(TRANSPORT_TIMEOUT_QUANTUM_MS as usize), + ) + .chain(TRANSPORT_TIMEOUT_COARSE_LADDER_MS); + for remaining_ms in reachable { + let timeout = canonicalize_transport_timeout_ms(remaining_ms, configured_ms); + if timeout > 0 { + buckets.insert(timeout); + } + } + buckets.len() +} /// Returns the default port for the given scheme (443 for HTTPS, 80 for HTTP). #[inline] @@ -189,11 +436,9 @@ impl<'a> BackendConfig<'a> { /// Compute the deterministic backend name and resolved port without /// registering anything. fn predict_backend(&self) -> Result> { - BackendNamingPolicy::Fastly - .predict(&self.platform_spec()) - .change_context(TrustedServerError::Proxy { - message: "backend name prediction failed".to_owned(), - }) + predict_backend(&self.platform_spec()).change_context(TrustedServerError::Proxy { + message: "backend name prediction failed".to_owned(), + }) } /// Return the deterministic backend name without registering anything. @@ -276,13 +521,102 @@ impl<'a> BackendConfig<'a> { #[cfg(test)] mod tests { - use trusted_server_core::platform::BackendNamingError; + use std::time::Duration; + + use trusted_server_core::platform::PlatformBackendSpec; use super::{ - BackendConfig, MAX_BACKEND_NAME_LEN, SPEC_DIGEST_HEX_LEN, compute_host_header, - normalize_backend_host, + AUCTION_BACKEND_BUDGET, BackendConfig, BackendNamingError, MAX_BACKEND_NAME_LEN, + PredictedBackend, SPEC_DIGEST_HEX_LEN, canonical_host, canonicalize_transport_timeout_ms, + compute_host_header, normalize_backend_host, predict_backend, + transport_timeout_bucket_count, }; + fn spec() -> PlatformBackendSpec { + PlatformBackendSpec { + scheme: "https".to_owned(), + host: "origin.example.com".to_owned(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(1500), + between_bytes_timeout: Duration::from_millis(1500), + discriminator: Some("provider-one".to_owned()), + } + } + + #[test] + fn the_predicted_name_is_pinned() { + assert_eq!( + predict_backend(&spec()).expect("should predict the backend"), + PredictedBackend { + name: "backend_https_origin_example_com_443_p_provider-one_fb1500_bb1500_51fb8dba14db39e759e2c1c5d204a6eb".to_owned(), + port: 443, + } + ); + } + + #[test] + fn prediction_canonicalizes_only_bracketed_ipv6_hosts() { + let mut bare = spec(); + bare.host = "2001:db8::1".to_string(); + bare.port = Some(8443); + let mut bracketed = bare.clone(); + bracketed.host = "[2001:db8::1]".to_string(); + + let bare_prediction = predict_backend(&bare).expect("should predict bare IPv6 backend"); + let bracketed_prediction = + predict_backend(&bracketed).expect("should predict bracketed IPv6 backend"); + assert_eq!(bare_prediction, bracketed_prediction); + + assert_eq!(canonical_host("origin.example.com"), "origin.example.com"); + assert_eq!(canonical_host("192.0.2.1"), "192.0.2.1"); + assert_eq!(canonical_host("[not-ipv6]"), "[not-ipv6]"); + } + + #[test] + fn prediction_refuses_a_control_character_in_the_scheme() { + let mut spec = spec(); + spec.scheme = "ht\ntps".to_owned(); + let err = predict_backend(&spec).expect_err("should refuse a scheme with a newline"); + assert_eq!( + err.current_context(), + &BackendNamingError::ControlCharacters { field: "scheme" } + ); + } + + #[test] + fn the_transport_timer_keeps_the_logical_distinction() { + assert_eq!(canonicalize_transport_timeout_ms(999, 2000), 750); + assert_eq!(canonicalize_transport_timeout_ms(2000, 100), 100); + } + + #[test] + fn the_bucket_count_covers_every_reachable_timer() { + assert_eq!( + transport_timeout_bucket_count(0), + 1, + "a zero timer reaches one name" + ); + let configured = 2000; + let mut reachable = std::collections::BTreeSet::new(); + for remaining in 0..=configured { + let value = canonicalize_transport_timeout_ms(remaining, configured); + if value > 0 { + reachable.insert(value); + } + } + assert_eq!( + transport_timeout_bucket_count(configured), + reachable.len(), + "the count should equal the distinct timers a 2000ms ceiling can reach" + ); + assert!( + transport_timeout_bucket_count(60_000) * 2 <= AUCTION_BACKEND_BUDGET, + "two providers with a 60s ceiling should fit the auction budget" + ); + } + /// Assert a computed name is `backend__` and stays within /// Fastly's length limit. The digest is what makes the name injective, so /// checking its presence and width guards the collision-safety property. @@ -535,8 +869,6 @@ mod tests { #[test] fn different_timeouts_produce_different_names() { - use std::time::Duration; - let name_a = BackendConfig::new("https", "origin.example.com") .first_byte_timeout(Duration::from_secs(2)) .predict_name() @@ -561,8 +893,6 @@ mod tests { #[test] fn different_between_bytes_timeouts_produce_different_names() { - use std::time::Duration; - let name_a = BackendConfig::new("https", "origin.example.com") .between_bytes_timeout(Duration::from_secs(2)) .predict_name() diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index ac2edc447f..006785595c 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -6,17 +6,18 @@ use error_stack::{Report, ResultExt}; use fastly::{ConfigStore, Request, SecretStore}; use std::io::Read as _; use std::net::IpAddr; +use std::sync::Arc; -use crate::backend::BackendConfig; +use crate::backend::{self, BackendConfig}; use trusted_server_core::permissions::PolicyReference; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformCacheIntent, - PlatformConfigStore, PlatformError, PlatformHttpClient, PlatformHttpRequest, - PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, - PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformPendingRequest, - PlatformPolicySource, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, - StoreName, policy_entry_text, policy_key, + AuctionTargetCapabilities, AuctionTargetDescriptor, ClientInfo, PlatformBackend, + PlatformBackendSpec, PlatformCacheIntent, PlatformConfigStore, PlatformError, + PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, + PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, + PlatformImageOptimizerRegion, PlatformPendingRequest, PlatformPolicySource, PlatformResponse, + PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, policy_entry_text, policy_key, }; use trusted_server_core::settings::TrustedClientIpConfig; use trusted_server_core::settings_data::resolve_fastly_chunk_pointer; @@ -166,15 +167,14 @@ impl PlatformSecretStore for FastlyPlatformSecretStore { /// Fastly dynamic-backend implementation of [`PlatformBackend`]. /// -/// Delegates name computation and registration to [`BackendConfig`], preserving -/// the existing deterministic naming scheme (scheme + host + port + cert + -/// timeout → unique name). +/// Delegates naming and registration to [`BackendConfig`], and answers the +/// transport timer, the backend budget and the timer buckets from the same +/// policy in `backend`, so the names startup validation predicts through +/// [`auction_target`] are the names this registers. pub struct FastlyPlatformBackend; #[cfg(test)] -const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; -#[cfg(test)] -const SUB_QUANTUM_LADDER_MS: [u32; 4] = [200, 150, 100, 50]; +use crate::backend::{SUB_QUANTUM_LADDER_MS, TRANSPORT_TIMEOUT_QUANTUM_MS}; fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { BackendConfig::new(&spec.scheme, &spec.host) @@ -187,10 +187,6 @@ fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { } impl PlatformBackend for FastlyPlatformBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Fastly - } - fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { // Use the same host normalization as registration. In particular, // URL-derived IPv6 hosts arrive bracketed, but both forms must predict @@ -205,6 +201,29 @@ impl PlatformBackend for FastlyPlatformBackend { .ensure() .change_context(PlatformError::Backend) } + + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + backend::canonicalize_transport_timeout_ms(remaining_ms, configured_ms) + } + + fn auction_backend_budget(&self) -> Option { + Some(backend::AUCTION_BACKEND_BUDGET) + } + + fn transport_timeout_bucket_count(&self, configured_ms: u32) -> usize { + backend::transport_timeout_bucket_count(configured_ms) + } +} + +/// This adapter as auction plan validation sees it. Fastly can fan out to +/// several providers at once, and its first-byte and between-byte timers are +/// not a total request deadline. +pub(crate) fn auction_target() -> AuctionTargetDescriptor { + AuctionTargetDescriptor::new( + "fastly", + Arc::new(FastlyPlatformBackend), + AuctionTargetCapabilities::new(true, false), + ) } // --------------------------------------------------------------------------- @@ -1273,9 +1292,14 @@ mod tests { #[test] fn auction_http_capabilities_are_explicit() { let client = FastlyPlatformHttpClient; - let capabilities = trusted_server_core::platform::AuctionTargetId::Fastly - .descriptor() - .capabilities(); + let target = auction_target(); + let capabilities = target.capabilities(); + assert_eq!(target.id(), "fastly"); + assert_eq!( + target.backend().auction_backend_budget(), + Some(160), + "the auction keeps 160 of the 200 dynamic backend names" + ); assert!(client.supports_concurrent_fanout()); assert!(capabilities.supports_concurrent_provider_fanout()); assert!(!client.has_enforceable_total_request_deadline()); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 798d2e8bc3..ec5f9b7cef 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -226,7 +226,7 @@ fn build_state_with_registrations_and_services( // here. Compiling without them would drop the implementation and report the // name as one no builder registers. let plan = Arc::new(compile_auction_plan_with(&settings, integrations)?); - plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Spin)?; + plan.validate_for_target(&crate::platform::auction_target())?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan))?; let registry = IntegrationRegistry::with_plan_and_registrations(&settings, plan, integrations)? .with_platform(PlatformIdentity::named("spin")); diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 37857bac90..200a7a7af9 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -5,13 +5,16 @@ use std::time::Duration; use bytes::Bytes; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; -use error_stack::{Report, ResultExt as _}; +use error_stack::Report; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use error_stack::ResultExt as _; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use http_body_util::BodyExt as _; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, - PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, + AuctionTargetCapabilities, AuctionTargetDescriptor, ClientInfo, GeoInfo, KvError, + PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, + PlatformHttpClient, PlatformKvStore, PlatformSecretStore, RuntimeServices, StoreId, StoreName, + UnavailableKvStore, }; #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] @@ -78,18 +81,40 @@ impl PlatformSecretStore for NoopSecretStore { } } +/// Spin outbound HTTP registers no backend, so the name is a correlation +/// key alone, made from every field of the specification, and the transport +/// timer is the exact bounded value. struct NoopBackend; -impl PlatformBackend for NoopBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Spin - } +/// The correlation name for a backend, carrying the first-byte timer so two +/// timers to one host are two backends, as the host's own naming would make +/// them. +fn backend_name(spec: &PlatformBackendSpec) -> String { + let port = spec + .port + .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); + let cert_suffix = if spec.certificate_check { + "" + } else { + "_nocert" + }; + let discriminator = spec + .discriminator + .as_deref() + .map(|value| format!("_p_{value}")) + .unwrap_or_default(); + format!( + "{}_{}_{}_{}ms{cert_suffix}{discriminator}", + spec.scheme, + spec.host, + port, + spec.first_byte_timeout.as_millis() + ) +} +impl PlatformBackend for NoopBackend { fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { - self.naming_policy() - .predict(spec) - .map(|prediction| prediction.name) - .change_context(PlatformError::Backend) + Ok(backend_name(spec)) } fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { @@ -97,6 +122,16 @@ impl PlatformBackend for NoopBackend { } } +/// This adapter as auction plan validation sees it. Spin outbound HTTP runs +/// one provider request at a time, and it exposes no total request deadline. +pub(crate) fn auction_target() -> AuctionTargetDescriptor { + AuctionTargetDescriptor::new( + "spin", + Arc::new(NoopBackend), + AuctionTargetCapabilities::new(false, false), + ) +} + // --------------------------------------------------------------------------- // edgezero handle adapters, for the handles edgezero_adapter_spin::run_app // injects for the stores the app declares and the config handle startup opens @@ -813,17 +848,51 @@ mod tests { use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; - use trusted_server_core::platform::AuctionTargetId; #[test] fn auction_http_capabilities_are_explicit() { - let capabilities = AuctionTargetId::Spin.descriptor().capabilities(); + let target = auction_target(); + let capabilities = target.capabilities(); + assert_eq!(target.id(), "spin"); + assert_eq!( + target.backend().auction_backend_budget(), + None, + "a correlation name is not a limited resource" + ); assert!(!capabilities.supports_concurrent_provider_fanout()); assert!( !capabilities.has_enforceable_total_request_deadline(), "Spin outbound HTTP does not expose an enforceable hard total request deadline" ); } + + #[test] + fn the_backend_name_and_transport_timer_are_pinned() { + let backend = NoopBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_owned(), + host: "origin.example.com".to_owned(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: std::time::Duration::from_millis(1500), + between_bytes_timeout: std::time::Duration::from_millis(1500), + discriminator: Some("provider-one".to_owned()), + }; + assert_eq!( + backend + .predict_name(&spec) + .expect("should name the backend"), + "https_origin.example.com_443_1500ms_p_provider-one" + ); + assert_eq!( + backend.ensure(&spec).expect("should register nothing"), + "https_origin.example.com_443_1500ms_p_provider-one" + ); + assert_eq!(backend.canonicalize_transport_timeout_ms(999, 2000), 999); + assert_eq!(backend.canonicalize_transport_timeout_ms(2000, 100), 100); + assert_eq!(backend.transport_timeout_bucket_count(2000), 1); + } use edgezero_core::context::RequestContext; use edgezero_core::http::request_builder; use edgezero_core::params::PathParams; diff --git a/crates/trusted-server-cli/src/commands/keys.rs b/crates/trusted-server-cli/src/commands/keys.rs index e613d3d77a..ecb8eb1fb9 100644 --- a/crates/trusted-server-cli/src/commands/keys.rs +++ b/crates/trusted-server-cli/src/commands/keys.rs @@ -17,9 +17,9 @@ use std::sync::{Arc, OnceLock}; use error_stack::Report; use trusted_server_core::permissions::PermissionPolicy; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeoModule, PlatformSecretStore, RuntimeServices, - StoreId, StoreName, UnavailableHttpClient, UnavailableKvStore, + ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformGeoModule, PlatformSecretStore, RuntimeServices, StoreId, StoreName, + UnavailableHttpClient, UnavailableKvStore, }; use trusted_server_core::request_signing::rotation::KeyRotationManager; use trusted_server_core::request_signing::{kid_is_creatable, kid_is_well_formed}; @@ -266,10 +266,6 @@ impl PlatformSecretStore for FastlySecretStore { struct NoBackend; impl PlatformBackend for NoBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Axum - } - fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { Err(Report::new(PlatformError::Unsupported)) } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index ce38e16497..321d84199b 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -656,8 +656,7 @@ mod tests { build_services_with_backend_and_http_client, noop_services, }; use crate::platform::{ - BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformHttpClient, PlatformHttpRequest, - PlatformResponse, + ClientInfo, PlatformBackend, PlatformHttpClient, PlatformHttpRequest, PlatformResponse, }; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use serde_json::json; @@ -2245,7 +2244,7 @@ mod tests { #[tokio::test] async fn auction_caps_the_transport_timers_of_the_device_modules_backend() { - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc, Arc::new(NoopHttpClient), diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index b64083dc3a..e9d4eab2b3 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -27,9 +27,7 @@ use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::module_context::{ModuleContext, ModuleRequest}; use crate::permissions::PermissionState; -use crate::platform::{ - BackendNamingPolicy, PlatformBackend, PlatformBackendSpec, PlatformError, RuntimeServices, -}; +use crate::platform::{PlatformBackend, PlatformBackendSpec, PlatformError, RuntimeServices}; use crate::settings::Settings; use super::orchestrator::OrchestrationResult; @@ -672,10 +670,6 @@ impl CappedBackend { } impl PlatformBackend for CappedBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - self.inner.naming_policy() - } - fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { self.inner.predict_name(&self.capped(spec)) } @@ -688,6 +682,14 @@ impl PlatformBackend for CappedBackend { self.inner .canonicalize_transport_timeout_ms(remaining_ms, configured_ms) } + + fn auction_backend_budget(&self) -> Option { + self.inner.auction_backend_budget() + } + + fn transport_timeout_bucket_count(&self, configured_ms: u32) -> usize { + self.inner.transport_timeout_bucket_count(configured_ms) + } } #[cfg(test)] diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 4a8a83f792..5d84ff7908 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -326,7 +326,7 @@ mod plan_sharing_tests { } #[test] - fn cloudflare_and_spin_reject_multi_provider_plans_before_runtime_construction() { + fn a_target_without_fanout_rejects_multi_provider_plans_before_runtime_construction() { let mut settings = create_test_settings(); settings.auction.enabled = true; settings.demand = demand_named( @@ -335,18 +335,18 @@ mod plan_sharing_tests { ); let plan = compile_auction_plan(&settings).expect("should compile target-independent plan"); - for target in [ - crate::platform::AuctionTargetId::Cloudflare, - crate::platform::AuctionTargetId::Spin, - ] { - let error = plan - .validate_for_target(target) - .expect_err("should reject unsupported multi-provider fanout"); - assert!( - error - .to_string() - .contains("does not support concurrent provider fanout") - ); - } + let one_at_a_time = crate::platform::AuctionTargetDescriptor::new( + "one-at-a-time", + Arc::new(crate::platform::test_support::NamingBackend::new()), + crate::platform::AuctionTargetCapabilities::new(false, false), + ); + let error = plan + .validate_for_target(&one_at_a_time) + .expect_err("should reject unsupported multi-provider fanout"); + assert!( + error + .to_string() + .contains("does not support concurrent provider fanout") + ); } } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 7a84454721..a7a5ef9076 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -2600,10 +2600,9 @@ mod tests { build_services_with_http_client, noop_services, }; use crate::platform::{ - BackendNamingPolicy, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, - PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, - PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, - StoreName, + PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, + PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, }; use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; @@ -2802,7 +2801,7 @@ mod tests { .expect("should compile launch-failure plan"), ); let orchestrator = AuctionOrchestrator::from_plan(plan, None); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); backend.fail_ensure_for("launch_fail"); let http = Arc::new(StubHttpClient::new()); let services = build_services_with_backend_and_http_client( @@ -2988,10 +2987,6 @@ mod tests { } impl PlatformBackend for ZeroCanonicalBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Fastly - } - fn predict_name( &self, _spec: &PlatformBackendSpec, @@ -3029,18 +3024,11 @@ mod tests { } impl PlatformBackend for FailThenZeroCanonicalBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Axum - } - fn predict_name( &self, spec: &PlatformBackendSpec, ) -> Result> { - BackendNamingPolicy::Axum - .predict(spec) - .map(|prediction| prediction.name) - .change_context(PlatformError::Backend) + NamingBackend::new().predict_name(spec) } fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { @@ -3060,10 +3048,6 @@ mod tests { struct CollidingBackend; impl PlatformBackend for CollidingBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Axum - } - fn predict_name( &self, _spec: &PlatformBackendSpec, @@ -3596,10 +3580,6 @@ mod tests { } impl PlatformBackend for CanonicalTimeoutBackend { - fn naming_policy(&self) -> crate::platform::BackendNamingPolicy { - crate::platform::BackendNamingPolicy::Axum - } - fn predict_name( &self, _spec: &PlatformBackendSpec, @@ -5212,7 +5192,7 @@ mod tests { async fn harness_outer_select_error_materializes_all_launches_as_transport_failures() { let http = Arc::new(OuterSelectErrorHttpClient::new()); http.push_response(204, Vec::new()); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -5262,7 +5242,7 @@ mod tests { async fn planned_dispatch_does_not_snapshot_inbound_headers() { let http = Arc::new(StubHttpClient::new()); http.push_response(204, Vec::new()); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -5308,7 +5288,7 @@ mod tests { let http = Arc::new(OuterSelectErrorHttpClient::new()); http.push_response(204, Vec::new()); http.push_response(204, Vec::new()); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -5580,7 +5560,7 @@ mod tests { })) .expect("should serialize provider response"), ); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -5667,7 +5647,7 @@ mod tests { })) .expect("should serialize provider response"), ); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -5730,7 +5710,7 @@ mod tests { http.push_response(200, b"{}".to_vec()); http.push_select_delay(Duration::ZERO); http.push_wait_delay(Duration::from_millis(50)); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -5795,7 +5775,7 @@ mod tests { http.push_response(200, b"{}".to_vec()); http.push_select_success(); http.push_wait_error(); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -5856,7 +5836,7 @@ mod tests { .expect("should serialize the upstream response"), ); } - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -6114,7 +6094,7 @@ mod tests { })) .expect("should serialize response b"), ); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -6214,8 +6194,12 @@ mod tests { } let specs = backend.specs.lock().expect("should lock specs"); assert_eq!(specs.len(), 2); - assert_eq!(specs[0].first_byte_timeout, Duration::from_millis(750)); - assert_eq!(specs[1].first_byte_timeout, Duration::from_millis(750)); + assert_eq!( + specs[0].first_byte_timeout, + Duration::from_millis(777), + "the test backend keeps the exact bounded budget as its transport timer" + ); + assert_eq!(specs[1].first_byte_timeout, Duration::from_millis(777)); assert_ne!(specs[0].discriminator, specs[1].discriminator); } @@ -6282,7 +6266,7 @@ mod tests { http.push_response(204, Vec::new()); http.push_response(204, Vec::new()); http.push_pending_backend_name_override(Some("divergent-backend")); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -6329,7 +6313,7 @@ mod tests { http.push_response(204, Vec::new()); http.push_response(204, Vec::new()); http.push_pending_backend_name_override(None); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -6453,7 +6437,7 @@ mod tests { async fn planned_skipped_provider_has_no_io_and_no_zero_impression_launch() { let http = Arc::new(StubHttpClient::new()); http.push_response(204, Vec::new()); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -6619,7 +6603,7 @@ mod tests { http.push_select_success(); http.push_select_success(); http.push_select_error(); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); backend.fail_ensure_for("launch_fail"); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, @@ -6720,7 +6704,7 @@ mod tests { let (profile, provider_id) = ("standard", "standard"); let http = Arc::new(StubHttpClient::new()); http.push_response(200, vec![b'x'; 1024 * 1024 + 1]); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, @@ -6781,7 +6765,7 @@ mod tests { key: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [7_u8; 32]) .into_bytes(), }); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let http = Arc::new(StubHttpClient::new()); http.push_response(204, Vec::new()); let services = crate::platform::test_support::test_services_builder() @@ -6851,7 +6835,7 @@ mod tests { key: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [11_u8; 32]) .into_bytes(), }); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let http = Arc::new(StubHttpClient::new()); http.push_response(204, Vec::new()); http.push_response(204, Vec::new()); @@ -6916,7 +6900,7 @@ mod tests { let config_store = Arc::new(FailingCountingConfigStore { reads: AtomicUsize::new(0), }); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let http = Arc::new(StubHttpClient::new()); let services = crate::platform::test_support::test_services_builder() .config_store(Arc::clone(&config_store) as Arc<_>) @@ -7007,7 +6991,7 @@ mod tests { let config_store = Arc::new(FailingCountingConfigStore { reads: AtomicUsize::new(0), }); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let http = Arc::new(StubHttpClient::new()); let services = crate::platform::test_support::test_services_builder() .config_store(Arc::clone(&config_store) as Arc<_>) @@ -7065,7 +7049,7 @@ mod tests { key: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [13_u8; 32]) .into_bytes(), }); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let backend = Arc::new(NamingBackend::new()); let http = Arc::new(StubHttpClient::new()); let services = crate::platform::test_support::test_services_builder() .config_store(Arc::clone(&config_store) as Arc<_>) @@ -7139,7 +7123,7 @@ mod tests { async fn planned_fanout_rejection_happens_before_backend_or_send() { let http = Arc::new(StubHttpClient::new()); http.set_concurrent_fanout(false); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Cloudflare)); + let backend = Arc::new(NamingBackend::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, diff --git a/crates/trusted-server-core/src/auction/plan.rs b/crates/trusted-server-core/src/auction/plan.rs index 768d7ad64a..95c3bed3fa 100644 --- a/crates/trusted-server-core/src/auction/plan.rs +++ b/crates/trusted-server-core/src/auction/plan.rs @@ -19,7 +19,7 @@ use super::demand::{ AdServerImplementation, CompiledDemand, DemandImplementation, DemandTimeoutDefault, }; use crate::error::TrustedServerError; -use crate::platform::{AuctionTargetId, PlatformBackendSpec}; +use crate::platform::{AuctionTargetDescriptor, PlatformBackendSpec}; use crate::provider_table::{ProviderChoice, ProviderList}; use crate::settings::RequestSigning; @@ -505,66 +505,67 @@ impl ProviderPlan { impl AuctionPlan { /// Validate adapter capabilities and backend-name correlation before I/O. /// - /// Each provider is predicted from a canonical backend specification using - /// its exact configured provider timeout as both transport timers and its - /// provider ID as the stable discriminator. These transport timers do not - /// replace the auction-wide logical budget. + /// The target is the adapter's own description of itself, so each + /// provider's name is predicted by the backend implementation that will + /// register it. Each provider is predicted from a canonical backend + /// specification using its exact configured provider timeout as both + /// transport timers and its provider ID as the stable discriminator. These + /// transport timers do not replace the auction-wide logical budget. /// /// # Errors /// /// Returns a configuration error when the target cannot fan out to every - /// configured provider, backend prediction fails, or two predicted names + /// configured provider, the providers could need more backend names than + /// the target allows, backend prediction fails, or two predicted names /// collide. pub fn validate_for_target( &self, - target_id: AuctionTargetId, + target: &AuctionTargetDescriptor, ) -> Result<(), Report> { if !self.enabled { return Ok(()); } - let target = target_id.descriptor(); if self.providers.len() > 1 && !target.capabilities().supports_concurrent_provider_fanout() { return Err(configuration_error(format!( "auction target `{}` does not support concurrent provider fanout; configured {} providers", - target_id.adapter_id(), + target.id(), self.providers.len() ))); } - let naming_policy = target.naming_policy(); - let backend_budget = naming_policy.auction_dynamic_backend_budget(); + let backend = target.backend(); + let backend_budget = backend.auction_backend_budget(); let mut required_backend_names = 0_usize; let mut predicted_names = BTreeMap::::new(); for provider in &self.providers { let reachable_timeout_ms = provider.timeout_ms.min(self.timeout_ms); required_backend_names = required_backend_names - .saturating_add(naming_policy.transport_timeout_bucket_count(reachable_timeout_ms)); + .saturating_add(backend.transport_timeout_bucket_count(reachable_timeout_ms)); if let Some(budget) = backend_budget && required_backend_names > budget { return Err(configuration_error(format!( "auction target `{}` requires up to {required_backend_names} dynamic provider backends, exceeding its auction budget of {budget}", - target_id.adapter_id(), + target.id(), ))); } let spec = provider.backend_spec(); - let prediction = - naming_policy - .predict(&spec) + let name = + backend + .predict_name(&spec) .change_context(TrustedServerError::Configuration { message: format!( "provider `{}` backend prediction failed for target `{}`", provider.id, - target_id.adapter_id() + target.id() ), })?; - if let Some(existing) = predicted_names.insert(prediction.name.clone(), &provider.id) { + if let Some(existing) = predicted_names.insert(name.clone(), &provider.id) { return Err(configuration_error(format!( - "providers `{existing}` and `{}` predict the same backend name `{}` for target `{}`", + "providers `{existing}` and `{}` predict the same backend name `{name}` for target `{}`", provider.id, - prediction.name, - target_id.adapter_id() + target.id() ))); } } @@ -818,33 +819,127 @@ mod tests { BidderId::from_str(value).expect("should parse bidder ID") } - #[test] - fn target_validation_accepts_fanout_and_rejects_unsupported_targets() { - let plan = AuctionPlan::compile(config(vec![ + /// A target described by the backend it names with and whether it can + /// call more than one provider at once. + fn target( + backend: std::sync::Arc, + fanout: bool, + ) -> crate::platform::AuctionTargetDescriptor { + crate::platform::AuctionTargetDescriptor::new( + "fixture", + backend, + crate::platform::AuctionTargetCapabilities::new(fanout, false), + ) + } + + fn two_providers() -> AuctionPlan { + AuctionPlan::compile(config(vec![ ("provider_one", table("auction.plain-fixture")), ("provider_two", table("auction.plain-fixture")), ])) - .expect("should compile plan"); + .expect("should compile plan") + } + #[test] + fn target_validation_accepts_fanout_and_rejects_a_target_without_it() { + let plan = two_providers(); + + plan.validate_for_target(&target( + std::sync::Arc::new(crate::platform::test_support::NamingBackend::new()), + true, + )) + .expect("a target that fans out should accept two providers"); + + let error = plan + .validate_for_target(&target( + std::sync::Arc::new(crate::platform::test_support::NamingBackend::new()), + false, + )) + .expect_err("a target that runs one demand source at a time should refuse fanout"); + let message = error.to_string(); assert!( - plan.validate_for_target(crate::platform::AuctionTargetId::Fastly) - .is_ok(), - "Fastly should accept provider fanout" + message.contains("`fixture` does not support concurrent provider fanout") + && message.contains("configured 2 providers"), + "should name the target and the provider count: {message}" ); + } + + #[test] + fn target_validation_refuses_more_backend_names_than_the_target_allows() { + let plan = two_providers(); + + plan.validate_for_target(&target( + std::sync::Arc::new( + crate::platform::test_support::NamingBackend::new().with_auction_backend_budget(2), + ), + true, + )) + .expect("two providers reaching one name each fit a budget of two"); + + let error = plan + .validate_for_target(&target( + std::sync::Arc::new( + crate::platform::test_support::NamingBackend::new() + .with_auction_backend_budget(3) + .with_transport_timeout_buckets(2), + ), + true, + )) + .expect_err("two providers reaching two names each exceed a budget of three"); + let message = error.to_string(); assert!( - plan.validate_for_target(crate::platform::AuctionTargetId::Axum) - .is_ok(), - "Axum should accept provider fanout" + message.contains("requires up to 4 dynamic provider backends") + && message.contains("exceeding its auction budget of 3"), + "should say how many names the plan needs and how many the target allows: {message}" ); - for target in [ - crate::platform::AuctionTargetId::Spin, - crate::platform::AuctionTargetId::Cloudflare, - ] { - assert!( - plan.validate_for_target(target).is_err(), - "{target:?} runs one demand source at a time and should refuse fanout" - ); - } + } + + #[test] + fn target_validation_reports_a_backend_the_target_cannot_name() { + let plan = two_providers(); + + let error = plan + .validate_for_target(&target( + std::sync::Arc::new(crate::platform::test_support::NoopBackend), + true, + )) + .expect_err("a backend that cannot predict a name should fail validation"); + let message = format!("{error:?}"); + assert!( + message + .contains("provider `provider_one` backend prediction failed for target `fixture`"), + "should name the provider and the target: {message}" + ); + } + + #[test] + fn target_validation_refuses_two_providers_with_one_backend_name() { + let plan = two_providers(); + + let error = plan + .validate_for_target(&target( + std::sync::Arc::new(crate::platform::test_support::StubBackend), + true, + )) + .expect_err("a backend naming every provider alike should fail validation"); + let message = error.to_string(); + assert!( + message.contains( + "providers `provider_one` and `provider_two` predict the same backend name `stub-backend` for target `fixture`" + ), + "should name both providers and the shared name: {message}" + ); + } + + #[test] + fn a_disabled_plan_validates_against_any_target() { + let plan = two_providers().with_enabled(false); + + plan.validate_for_target(&target( + std::sync::Arc::new(crate::platform::test_support::StubBackend), + false, + )) + .expect("a disabled auction asks nothing of the target"); } #[test] diff --git a/crates/trusted-server-core/src/platform/auction_target.rs b/crates/trusted-server-core/src/platform/auction_target.rs new file mode 100644 index 0000000000..e378e41749 --- /dev/null +++ b/crates/trusted-server-core/src/platform/auction_target.rs @@ -0,0 +1,166 @@ +//! What an adapter tells core about itself as an auction target. +//! +//! The adapter builds the descriptor at startup from its own backend +//! implementation and its own capabilities, and core validates the auction +//! plan against it. The backend names core predicts here are the names the +//! same implementation registers at runtime, so the two cannot drift. + +use std::sync::Arc; + +use super::PlatformBackend; + +/// Adapter capabilities relevant to auction dispatch validation. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct AuctionTargetCapabilities { + concurrent_provider_fanout: bool, + enforceable_total_request_deadline: bool, +} + +impl AuctionTargetCapabilities { + /// Declare whether several provider requests can be in flight at once and + /// whether the adapter caps a provider request's complete lifetime. + #[must_use] + pub const fn new( + concurrent_provider_fanout: bool, + enforceable_total_request_deadline: bool, + ) -> Self { + Self { + concurrent_provider_fanout, + enforceable_total_request_deadline, + } + } + + /// Return whether multiple provider requests can be in flight concurrently. + #[must_use] + pub const fn supports_concurrent_provider_fanout(self) -> bool { + self.concurrent_provider_fanout + } + + /// Return whether the adapter enforces a total request deadline per provider. + /// + /// Transport first-byte or between-byte timers do not satisfy this + /// capability because they do not cap the complete request lifetime. + #[must_use] + pub const fn has_enforceable_total_request_deadline(self) -> bool { + self.enforceable_total_request_deadline + } +} + +/// The adapter as plan validation sees it: its name, the backend +/// implementation that names and registers backends, and its capabilities. +#[derive(Clone)] +pub struct AuctionTargetDescriptor { + id: &'static str, + backend: Arc, + capabilities: AuctionTargetCapabilities, +} + +impl AuctionTargetDescriptor { + /// Describe an adapter by the name its errors carry, the backend + /// implementation its runtime services use, and its capabilities. + #[must_use] + pub fn new( + id: &'static str, + backend: Arc, + capabilities: AuctionTargetCapabilities, + ) -> Self { + Self { + id, + backend, + capabilities, + } + } + + /// The adapter's name, as validation errors report it. + #[must_use] + pub const fn id(&self) -> &'static str { + self.id + } + + /// The backend implementation whose names and timers validation predicts. + #[must_use] + pub fn backend(&self) -> &dyn PlatformBackend { + self.backend.as_ref() + } + + /// This target's auction dispatch capabilities. + #[must_use] + pub const fn capabilities(&self) -> AuctionTargetCapabilities { + self.capabilities + } +} + +impl core::fmt::Debug for AuctionTargetDescriptor { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("AuctionTargetDescriptor") + .field("id", &self.id) + .field("capabilities", &self.capabilities) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::test_support::{NamingBackend, StubBackend}; + + #[test] + fn a_descriptor_answers_what_the_adapter_gave_it() { + let descriptor = AuctionTargetDescriptor::new( + "fixture", + Arc::new(StubBackend), + AuctionTargetCapabilities::new(true, false), + ); + assert_eq!(descriptor.id(), "fixture"); + assert!( + descriptor + .capabilities() + .supports_concurrent_provider_fanout() + ); + assert!( + !descriptor + .capabilities() + .has_enforceable_total_request_deadline() + ); + assert_eq!( + descriptor.backend().auction_backend_budget(), + None, + "a backend that declares no budget has none" + ); + assert_eq!( + descriptor.backend().transport_timeout_bucket_count(2000), + 1, + "a backend that declares no buckets reaches one name per timeout" + ); + assert_eq!( + format!("{descriptor:?}"), + "AuctionTargetDescriptor { id: \"fixture\", capabilities: \ + AuctionTargetCapabilities { concurrent_provider_fanout: true, \ + enforceable_total_request_deadline: false }, .. }" + ); + } + + #[test] + fn a_descriptor_carries_the_backend_budget_the_adapter_declares() { + let backend = NamingBackend::new() + .with_auction_backend_budget(7) + .with_transport_timeout_buckets(3); + let descriptor = AuctionTargetDescriptor::new( + "fixture", + Arc::new(backend), + AuctionTargetCapabilities::new(false, true), + ); + assert_eq!(descriptor.backend().auction_backend_budget(), Some(7)); + assert_eq!(descriptor.backend().transport_timeout_bucket_count(500), 3); + assert!( + !descriptor + .capabilities() + .supports_concurrent_provider_fanout() + ); + assert!( + descriptor + .capabilities() + .has_enforceable_total_request_deadline() + ); + } +} diff --git a/crates/trusted-server-core/src/platform/backend_naming.rs b/crates/trusted-server-core/src/platform/backend_naming.rs deleted file mode 100644 index 54fc176b1b..0000000000 --- a/crates/trusted-server-core/src/platform/backend_naming.rs +++ /dev/null @@ -1,652 +0,0 @@ -//! Pure adapter backend naming and auction-target capability policies. -//! -//! These policies contain no platform SDK calls. Startup validation, CLI -//! validation, and runtime adapters can therefore predict the same backend -//! names before any backend registration occurs. - -use core::fmt::Write as _; - -use error_stack::Report; -use sha2::{Digest as _, Sha256}; - -use super::PlatformBackendSpec; -use crate::host_header::validate_host_header_override_value; - -const MAX_FASTLY_BACKEND_NAME_LEN: usize = 255; -const MAX_FASTLY_READABLE_PREFIX_LEN: usize = 200; -const FASTLY_SPEC_DIGEST_HEX_LEN: usize = 32; -const FASTLY_TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; -const FASTLY_TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS: u32 = 2000; -const FASTLY_SUB_QUANTUM_LADDER_MS: [u32; 4] = [200, 150, 100, 50]; -const FASTLY_TRANSPORT_TIMEOUT_COARSE_LADDER_MS: [u32; 8] = - [2000, 3000, 5000, 10000, 20000, 30000, 45000, 60000]; -const FASTLY_DYNAMIC_BACKEND_LIMIT: usize = 200; -const FASTLY_NON_AUCTION_BACKEND_RESERVE: usize = 40; - -/// A pure backend-name and transport-timeout policy for one adapter. -/// -/// Cloudflare and Spin intentionally remain separate variants even though -/// their current no-registration name formats are identical. Keeping distinct -/// policies prevents a future adapter-specific change from silently affecting -/// the other target. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum BackendNamingPolicy { - /// Fastly dynamic backend naming and bounded timeout buckets. - Fastly, - /// Axum's environment-segment-compatible correlation name. - Axum, - /// Cloudflare's deterministic no-registration correlation name. - Cloudflare, - /// Spin's deterministic no-registration correlation name. - Spin, -} - -/// Pure backend prediction shared by validation and runtime registration. -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct PredictedBackend { - /// Deterministic platform backend or correlation name. - pub name: String, - /// Explicit or scheme-derived target port. - pub port: u16, -} - -/// Detailed pure backend prediction error. -#[derive(Debug, Clone, Eq, PartialEq, derive_more::Display)] -pub enum BackendNamingError { - /// The backend host is empty. - #[display("missing host")] - MissingHost, - /// A field contains a control character. - #[display("{field} contains control characters")] - ControlCharacters { field: &'static str }, - /// An outbound Host override is invalid. - #[display("host header override {reason}")] - InvalidHostHeaderOverride { reason: &'static str }, - /// A generated Fastly backend name exceeded its documented limit. - #[display("backend name exceeds {limit}-char limit ({actual} chars)")] - NameTooLong { limit: usize, actual: usize }, -} - -impl core::error::Error for BackendNamingError {} - -impl BackendNamingPolicy { - /// Predict a backend name and resolved port without platform I/O. - /// - /// # Errors - /// - /// Returns a naming error when a Fastly backend specification contains an - /// invalid host, scheme, Host override, or cannot fit the platform limit. - pub fn predict( - self, - spec: &PlatformBackendSpec, - ) -> Result> { - match self { - Self::Fastly => predict_fastly(spec), - Self::Axum => Ok(predict_axum(spec)), - Self::Cloudflare => Ok(predict_cloudflare(spec)), - Self::Spin => Ok(predict_spin(spec)), - } - } - - /// Canonicalize a transport timer without changing the logical budget. - /// - /// The result configures adapter transport timers and backend-name - /// stability only. It is not an auction-wide deadline. Fastly bounds the - /// cardinality of budget-derived timers because timers are encoded in - /// dynamic backend names; other adapters preserve the exact bounded value. - #[must_use] - pub fn canonicalize_transport_timeout_ms(self, remaining_ms: u32, configured_ms: u32) -> u32 { - match self { - Self::Fastly => canonicalize_fastly_transport_timeout_ms(remaining_ms, configured_ms), - Self::Axum | Self::Cloudflare | Self::Spin => remaining_ms.min(configured_ms), - } - } - - /// Return the target-specific backend-name budget available to auction providers. - #[must_use] - pub(crate) fn auction_dynamic_backend_budget(self) -> Option { - matches!(self, Self::Fastly).then_some( - FASTLY_DYNAMIC_BACKEND_LIMIT.saturating_sub(FASTLY_NON_AUCTION_BACKEND_RESERVE), - ) - } - - /// Count every transport-timeout bucket one provider can reach. - #[must_use] - pub(crate) fn transport_timeout_bucket_count(self, configured_ms: u32) -> usize { - if !matches!(self, Self::Fastly) || configured_ms == 0 { - return 1; - } - let mut buckets = std::collections::BTreeSet::new(); - buckets.insert(configured_ms); - for remaining_ms in FASTLY_SUB_QUANTUM_LADDER_MS { - let timeout = self.canonicalize_transport_timeout_ms(remaining_ms, configured_ms); - if timeout > 0 { - buckets.insert(timeout); - } - } - for remaining_ms in (FASTLY_TRANSPORT_TIMEOUT_QUANTUM_MS - ..FASTLY_TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS) - .step_by(FASTLY_TRANSPORT_TIMEOUT_QUANTUM_MS as usize) - { - let timeout = self.canonicalize_transport_timeout_ms(remaining_ms, configured_ms); - if timeout > 0 { - buckets.insert(timeout); - } - } - for remaining_ms in FASTLY_TRANSPORT_TIMEOUT_COARSE_LADDER_MS { - let timeout = self.canonicalize_transport_timeout_ms(remaining_ms, configured_ms); - if timeout > 0 { - buckets.insert(timeout); - } - } - buckets.len() - } -} - -/// Canonical adapter target identifier accepted by Trusted Server tooling. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum AuctionTargetId { - /// Fastly Compute. - Fastly, - /// Native Axum development server. - Axum, - /// Cloudflare Workers. - Cloudflare, - /// Fermyon Spin. - Spin, -} - -impl AuctionTargetId { - /// Map a canonical `EdgeZero` adapter registry ID to an auction target. - #[must_use] - pub fn from_adapter_id(adapter_id: &str) -> Option { - match adapter_id { - "fastly" => Some(Self::Fastly), - "axum" => Some(Self::Axum), - "cloudflare" => Some(Self::Cloudflare), - "spin" => Some(Self::Spin), - _ => None, - } - } - - /// Return the canonical `EdgeZero` adapter registry ID. - #[must_use] - pub const fn adapter_id(self) -> &'static str { - match self { - Self::Fastly => "fastly", - Self::Axum => "axum", - Self::Cloudflare => "cloudflare", - Self::Spin => "spin", - } - } - - /// Return the shared naming and capability descriptor for this target. - #[must_use] - pub const fn descriptor(self) -> AuctionTargetDescriptor { - match self { - Self::Fastly => AuctionTargetDescriptor { - id: self, - naming_policy: BackendNamingPolicy::Fastly, - capabilities: AuctionTargetCapabilities { - concurrent_provider_fanout: true, - enforceable_total_request_deadline: false, - }, - }, - Self::Axum => AuctionTargetDescriptor { - id: self, - naming_policy: BackendNamingPolicy::Axum, - capabilities: AuctionTargetCapabilities { - concurrent_provider_fanout: true, - enforceable_total_request_deadline: false, - }, - }, - Self::Cloudflare => AuctionTargetDescriptor { - id: self, - naming_policy: BackendNamingPolicy::Cloudflare, - capabilities: AuctionTargetCapabilities { - concurrent_provider_fanout: false, - enforceable_total_request_deadline: false, - }, - }, - Self::Spin => AuctionTargetDescriptor { - id: self, - naming_policy: BackendNamingPolicy::Spin, - capabilities: AuctionTargetCapabilities { - concurrent_provider_fanout: false, - enforceable_total_request_deadline: false, - }, - }, - } - } -} - -/// Adapter capabilities relevant to auction dispatch validation. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub struct AuctionTargetCapabilities { - concurrent_provider_fanout: bool, - enforceable_total_request_deadline: bool, -} - -impl AuctionTargetCapabilities { - /// Return whether multiple provider requests can be in flight concurrently. - #[must_use] - pub const fn supports_concurrent_provider_fanout(self) -> bool { - self.concurrent_provider_fanout - } - - /// Return whether the adapter enforces a total request deadline per provider. - /// - /// Transport first-byte or between-byte timers do not satisfy this - /// capability because they do not cap the complete request lifetime. - #[must_use] - pub const fn has_enforceable_total_request_deadline(self) -> bool { - self.enforceable_total_request_deadline - } -} - -/// Shared target descriptor used by plan validation. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub struct AuctionTargetDescriptor { - id: AuctionTargetId, - naming_policy: BackendNamingPolicy, - capabilities: AuctionTargetCapabilities, -} - -impl AuctionTargetDescriptor { - /// Return the canonical target identity. - #[must_use] - pub const fn id(self) -> AuctionTargetId { - self.id - } - - /// Return the pure backend naming and transport timer policy. - #[must_use] - pub const fn naming_policy(self) -> BackendNamingPolicy { - self.naming_policy - } - - /// Return this target's auction dispatch capabilities. - #[must_use] - pub const fn capabilities(self) -> AuctionTargetCapabilities { - self.capabilities - } -} - -fn default_port(scheme: &str, https_case_insensitive: bool) -> u16 { - let https = if https_case_insensitive { - scheme.eq_ignore_ascii_case("https") - } else { - scheme == "https" - }; - if https { 443 } else { 80 } -} - -fn sanitize_fastly_component(value: &str) -> String { - value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { - ch - } else { - '_' - } - }) - .collect() -} - -fn canonical_fastly_host(host: &str) -> &str { - host.strip_prefix('[') - .and_then(|value| value.strip_suffix(']')) - .filter(|value| value.parse::().is_ok()) - .unwrap_or(host) -} - -fn fastly_canonical_spec(spec: &PlatformBackendSpec, target_port: u16) -> String { - fn push_field(buffer: &mut String, field: &str) { - buffer.push_str(&field.len().to_string()); - buffer.push(':'); - buffer.push_str(field); - } - - let mut buffer = String::new(); - push_field(&mut buffer, &spec.scheme); - push_field(&mut buffer, canonical_fastly_host(&spec.host)); - push_field(&mut buffer, &target_port.to_string()); - push_field(&mut buffer, if spec.certificate_check { "1" } else { "0" }); - match spec.host_header_override.as_deref() { - Some(value) => { - buffer.push('s'); - push_field(&mut buffer, value); - } - None => buffer.push('n'), - } - match spec.discriminator.as_deref() { - Some(value) => { - buffer.push('s'); - push_field(&mut buffer, value); - } - None => buffer.push('n'), - } - push_field( - &mut buffer, - &spec.first_byte_timeout.as_millis().to_string(), - ); - push_field( - &mut buffer, - &spec.between_bytes_timeout.as_millis().to_string(), - ); - buffer -} - -fn fastly_spec_digest(canonical: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(canonical.as_bytes()); - let digest = hasher.finalize(); - let mut hex = String::with_capacity(FASTLY_SPEC_DIGEST_HEX_LEN); - for byte in digest.iter().take(FASTLY_SPEC_DIGEST_HEX_LEN / 2) { - write!(hex, "{byte:02x}").expect("should write hex digit to string"); - } - hex -} - -fn predict_fastly( - spec: &PlatformBackendSpec, -) -> Result> { - if spec.host.is_empty() { - return Err(Report::new(BackendNamingError::MissingHost)); - } - if spec.host.chars().any(char::is_control) { - return Err(Report::new(BackendNamingError::ControlCharacters { - field: "host", - })); - } - if spec.scheme.chars().any(char::is_control) { - return Err(Report::new(BackendNamingError::ControlCharacters { - field: "scheme", - })); - } - if let Some(host_header_override) = spec.host_header_override.as_deref() { - validate_host_header_override_value(host_header_override).map_err(|reason| { - Report::new(BackendNamingError::InvalidHostHeaderOverride { reason }) - })?; - } - - let port = spec - .port - .unwrap_or_else(|| default_port(&spec.scheme, true)); - let name_base = format!( - "{}_{}_{}", - spec.scheme, - canonical_fastly_host(&spec.host), - port - ); - let host_override_suffix = spec - .host_header_override - .as_deref() - .map(|host| format!("_oh_{}", sanitize_fastly_component(host))) - .unwrap_or_default(); - let cert_suffix = if spec.certificate_check { - "" - } else { - "_nocert" - }; - let discriminator_suffix = spec - .discriminator - .as_deref() - .map(|value| format!("_p_{}", sanitize_fastly_component(value))) - .unwrap_or_default(); - let readable_full = format!( - "{}{}{}{}_fb{}_bb{}", - sanitize_fastly_component(&name_base), - host_override_suffix, - cert_suffix, - discriminator_suffix, - spec.first_byte_timeout.as_millis(), - spec.between_bytes_timeout.as_millis() - ); - let readable = readable_full - .chars() - .take(MAX_FASTLY_READABLE_PREFIX_LEN) - .collect::(); - let digest = fastly_spec_digest(&fastly_canonical_spec(spec, port)); - let name = format!("backend_{readable}_{digest}"); - if name.len() > MAX_FASTLY_BACKEND_NAME_LEN { - return Err(Report::new(BackendNamingError::NameTooLong { - limit: MAX_FASTLY_BACKEND_NAME_LEN, - actual: name.len(), - })); - } - - Ok(PredictedBackend { name, port }) -} - -fn normalize_axum_segment(value: &str) -> String { - value.to_uppercase().replace(['-', '.', ' '], "_") -} - -fn predict_axum(spec: &PlatformBackendSpec) -> PredictedBackend { - let port = spec - .port - .unwrap_or_else(|| default_port(&spec.scheme, false)); - let discriminator = spec - .discriminator - .as_deref() - .map(|value| format!("_p_{}", normalize_axum_segment(value))) - .unwrap_or_default(); - PredictedBackend { - name: format!( - "{}_{}_{}{}", - normalize_axum_segment(&spec.scheme), - normalize_axum_segment(&spec.host), - port, - discriminator - ), - port, - } -} - -fn predict_no_registration(spec: &PlatformBackendSpec) -> PredictedBackend { - let port = spec - .port - .unwrap_or_else(|| default_port(&spec.scheme, false)); - let cert_suffix = if spec.certificate_check { - "" - } else { - "_nocert" - }; - let discriminator = spec - .discriminator - .as_deref() - .map(|value| format!("_p_{value}")) - .unwrap_or_default(); - PredictedBackend { - name: format!( - "{}_{}_{}_{}ms{cert_suffix}{discriminator}", - spec.scheme, - spec.host, - port, - spec.first_byte_timeout.as_millis() - ), - port, - } -} - -fn predict_cloudflare(spec: &PlatformBackendSpec) -> PredictedBackend { - predict_no_registration(spec) -} - -fn predict_spin(spec: &PlatformBackendSpec) -> PredictedBackend { - predict_no_registration(spec) -} - -fn canonicalize_fastly_transport_timeout_ms(remaining_ms: u32, configured_ms: u32) -> u32 { - if remaining_ms >= configured_ms { - return configured_ms; - } - if remaining_ms >= FASTLY_TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS { - return FASTLY_TRANSPORT_TIMEOUT_COARSE_LADDER_MS - .into_iter() - .rev() - .find(|&rung| rung <= remaining_ms) - .unwrap_or(FASTLY_TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS); - } - let floored = - (remaining_ms / FASTLY_TRANSPORT_TIMEOUT_QUANTUM_MS) * FASTLY_TRANSPORT_TIMEOUT_QUANTUM_MS; - if floored > 0 { - return floored; - } - FASTLY_SUB_QUANTUM_LADDER_MS - .into_iter() - .find(|&rung| rung <= remaining_ms) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use super::*; - - fn spec() -> PlatformBackendSpec { - PlatformBackendSpec { - scheme: "https".to_owned(), - host: "origin.example.com".to_owned(), - port: None, - host_header_override: None, - certificate_check: true, - first_byte_timeout: Duration::from_millis(1500), - between_bytes_timeout: Duration::from_millis(1500), - discriminator: Some("provider-one".to_owned()), - } - } - - #[test] - fn adapter_prediction_outputs_are_pinned() { - let spec = spec(); - assert_eq!( - BackendNamingPolicy::Fastly - .predict(&spec) - .expect("should predict Fastly backend"), - PredictedBackend { - name: "backend_https_origin_example_com_443_p_provider-one_fb1500_bb1500_51fb8dba14db39e759e2c1c5d204a6eb".to_owned(), - port: 443, - } - ); - assert_eq!( - BackendNamingPolicy::Axum - .predict(&spec) - .expect("should predict Axum backend"), - PredictedBackend { - name: "HTTPS_ORIGIN_EXAMPLE_COM_443_p_PROVIDER_ONE".to_owned(), - port: 443, - } - ); - let no_registration = PredictedBackend { - name: "https_origin.example.com_443_1500ms_p_provider-one".to_owned(), - port: 443, - }; - assert_eq!( - BackendNamingPolicy::Cloudflare - .predict(&spec) - .expect("should predict Cloudflare backend"), - no_registration - ); - assert_eq!( - BackendNamingPolicy::Spin - .predict(&spec) - .expect("should predict Spin backend"), - no_registration - ); - } - - #[test] - fn fastly_prediction_canonicalizes_only_bracketed_ipv6_hosts() { - let mut bare = spec(); - bare.host = "2001:db8::1".to_string(); - bare.port = Some(8443); - let mut bracketed = bare.clone(); - bracketed.host = "[2001:db8::1]".to_string(); - - let bare_prediction = BackendNamingPolicy::Fastly - .predict(&bare) - .expect("should predict bare IPv6 backend"); - let bracketed_prediction = BackendNamingPolicy::Fastly - .predict(&bracketed) - .expect("should predict bracketed IPv6 backend"); - assert_eq!(bare_prediction, bracketed_prediction); - assert_eq!( - bare_prediction - .name - .rsplit_once('_') - .map(|(_, digest)| digest), - bracketed_prediction - .name - .rsplit_once('_') - .map(|(_, digest)| digest), - "canonical forms must hash the identical Fastly specification" - ); - - assert_eq!( - canonical_fastly_host("origin.example.com"), - "origin.example.com" - ); - assert_eq!(canonical_fastly_host("192.0.2.1"), "192.0.2.1"); - assert_eq!(canonical_fastly_host("[not-ipv6]"), "[not-ipv6]"); - } - - #[test] - fn target_descriptors_pin_all_capabilities_and_policies() { - let cases = [ - (AuctionTargetId::Fastly, true, BackendNamingPolicy::Fastly), - (AuctionTargetId::Axum, true, BackendNamingPolicy::Axum), - ( - AuctionTargetId::Cloudflare, - false, - BackendNamingPolicy::Cloudflare, - ), - (AuctionTargetId::Spin, false, BackendNamingPolicy::Spin), - ]; - for (id, fanout, naming_policy) in cases { - let descriptor = id.descriptor(); - assert_eq!(descriptor.id(), id); - assert_eq!(descriptor.naming_policy(), naming_policy); - assert_eq!( - descriptor - .capabilities() - .supports_concurrent_provider_fanout(), - fanout, - "should declare fanout accurately for {}", - id.adapter_id() - ); - assert!( - !descriptor - .capabilities() - .has_enforceable_total_request_deadline(), - "no current adapter should claim a total request deadline" - ); - assert_eq!(AuctionTargetId::from_adapter_id(id.adapter_id()), Some(id)); - } - assert_eq!(AuctionTargetId::from_adapter_id("FASTLY"), None); - assert_eq!(AuctionTargetId::from_adapter_id("unknown"), None); - } - - #[test] - fn timeout_policies_preserve_logical_transport_distinction() { - assert_eq!( - BackendNamingPolicy::Fastly.canonicalize_transport_timeout_ms(999, 2000), - 750 - ); - assert_eq!( - BackendNamingPolicy::Fastly.canonicalize_transport_timeout_ms(2000, 100), - 100 - ); - for policy in [ - BackendNamingPolicy::Axum, - BackendNamingPolicy::Cloudflare, - BackendNamingPolicy::Spin, - ] { - assert_eq!(policy.canonicalize_transport_timeout_ms(999, 2000), 999); - assert_eq!(policy.canonicalize_transport_timeout_ms(2000, 100), 100); - } - } -} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index af160d2bbe..c882703cc0 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -37,7 +37,7 @@ use std::time::Duration; -mod backend_naming; +mod auction_target; mod cache_headers; mod error; mod geo; @@ -63,10 +63,7 @@ mod traits; mod types; mod value_cache; -pub use backend_naming::{ - AuctionTargetCapabilities, AuctionTargetDescriptor, AuctionTargetId, BackendNamingError, - BackendNamingPolicy, PredictedBackend, -}; +pub use auction_target::{AuctionTargetCapabilities, AuctionTargetDescriptor}; pub use cache_headers::{declare_platform_cache_headers, platform_cache_headers}; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; pub use error::PlatformError; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 36eea46926..3daa5fea78 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -132,10 +132,6 @@ impl PlatformSecretStore for HashMapSecretStore { pub struct NoopBackend; impl PlatformBackend for NoopBackend { - fn naming_policy(&self) -> super::BackendNamingPolicy { - super::BackendNamingPolicy::Axum - } - fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { Err(Report::new(PlatformError::Unsupported)) } @@ -182,10 +178,6 @@ impl PlatformHttpClient for NoopHttpClient { pub struct StubBackend; impl PlatformBackend for StubBackend { - fn naming_policy(&self) -> super::BackendNamingPolicy { - super::BackendNamingPolicy::Axum - } - fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { Ok("stub-backend".to_owned()) } @@ -199,11 +191,12 @@ impl PlatformBackend for StubBackend { // NamingBackend // --------------------------------------------------------------------------- -/// Test stub for [`PlatformBackend`] that names a backend the way the given -/// policy does, so two demand sources get two names, and records what it was -/// asked. +/// Test stub for [`PlatformBackend`] that names a backend from every field +/// of its specification, so two demand sources get two names and two timers +/// get two names, and records what it was asked. It declares the backend +/// budget and the timeout buckets a test gives it, so plan validation can be +/// driven to each refusal without a host. pub struct NamingBackend { - pub policy: super::BackendNamingPolicy, /// How many names were predicted. pub predicted: std::sync::atomic::AtomicUsize, /// How many backends were registered or refused. @@ -212,19 +205,36 @@ pub struct NamingBackend { pub specs: Mutex>, /// The discriminators whose registration fails. pub fail_ensure_for: Mutex>, + auction_backend_budget: Option, + transport_timeout_buckets: usize, } impl NamingBackend { - pub fn new(policy: super::BackendNamingPolicy) -> Self { + pub fn new() -> Self { Self { - policy, predicted: std::sync::atomic::AtomicUsize::new(0), ensured: std::sync::atomic::AtomicUsize::new(0), specs: Mutex::new(Vec::new()), fail_ensure_for: Mutex::new(std::collections::HashSet::new()), + auction_backend_budget: None, + transport_timeout_buckets: 1, } } + /// Declares how many backend names the auction may register. + #[must_use] + pub fn with_auction_backend_budget(mut self, budget: usize) -> Self { + self.auction_backend_budget = Some(budget); + self + } + + /// Declares how many names one provider reaches through its timeout. + #[must_use] + pub fn with_transport_timeout_buckets(mut self, buckets: usize) -> Self { + self.transport_timeout_buckets = buckets; + self + } + /// Makes registration fail for the source `provider_id`. pub fn fail_ensure_for(&self, provider_id: &str) { self.fail_ensure_for @@ -233,23 +243,35 @@ impl NamingBackend { .insert(provider_id.to_string()); } - fn name(&self, spec: &PlatformBackendSpec) -> Result> { - self.policy - .predict(spec) - .map(|prediction| prediction.name) - .change_context(PlatformError::Backend) + fn name(spec: &PlatformBackendSpec) -> String { + let port = spec + .port + .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); + let cert_suffix = if spec.certificate_check { + "" + } else { + "_nocert" + }; + let discriminator = spec + .discriminator + .as_deref() + .map(|value| format!("_p_{value}")) + .unwrap_or_default(); + format!( + "{}_{}_{}_{}ms{cert_suffix}{discriminator}", + spec.scheme, + spec.host, + port, + spec.first_byte_timeout.as_millis() + ) } } impl PlatformBackend for NamingBackend { - fn naming_policy(&self) -> super::BackendNamingPolicy { - self.policy - } - fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { self.predicted .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - self.name(spec) + Ok(Self::name(spec)) } fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { @@ -267,7 +289,15 @@ impl PlatformBackend for NamingBackend { .lock() .expect("should lock planned backend specs") .push(spec.clone()); - self.name(spec) + Ok(Self::name(spec)) + } + + fn auction_backend_budget(&self) -> Option { + self.auction_backend_budget + } + + fn transport_timeout_bucket_count(&self, _configured_ms: u32) -> usize { + self.transport_timeout_buckets } } diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index f3f2ce7e69..85076321fd 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -2,10 +2,7 @@ use std::net::IpAddr; use error_stack::Report; -use super::{ - BackendNamingPolicy, GeoInfo, PlatformBackendSpec, PlatformError, RuntimeServices, StoreId, - StoreName, -}; +use super::{GeoInfo, PlatformBackendSpec, PlatformError, RuntimeServices, StoreId, StoreName}; /// Synchronous, object-safe access to a key-value config store. /// @@ -96,10 +93,12 @@ pub trait PlatformSecretStore: Send + Sync { } /// Synchronous, object-safe dynamic backend management. +/// +/// The one interface through which the host answers how it names a backend +/// and how it bounds a transport timer. Startup validation asks the same +/// implementation the runtime services carry, so the names validation +/// predicts are the names the host registers. pub trait PlatformBackend: Send + Sync { - /// Return this adapter's pure backend naming and transport timer policy. - fn naming_policy(&self) -> BackendNamingPolicy; - /// Compute the deterministic backend name for the given spec without /// registering anything. /// @@ -125,17 +124,30 @@ pub trait PlatformBackend: Send + Sync { /// provider's request deadline, so it must be identical for prediction and /// registration of the same launch. /// - /// Adapters that embed the transport timeout in the dynamic backend name - /// (Fastly) override this to round budget-derived values to a coarse - /// ladder, so per-request wall-clock jitter neither defeats cross-request - /// connection pooling nor accumulates registrations toward the per-service - /// dynamic backend limit. - /// - /// Delegates to the same pure policy used by startup validation so runtime - /// transport timers and predicted names cannot drift. + /// A host that embeds the transport timeout in the backend name overrides + /// this to round budget-derived values to a coarse ladder, so per-request + /// wall-clock jitter neither defeats cross-request connection pooling nor + /// accumulates registrations toward the host's backend limit. The default + /// keeps the exact bounded value. fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { - self.naming_policy() - .canonicalize_transport_timeout_ms(remaining_ms, configured_ms) + remaining_ms.min(configured_ms) + } + + /// How many backend names the auction may register on this host, or + /// `None` where a backend name is not a limited resource. + fn auction_backend_budget(&self) -> Option { + None + } + + /// How many distinct backend names one provider with this configured + /// timeout can reach through [`Self::canonicalize_transport_timeout_ms`]. + /// + /// Plan validation adds every provider's count and refuses a plan that + /// could exceed [`Self::auction_backend_budget`]. A host whose names do + /// not carry the timer reaches one name per provider. + fn transport_timeout_bucket_count(&self, configured_ms: u32) -> usize { + let _ = configured_ms; + 1 } } diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index b69197afc3..156a391368 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -549,10 +549,10 @@ pub mod nextjs_auction { use crate::geo::GeoInfo; use crate::platform::{ - BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, - PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, - RuntimeServices, StoreId, StoreName, UnavailableKvStore, + ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, + StoreName, UnavailableKvStore, }; use crate::settings::Settings; @@ -846,10 +846,6 @@ pub mod nextjs_auction { } impl PlatformBackend for FixtureStore { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Axum - } - fn predict_name( &self, _spec: &PlatformBackendSpec, diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index d69ccf20df..3ea8d9ac56 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -53,11 +53,12 @@ Native Axum dev/test adapter (native binary): **Current limitations compared to the Fastly adapter:** -| Feature | Axum dev server | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| KV store | Unavailable — synthetic-ID and consent routes degrade gracefully | -| Geo lookup | None, and `[geo] module = "platform"` is refused at startup | -| Auction fan-out ordering | Requests run concurrently via `tokio::spawn`; `select` returns first-to-complete but does not replicate Fastly's priority-queue tie-breaking | +| Feature | Axum dev server | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| KV store | Unavailable — synthetic-ID and consent routes degrade gracefully | +| Geo lookup | None, and `[geo] module = "platform"` is refused at startup | +| Auction fan-out ordering | Requests run concurrently via `tokio::spawn`; `select` returns first-to-complete but does not replicate Fastly's priority-queue tie-breaking | +| `proxy.certificate_check` | `false` is not honored, so HTTPS origin certificates are always verified, and startup logs a warning | ### trusted-server-adapter-spin diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index c134b7db75..77926a04fa 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -818,12 +818,15 @@ bytes each. Target-independent `ts config validate` compiles implementations, defaults, routes, endpoints, bounds, signing structure, and the ad server selection. Every adapter startup compiles the same plan and then validates backend-name -prediction, fan-out capability, and target resource limits. Fastly and Axum -allow fan-out to several demand sources, whereas Cloudflare and Spin currently -reject an enabled auction with more than one. Fastly reserves 40 of its -default 200 dynamic backend names for non-auction traffic and rejects auction -plans whose demand source names and reachable timeout buckets could require -more than the remaining 160. +prediction, fan-out capability, and target resource limits against the +adapter's own description of itself. The adapter hands core the backend +implementation its runtime services use and the capabilities it has, so the +names validation predicts are the names the adapter registers, and plan +validation names no platform. Fastly and Axum allow fan-out to several demand +sources, whereas Cloudflare and Spin currently reject an enabled auction with +more than one. Fastly reserves 40 of its default 200 dynamic backend names for +non-auction traffic and rejects auction plans whose demand source names and +reachable timeout buckets could require more than the remaining 160. This tree does not yet have the EdgeZero callback required to run target-aware validation before `ts config push --adapter ` performs remote work. diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index 4e785f977b..8b0627ff5e 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -75,5 +75,11 @@ Axum sanitizes forwarded client-IP headers but does not resolve Axum sends no [auction telemetry](./telemetry#adapter-support), and warns at startup when `[analytics]` selects a module. +Axum always verifies HTTPS origin certificates against the public certificate +authorities built into its client. It does not honor +`proxy.certificate_check = false` and logs a warning at startup when the +setting is off, so an origin whose certificate is self-signed or issued by a +private authority cannot be reached through it. + For an edge deployment, use the [Fastly](./fastly), [Cloudflare](./cloudflare), or [Spin](./spin) guide. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a63a1b3466..8fb4cf4fca 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1707,6 +1707,12 @@ See [First-Party Proxy](/guide/first-party-proxy#proxy-allowlist) for usage deta Set this to `false` only for local development with self-signed certificates. +Only the Fastly adapter honors `false`. The Axum adapter always verifies +certificates, and logs a warning at startup when the setting is `false`. The +Cloudflare and Spin adapters use the setting only in the name each gives a +backend, because the HTTP client each platform provides has no option to turn +verification off, and they log nothing about it. + ### `[[proxy.asset_routes]]` Asset routes proxy selected first-party paths to an alternate asset origin without requiring signed `/first-party/proxy` URLs. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index cb53398fdd..029119e740 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -142,7 +142,8 @@ values in the TOML config, config-store blob, or a source-controlled environment file. > **Dev server limitations:** The Axum adapter does not support KV store or -> geo lookup. +> geo lookup, and always verifies HTTPS origin certificates whatever +> `proxy.certificate_check` says. > See [Architecture](/guide/architecture) for the full list. ### Build the Project diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 8b6da28b53..d661af014d 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -47,7 +47,7 @@ use trusted_server_core::middleware::{ MiddlewareContext, MiddlewarePhase, }; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, + ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformGeoModule, PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableHttpClient, UnavailableKvStore, @@ -112,10 +112,6 @@ impl PlatformSecretStore for ReadOnlyStore { struct FixtureBackend; impl PlatformBackend for FixtureBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - BackendNamingPolicy::Axum - } - fn predict_name( &self, spec: &PlatformBackendSpec, diff --git a/docs/superpowers/specs/2026-10-14-backend-naming-design.md b/docs/superpowers/specs/2026-10-14-backend-naming-design.md new file mode 100644 index 0000000000..1dfd81b1fe --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-backend-naming-design.md @@ -0,0 +1,78 @@ +# Design Spec: Each adapter names its own backends + +**Status:** Proposed, 2026-10-14. Raised as draft pull request IABTechLab/trusted-server#1390 (from jwrosewell/trusted-server#42), for Tech Lab review. +**Author:** 51Degrees (contributed), for Tech Lab review +**Related specs:** `2026-08-10-config-first-auction-provider-architecture-design.md`, `2026-03-19-edgezero-migration-design.md`, `2026-10-14-last-names-in-core-design.md` +**Related PRs:** jwrosewell/trusted-server#42, IABTechLab/trusted-server#1390 +**Issues:** IABTechLab/trusted-server#1339, IABTechLab/trusted-server#1132 +**Last updated:** 2026-10-17 + +> **Why this spec exists.** Core held a backend naming and transport timer policy for each of the four adapters, so that startup validation and each adapter's registration predicted the same backend names before any was registered. The policies called no platform, and they still named every platform in core, where the rule is that core names none. + +## 1. Problem + +`crates/trusted-server-core/src/platform/backend_naming.rs` carried an enum with a variant for Fastly, Axum, Cloudflare and Spin, the naming rule and the transport timer rounding of each, the backend budget of the one host whose names are a limited resource, and a second enum of the same four targets with each one's fan-out capability. `PlatformBackend` asked every adapter which variant it was, and `AuctionPlan::validate_for_target` took a variant and read the policy from core. + +Putting the host's rules in core kept two callers agreeing. Each adapter's startup called `validate_for_target` with its own variant, and each adapter's registration read the same policy for its names and, through the default `canonicalize_transport_timeout_ms`, for its transport timer. The `ts` command was not a third caller. The comment at the top of `backend_naming.rs` listed CLI validation among the callers, but the command never called the policy. Its one `PlatformBackend`, `NoBackend` in `crates/trusted-server-cli/src/commands/keys.rs`, answered the method that returned a variant with Axum and was never asked for a name. An adapter could not change how it names a backend without a change in core, and a fifth host could not be added without naming it there. The rule the stack serves is that core is the interfaces, the routing, the origin fetch and the caches, and names no hosting platform. + +The issue behind this change, "Core predicts each platform's backend names" (IABTechLab/trusted-server#1339), asks for three things. + +1. Core asks the running adapter for the name and timer of a backend through one interface. +2. Each adapter carries its own policy next to its registration, and where EdgeZero already owns a platform's registration the policy belongs in EdgeZero, with the series saying which. +3. The `ts` command asks the adapter it is told about. + +This change makes the first two. The third has no call to move because the command never made one, and section 3 says what the command would need. + +## 2. Design + +**Core keeps one interface.** `PlatformBackend` is how core asks the host what a backend will be called and what transport timer it allows. It already answered `predict_name` and `canonicalize_transport_timeout_ms`. It now also answers the two questions plan validation asks. `auction_backend_budget` is how many backend names the auction may register, which is `None` by default because a correlation name is not a limited resource. `transport_timeout_bucket_count` is how many names one provider can reach through the timer rounding, which is one by default. The default is exact for Axum, whose names do not carry the timer. Cloudflare and Spin also take the default although their names carry the first-byte timer in milliseconds and their timers are exact, so one provider can reach a different name for each timer. Taking the default does no harm because validation compares the count only with a budget, and neither adapter has one. A host that sets a budget must also report a count that matches its names, as Fastly does. The method that returned a platform variant is gone. + +**The adapter describes itself.** Plan validation takes an `AuctionTargetDescriptor` the adapter builds from its name, a backend, and its `AuctionTargetCapabilities`, which say whether it can call several providers at once and whether it caps a provider request's whole lifetime. Core validates the plan against that description and nothing else. The backend in the descriptor is the type the adapter's runtime services use, so the names validation predicts are the names the host registers, which is what keeping the policies in core was for. Only convention makes the two backends the same type. Each adapter makes the descriptor's backend in its `auction_target()` and makes its runtime services' backend where it assembles those services, so each adapter holds two instances of one type that keeps no state. No shared constructor joins the two instances and no test checks that they are the same type, on Fastly as much as on Axum, Cloudflare and Spin. Within one type, prediction and registration cannot differ, because `ensure` returns the name `predict_name` makes on all four adapters, and core refuses a provider launch whose registered name differs from the predicted one. + +**Each adapter carries its own policy beside its registration.** The repository holds four adapters, Axum, Cloudflare, Fastly and Spin, with no fifth, and all four change. The Fastly adapter's policy sits in `backend.rs` next to `BackendConfig::ensure`, the code that registers a dynamic backend, and `ensure` registers the name the policy predicts, so the two cannot drift. The name is a readable prefix of the specification cut to a bound and a digest of the whole specification, the transport timer rounds to a bounded ladder so a budget-derived timer cannot register a backend per request, and the auction keeps 160 of the 200 dynamic backend names a service allows. The Axum, Cloudflare and Spin adapters register nothing, so their names are correlation keys alone, each made in that adapter's `platform.rs` beside its `PlatformBackend`, and their timers are the exact bounded value. Cloudflare and Spin make the same shape of name today and still each own their function, so a change for one is a change in one place. + +**The `ts` command names nothing.** Its one `PlatformBackend` is a backend nothing asks for, which now implements the interface alone. The command does not yet validate a plan against a target, as the auction guide says. How the command will get a descriptor is not settled here. Each adapter's `auction_target()` is `pub(crate)` and the command links no adapter crate, so the command cannot call any of them. The guide names what is missing, which is an EdgeZero callback that runs target-aware validation before `ts config push --adapter ` does remote work, and this change does not decide what that callback hands the command. + +**EdgeZero owns no adapter's registration.** The issue asks that a policy live in EdgeZero where EdgeZero already owns that platform's registration, and that the series say which. EdgeZero owns none. The Fastly adapter registers its backends itself through the platform SDK in `backend.rs`. At the pinned EdgeZero revision (`683202c`), EdgeZero's own Fastly proxy (`edgezero-adapter-fastly/src/proxy.rs`) registers backends under its own `edgezero-dynamic-` prefix for its own proxy path, and this policy never predicted those names. The Cloudflare, Spin and Axum adapters of EdgeZero and of this repository register no backend at all. So every policy lives in its adapter and nothing moves into EdgeZero. + +**The certificate check.** A backend's specification carries `certificate_check`, which `proxy.certificate_check` sets for the origins core proxies. The Fastly adapter registers a backend that skips the check when it is `false`. The Axum, Cloudflare and Spin adapters register no backend, so the flag changes only the name each makes. The Axum client verifies every certificate against the public authorities built into it, and the Cloudflare and Spin clients take no option to skip the check. Axum, the adapter for local development where the setting is meant to be used, warns once at startup when it is `false`, so an operator whose self-signed origin still fails is told why (IABTechLab/trusted-server#1132). + +**What breaks outside core.** A deployment sees no change. A crate outside core that implements `PlatformBackend` or checks a plan against a target must change, which is why `CHANGELOG.md` marks the change as breaking. Gone from core are `BackendNamingPolicy`, `AuctionTargetId`, `PredictedBackend`, `BackendNamingError` and `PlatformBackend::naming_policy`. `AuctionPlan::validate_for_target` takes a `&AuctionTargetDescriptor` in place of an `AuctionTargetId`, and a crate builds the descriptor with `AuctionTargetDescriptor::new(name, backend, capabilities)` and `AuctionTargetCapabilities::new`. The descriptor now holds the adapter's backend, so it is no longer `Copy`, `PartialEq` or `Eq`, and it is still `Clone` and `Debug`. It has no `naming_policy()`, its methods take `&self`, and `id()` returns the adapter's name as a `&'static str` where it returned an `AuctionTargetId`. `PlatformBackend` gains `auction_backend_budget` and `transport_timeout_bucket_count`, both with defaults. A crate that implements `PlatformBackend` deletes its `naming_policy` method, because a method the trait no longer has is a compile error, and overrides `canonicalize_transport_timeout_ms`, `auction_backend_budget` and `transport_timeout_bucket_count` where its host rounds timers or limits backend names. The default `canonicalize_transport_timeout_ms` no longer follows a policy, so a crate that answered `BackendNamingPolicy::Fastly` and took the Fastly rounding from the default now gets the exact bounded timer unless it overrides the method. `docs/guide/integration-guide.md` shows an implementation without `naming_policy`. + +## 3. What is not in this change + +- The names a deployment sees. Each adapter predicts the names it predicted before, pinned by a test in that adapter. +- The capabilities each adapter declares. Fastly and Axum fan out, Cloudflare and Spin run one provider at a time, and no adapter claims a total request deadline, as before. +- The settings a deployment writes. No setting, environment variable or command line flag is added, changed or removed. +- What goes into a name. This change adds no input to a name. Each adapter's name function takes the same backend specification as before and makes the same name, and a provider's specification comes from its configured endpoint, its name and its timers (`ProviderPlan::backend_spec` in `crates/trusted-server-core/src/auction/plan.rs`), with nothing taken from a visitor's request. +- What happens when a plan fails validation. Each adapter's startup still fails, because its `app.rs` returns the error from `validate_for_target`, and the messages for too many providers, too many backend names, a name that cannot be made and two providers sharing a name read as they did. On Fastly, a name that cannot be made is now reported through the adapter's `predict_name`, so the error report gains the `TrustedServerError::Proxy` and `PlatformError::Backend` frames that registration already reports, and the message stays the same. +- Running target-aware validation from the `ts` command before a push, which is the third thing the issue asks for. The validation needs the EdgeZero callback the auction guide describes, and the command cannot build a descriptor itself, as section 2 says. +- The other platform names in core. The naming policy named four platforms and is gone, and plan validation in core now names none. Core still names Fastly in code this change does not touch, for example the `x-debug-fastly-pop` header and the `FASTLY_IS_STAGING` variable in `constants.rs` and the `fastly-client-ip` header that `settings.rs` accepts as the trusted client IP header, among others. `2026-10-14-last-names-in-core-design.md` takes the names it lists out of core, and the backend naming policy is not among them. + +## 4. Tests + +- Core validates a plan against a descriptor built from test doubles: a target that fans out accepts two providers, a target that does not refuses them naming the target and the count, a target whose budget the providers' timer buckets exceed is refused naming both numbers, a backend that cannot name a provider fails naming the provider and the target, two providers given one name are refused naming both and the name, and a disabled plan asks nothing of the target. No earlier test covered the budget refusal. +- The descriptor answers what the adapter gave it, including a backend that declares no budget. +- Core's four policy tests went with the policy and their cases moved to the adapters. Axum, Cloudflare and Spin each pin the name and the transport timer their backend makes for one specification, with the names the core tests pinned, and each adapter's capabilities test reads the descriptor it builds. Cloudflare and Spin keep a startup test that refuses a two-provider plan and accepts the same plan with the auction disabled. +- The Fastly adapter keeps three of the policy's tests in `backend.rs`, namely the pinned name with its digest, the bracketed and bare forms of an IPv6 host naming one backend, and the two timer points. Its `platform.rs` keeps the timer ladder tests, which now run against the adapter's own policy and not the default method that read core's. `backend.rs` adds a refused control character in the scheme, where the host and the Host override were already tested, and a bucket count equal to the number of distinct timers a 2,000 ms ceiling reaches. The capabilities test pins the budget of 160. +- The orchestrator test `planned_standard_instances_have_distinct_backends_and_independent_results` expected a 750 ms transport timer, which was the Fastly rounding that core's naming double applied to the test's 777 ms budget. The double now keeps the exact bounded value, so the test expects 777 ms for each of the two providers' backends, and those two assertions are the only ones in the orchestrator tests that changed. Core's tests no longer exercise the Fastly rounding, and the Fastly adapter's tests cover it. +- `startup_warns_once_that_turning_the_certificate_check_off_is_not_honored`, in the Axum adapter's `app.rs`, builds the state with `proxy.certificate_check = false` and finds one warning saying certificates are still verified, and builds it with the check on and finds none. +- Not tested. Nothing checks that an adapter's descriptor and its runtime services use the same backend type. +- Not tested. No test drives a plan through the Fastly backend to the budget of 160. The refusal is tested in core with a double and the 160 is pinned in the Fastly capabilities test, so the two are tested apart. +- Not tested. The bucket count is compared with the distinct timers only at a 2,000 ms ceiling, where the coarse ladder is never reached. At a 60,000 ms ceiling the ladder test bounds the distinct timers at 20 and one assertion checks that two providers fit the budget, but nothing asserts that the count equals the timers the ladder reaches. +- Not tested. Axum and Fastly have no startup test of a plan being refused. + +## Review log + +1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/47-backend-naming` at `2dbc2d731`. Issues found, one of them blocking. The opening note and the problem section named the `ts` command as one of three callers of the policy, when the command never called it and the callers were each adapter's startup validation and its registration. This revision names the real callers and fixes the two partly true claims, the coverage gaps and the advice the round gave. + +2. **Round 2, 2026-10-15.** A lighter review in a fresh session, against this page at `969905300`, of the round 1 fixes and the new text. Approved, with no blocking issues. This revision adds the descriptor changes the compatibility paragraph left out, names the Tech Lab draft pull request, and counts the changed orchestrator assertions as two. + +## Revision record + +| Date | Change | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-10-14 | First draft. | +| 2026-10-15 | Fixes from review round 1. The real callers of the policy, the break for crates outside core, the descriptor resting on convention, the timer bucket default, the untested paths, the platform names left in core, what the issue asks, the pull request references and the review log. | +| 2026-10-15 | Fixes from review round 2. The descriptor changes the compatibility paragraph left out, the Tech Lab pull request reference and the count of changed orchestrator assertions. | +| 2026-10-17 | The certificate check (IABTechLab/trusted-server#1132). The Axum adapter warns once at startup that it does not honor `proxy.certificate_check = false`, with its test, and the guides say which adapters honor the setting. |