1use std::future::Future;
15use std::net::{IpAddr, SocketAddr};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::body::Body;
20use axum::extract::{ConnectInfo, Path, Query, Request, State};
21use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{any, get, post, put};
24use axum::{Extension, Json, Router};
25use boatramp_core::access::{AccessConfig, BasicAuth};
26use boatramp_core::authz::{GrantedRole, TokenMeta};
27use boatramp_core::config::{DeployConfig, SiteConfig};
28use boatramp_core::cose::{self, Claims, Signer};
29use boatramp_core::deploy::{
30 DeployMetaInput, DeployStore, FileEntry, GcOptions, GcReport, Manifest,
31};
32use boatramp_core::matcher::Pattern;
33use boatramp_core::route::{self, Outcome};
34use boatramp_core::{DeployError, StorageError};
35use futures::StreamExt;
36use serde::{Deserialize, Serialize};
37
38mod admin_api;
39pub mod sql_shim;
40#[cfg(feature = "oidc")]
41pub(crate) use admin_api::auth_exchange;
42pub(crate) use admin_api::{
43 activate_deployment, cert_status, create_deployment, current_deployment, delete_compute,
44 delete_site, get_compute, get_daemon_config, get_deployment, get_site_config, invalidate_cache,
45 list_aliases, list_compute, list_deployments, list_sites, prune_delete, prune_report, put_blob,
46 put_compute, put_daemon_config, put_site_config, remove_alias, rollback_daemon_config,
47 scrub_blobs, set_alias,
48};
49#[cfg(feature = "handlers")]
50pub(crate) use admin_api::{
51 delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
52 list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
53 put_graphql_subgraph, register_graphql_safelist,
54};
55mod auth;
56#[cfg(feature = "console")]
57pub mod console;
58mod content;
59mod control_api;
60#[cfg(feature = "compression")]
61pub(crate) use content::maybe_compress;
62pub(crate) use content::multipart_byteranges;
63pub(crate) use content::{
64 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
65};
66pub(crate) use control_api::{
67 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
68 cluster_revoke, cluster_rotate_key, create_join_token, create_token, get_authz_policy,
69 list_root_anchors, list_tokens, put_authz_policy, remove_root_anchor, revoke_token,
70};
71#[cfg(all(test, feature = "handlers"))]
72use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
73mod domain_verify;
74pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
75pub mod envelope;
76#[cfg(feature = "handlers")]
77mod graphql_apq;
78#[cfg(feature = "handlers")]
79mod graphql_cache;
80#[cfg(feature = "handlers")]
81mod graphql_data;
82#[cfg(feature = "handlers")]
83mod graphql_federation;
84#[cfg(feature = "handlers")]
85mod graphql_gateway;
86#[cfg(feature = "handlers")]
87mod graphql_graphiql;
88#[cfg(feature = "handlers")]
89mod graphql_guard;
90#[cfg(feature = "handlers")]
91mod graphql_plan;
92#[cfg(feature = "handlers")]
93mod graphql_registry;
94#[cfg(feature = "handlers")]
95mod graphql_subscription;
96#[cfg(feature = "handlers")]
97mod handler_cache;
98#[cfg(feature = "handlers")]
99mod handler_dispatch;
100#[cfg(feature = "handlers")]
101pub(crate) use handler_dispatch::{
102 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
103 read_blob_fully,
104};
105#[cfg(all(feature = "handlers", test))]
106use handler_dispatch::{resolve_env, set_forwarded_headers};
107mod function_api;
108pub(crate) use function_api::{
109 alias_function, deploy_function, list_functions, remove_function, rollback_function,
110};
111#[cfg(all(test, feature = "handlers"))]
112use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
113mod gateway;
114mod host;
115pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
116#[cfg(feature = "http3")]
117mod http3;
118mod limits;
119#[cfg(feature = "handlers")]
120mod logs;
121#[cfg(feature = "handlers")]
122mod metrics;
123#[cfg(feature = "oidc")]
124mod oidc;
125mod operator;
126pub(crate) use operator::prometheus_metrics;
127#[cfg(feature = "handlers")]
128pub(crate) use operator::{
129 operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
130};
131mod proxy;
132pub use proxy::spawn_compute_reconcile;
133pub(crate) use proxy::{
134 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
135 proxy, COMPUTE_WAKE_TIMEOUT,
136};
137#[cfg(feature = "handlers")]
139pub(crate) use proxy::is_upgrade_request;
140#[cfg(all(test, feature = "handlers"))]
141use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
142mod project_api;
143pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
144mod project_scope;
145pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
146mod ratelimit;
147mod routes;
148pub use routes::{router, router_with};
149#[cfg(feature = "mcp")]
150mod mcp_http;
151#[cfg(feature = "handlers")]
152mod scheduler;
153mod serve_pipeline;
154pub use serve_pipeline::http_redirect_router;
155#[cfg(all(test, feature = "handlers"))]
156use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
157pub(crate) use serve_pipeline::{
158 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
159 BootstrapAttestation,
160};
161pub mod signer;
164mod srvmetrics;
165#[cfg(all(feature = "handlers", test))]
166use scheduler::run_scheduler_tick;
167#[cfg(feature = "handlers")]
168pub(crate) use scheduler::{
169 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
170};
171#[cfg(feature = "handlers")]
172use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
173#[cfg(feature = "handlers")]
174mod function_runtime;
175#[cfg(feature = "handlers")]
176pub(crate) use function_runtime::{
177 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
178 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
179 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
180 put_trigger_handler, webhook_ingress,
181};
182#[cfg(feature = "handlers")]
183mod stream;
184#[cfg(feature = "handlers")]
185mod workflow;
186pub use auth::{require_auth, Auth};
187#[cfg(feature = "http3")]
188pub use http3::{
189 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
190 Http3Error,
191};
192pub use limits::{ServerLimits, UploadGuard};
193#[cfg(feature = "oidc")]
194pub use oidc::{OidcConfig, OidcError, OidcVerifier};
195use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
196#[cfg(feature = "handlers")]
197pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
198#[cfg(feature = "handlers")]
199pub(crate) use workflow::{
200 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
201 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
202};
203pub use srvmetrics::{server_metrics, ServerMetrics};
206
207#[derive(Clone, Default)]
212pub struct HandlerRuntime {
213 #[cfg(feature = "handlers")]
214 inner: Option<Arc<HandlerRuntimeInner>>,
215}
216
217#[cfg(feature = "handlers")]
218struct HandlerRuntimeInner {
219 engine: boatramp_handlers::HandlerEngine,
220 kv: Arc<dyn boatramp_core::kv::KvStore>,
221 storage: Arc<dyn boatramp_core::Storage>,
222 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
226 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
229 site_semaphores:
232 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
233 stream_semaphores:
237 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
238 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
241 metrics: metrics::Metrics,
244 logs: Arc<logs::LogStore>,
246 #[cfg(feature = "handlers")]
250 graphql_cache: graphql_cache::GraphqlCache,
251 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
257 max_blob_bytes: std::sync::OnceLock<u64>,
261 max_component_bytes: std::sync::OnceLock<u64>,
266 function_meter_locks:
270 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
271 function_semaphores:
274 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
275 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
280 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
284 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
292 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
297}
298
299pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
302
303impl HandlerRuntime {
304 pub fn disabled() -> Self {
306 Self::default()
307 }
308
309 #[cfg(feature = "handlers")]
315 pub fn new(
316 engine: boatramp_handlers::HandlerEngine,
317 kv: Arc<dyn boatramp_core::kv::KvStore>,
318 storage: Arc<dyn boatramp_core::Storage>,
319 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
320 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
321 ) -> Self {
322 Self {
323 inner: Some(Arc::new(HandlerRuntimeInner {
324 engine,
325 kv,
326 storage,
327 sql,
328 messaging,
329 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
330 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
331 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
332 metrics: metrics::Metrics::default(),
333 logs: Arc::new(logs::LogStore::default()),
334 #[cfg(feature = "handlers")]
335 graphql_cache: graphql_cache::GraphqlCache::default(),
336 cron_leader_gate: std::sync::OnceLock::new(),
337 max_blob_bytes: std::sync::OnceLock::new(),
338 max_component_bytes: std::sync::OnceLock::new(),
339 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
340 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
341 watch_provider: std::sync::OnceLock::new(),
342 provision_tier: std::sync::OnceLock::new(),
343 invoker: std::sync::OnceLock::new(),
344 federation_runner: std::sync::OnceLock::new(),
345 })),
346 }
347 }
348
349 #[cfg(feature = "handlers")]
352 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
353 self.inner.as_ref().and_then(|inner| inner.sql.clone())
354 }
355
356 #[cfg(feature = "handlers")]
360 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
361 self.inner
362 .as_ref()
363 .and_then(|inner| inner.invoker.get().cloned())
364 }
365
366 #[cfg(feature = "handlers")]
370 pub(crate) async fn introspect_subgraph_sdl(
371 &self,
372 deploy: &DeployStore,
373 project: boatramp_core::project::ProjectRef<'_>,
374 function: &boatramp_core::function::Function,
375 component: &str,
376 ) -> Result<String, function_runtime::SubgraphSdlError> {
377 match self.inner.as_ref() {
378 Some(inner) => {
379 function_runtime::introspect_service_sdl(
380 inner, deploy, project, function, component,
381 )
382 .await
383 }
384 None => Err(function_runtime::SubgraphSdlError::Unavailable),
385 }
386 }
387
388 #[cfg(feature = "handlers")]
394 pub fn set_invoker(&self, deploy: DeployStore) {
395 if let Some(inner) = self.inner.as_ref() {
396 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
397 deploy,
398 Arc::downgrade(inner),
399 ));
400 let _ = inner.invoker.set(invoker);
401 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
404 inner,
405 )));
406 let _ = inner.federation_runner.set(runner);
407 }
408 }
409
410 #[cfg(feature = "handlers")]
414 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
415 self.inner.as_ref().and_then(|inner| inner.sql.clone())
416 }
417
418 #[cfg(feature = "handlers")]
422 pub fn set_watch_provider(
423 &self,
424 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
425 ) {
426 if let Some(inner) = self.inner.as_ref() {
427 let _ = inner.watch_provider.set(provider);
428 }
429 }
430
431 #[cfg(feature = "handlers")]
435 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
436 if let Some(inner) = self.inner.as_ref() {
437 let _ = inner.provision_tier.set(tier);
438 }
439 }
440
441 #[cfg(feature = "handlers")]
445 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
446 if let Some(inner) = self.inner.as_ref() {
447 let _ = inner.max_blob_bytes.set(max_bytes);
448 }
449 }
450
451 #[cfg(feature = "handlers")]
454 pub fn set_max_component_bytes(&self, max_bytes: u64) {
455 if let Some(inner) = self.inner.as_ref() {
456 let _ = inner.max_component_bytes.set(max_bytes);
457 }
458 }
459
460 #[cfg(feature = "handlers")]
465 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
466 if let Some(inner) = self.inner.as_ref() {
467 let _ = inner.cron_leader_gate.set(gate);
468 }
469 }
470
471 #[cfg(feature = "handlers")]
478 async fn precheck_activation(
479 &self,
480 deploy: &DeployStore,
481 manifest: &Manifest,
482 site_config: Option<&SiteConfig>,
483 ) -> Result<(), String> {
484 let Some(inner) = self.inner.as_ref() else {
485 return Ok(());
486 };
487 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
490 return Ok(());
491 }
492 let site_handlers = site_config
494 .and_then(|c| c.handlers.as_ref())
495 .filter(|h| h.enabled)
496 .ok_or_else(|| {
497 "deployment ships handlers/consumers but the site has them disabled".to_string()
498 })?;
499 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
500
501 for handler in &manifest.config.handlers {
503 precheck_component(
504 deploy,
505 manifest,
506 site_handlers,
507 inner,
508 max_component,
509 &handler.imports,
510 &handler.component,
511 &format!("handler {:?}", handler.route),
512 )
513 .await?;
514 }
515 for consumer in &manifest.config.consumers {
516 precheck_component(
517 deploy,
518 manifest,
519 site_handlers,
520 inner,
521 max_component,
522 &consumer.imports,
523 &consumer.component,
524 &format!("consumer {:?}", consumer.topic),
525 )
526 .await?;
527 }
528 Ok(())
529 }
530
531 #[cfg(not(feature = "handlers"))]
532 async fn precheck_activation(
533 &self,
534 _deploy: &DeployStore,
535 _manifest: &Manifest,
536 _site_config: Option<&SiteConfig>,
537 ) -> Result<(), String> {
538 Ok(())
539 }
540}
541
542#[derive(Default, Clone)]
548pub struct ServerOptions {
549 pub limits: ServerLimits,
551 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
554 pub default_site: Option<String>,
557 pub implicit_routing: bool,
563 pub protect_previews: bool,
568 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
572 pub issuer: Option<Arc<dyn Signer>>,
576 pub bootstrap_secret: Option<String>,
581 pub bootstrap_attestation: Option<String>,
587 pub mesh_control: Option<Arc<dyn MeshControl>>,
591 pub cors_allowed_origins: Vec<String>,
601 #[cfg(feature = "oidc")]
604 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
605 pub posture: boatramp_core::security::SecurityPosture,
609 pub served_over_tls: bool,
614 pub pop_origin: Option<String>,
621 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
625 #[cfg(feature = "console")]
629 pub console: Option<console::ConsoleMount>,
630}
631
632#[derive(Clone, Copy)]
636struct ServedOverTls(bool);
637
638#[derive(Clone, Copy, Default)]
643struct ImplicitRouting(bool);
644
645const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
657
658pub struct DaemonRuntime {
659 baseline: boatramp_core::daemon_config::ConfigBaseline,
660 state: std::sync::RwLock<DaemonState>,
661 reload: tokio::sync::Notify,
664}
665
666struct DaemonState {
667 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
668 generation: Option<String>,
669}
670
671pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
676 #[cfg(feature = "console")]
680 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
681 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
682 None => (false, None, None),
683 };
684 #[cfg(not(feature = "console"))]
685 let (console_enabled, console_host, console_path) = (false, None, None);
686 boatramp_core::daemon_config::ConfigBaseline {
687 default_site: options.default_site.clone(),
688 protect_previews: options.protect_previews,
689 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
690 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
691 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
692 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
693 compute_vcpus: 0,
694 compute_mem_mib: 0,
695 console_enabled,
696 console_host,
697 console_path,
698 max_upload_ceiling: options.posture.max_upload_bytes,
699 max_concurrent_uploads_ceiling: None,
700 posture: options.posture,
701 }
702}
703
704impl DaemonRuntime {
705 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
709 let effective =
710 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
711 Self {
712 baseline,
713 state: std::sync::RwLock::new(DaemonState {
714 effective,
715 generation: None,
716 }),
717 reload: tokio::sync::Notify::new(),
718 }
719 }
720
721 pub fn notify_reload(&self) {
725 self.reload.notify_one();
726 }
727
728 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
730 self.state
731 .read()
732 .expect("daemon config lock")
733 .effective
734 .clone()
735 }
736
737 pub fn generation(&self) -> Option<String> {
740 self.state
741 .read()
742 .expect("daemon config lock")
743 .generation
744 .clone()
745 }
746
747 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
749 &self.baseline
750 }
751
752 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
755 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
756 let generation = deploy.daemon_config_generation().await?;
757 let effective = Arc::new(cfg.resolve(&self.baseline));
758 *self.state.write().expect("daemon config lock") = DaemonState {
759 effective,
760 generation,
761 };
762 Ok(())
763 }
764}
765
766#[derive(Clone, Copy, Default)]
769struct PreviewPolicy {
770 protect: bool,
771}
772
773#[derive(Clone, Default)]
778struct Issuer(Option<Arc<dyn Signer>>);
779
780#[derive(Clone, Default)]
785struct BootstrapGate(Option<Arc<BootstrapInner>>);
786
787struct BootstrapInner {
788 secret_hash: String,
791 lock: tokio::sync::Mutex<()>,
794}
795
796impl BootstrapGate {
797 fn new(secret: Option<&str>) -> Self {
798 Self(secret.filter(|s| !s.is_empty()).map(|s| {
799 Arc::new(BootstrapInner {
800 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
801 lock: tokio::sync::Mutex::new(()),
802 })
803 }))
804 }
805}
806
807#[async_trait::async_trait]
811pub trait MeshControl: Send + Sync {
812 async fn admit(
820 &self,
821 mesh_pubkey_hex: &str,
822 jti: &str,
823 possession_proof: &[u8],
824 proof_iat: u64,
825 now: u64,
826 advertise_addr: Option<&str>,
827 ) -> Result<JoinOutcome, String>;
828
829 async fn rotate_key(&self) -> Result<String, String>;
833
834 async fn revoke(&self, node: u64) -> Result<(), String>;
838
839 async fn members(&self) -> Result<Vec<MeshMember>, String>;
843
844 async fn promote(&self, node: u64) -> Result<(), String>;
847}
848
849pub enum JoinOutcome {
851 Admitted {
854 members: Vec<String>,
856 addrs: std::collections::BTreeMap<u64, String>,
858 },
859 TokenSpent,
861 ProofInvalid,
863 Revoked,
866}
867
868#[derive(Debug, Clone, Serialize)]
870pub struct MeshMember {
871 pub node: u64,
873 pub voter: bool,
875 pub caught_up: bool,
877 pub leader: bool,
879 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub addr: Option<String>,
884}
885
886#[derive(Clone, Default)]
889struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
890
891#[cfg(feature = "oidc")]
893#[derive(Clone, Default)]
894struct OidcState(Option<Arc<oidc::OidcVerifier>>);
895
896#[cfg(feature = "oidc")]
899const EXCHANGE_TTL_SECS: u64 = 3600;
900
901use boatramp_core::time::now_unix;
902
903#[derive(Clone)]
905struct CorsState(Arc<Vec<String>>);
906
907const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
909const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
912const CORS_MAX_AGE: &str = "600";
914
915fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
919 allowed.iter().any(|a| a == "*" || a == origin)
920}
921
922async fn cors(
929 State(allowed): State<CorsState>,
930 request: Request,
931 next: axum::middleware::Next,
932) -> Response {
933 let origin = request
934 .headers()
935 .get(header::ORIGIN)
936 .and_then(|v| v.to_str().ok())
937 .filter(|o| cors_origin_allowed(&allowed.0, o))
938 .map(str::to_string);
939 let is_preflight = request.method() == Method::OPTIONS
941 && request
942 .headers()
943 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
944 if is_preflight {
945 let allow_headers = request
947 .headers()
948 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
949 .and_then(|v| v.to_str().ok())
950 .map(str::to_string)
951 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
952 let mut response = Response::new(Body::empty());
953 *response.status_mut() = StatusCode::NO_CONTENT;
954 if let Some(origin) = origin {
955 let headers = response.headers_mut();
956 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
957 set_header(headers, header::VARY, "Origin");
958 set_header(
959 headers,
960 header::ACCESS_CONTROL_ALLOW_METHODS,
961 CORS_ALLOW_METHODS,
962 );
963 set_header(
964 headers,
965 header::ACCESS_CONTROL_ALLOW_HEADERS,
966 &allow_headers,
967 );
968 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
969 }
970 return response;
971 }
972 let mut response = next.run(request).await;
973 if let Some(origin) = origin {
974 let headers = response.headers_mut();
975 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
976 if let Ok(value) = HeaderValue::from_str("Origin") {
979 headers.append(header::VARY, value);
980 }
981 }
982 response
983}
984
985const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
990
991#[derive(Debug, thiserror::Error)]
993pub enum ServeError {
994 #[error("server I/O: {0}")]
996 Io(#[from] std::io::Error),
997}
998
999pub async fn serve(
1002 addr: SocketAddr,
1003 deploy: DeployStore,
1004 auth: Auth,
1005 handlers: HandlerRuntime,
1006) -> Result<(), ServeError> {
1007 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1008}
1009
1010pub async fn serve_with(
1012 addr: SocketAddr,
1013 deploy: DeployStore,
1014 auth: Auth,
1015 handlers: HandlerRuntime,
1016 options: ServerOptions,
1017) -> Result<(), ServeError> {
1018 let listener = tokio::net::TcpListener::bind(addr).await?;
1019 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1020 #[cfg(feature = "handlers")]
1023 let scheduler = handlers.spawn_scheduler(deploy.clone());
1024 let gateway_prober = gateway::spawn_active_health_prober();
1028 let app = router_with(deploy, auth, handlers, options)
1031 .into_make_service_with_connect_info::<SocketAddr>();
1032
1033 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1037 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
1038 shutdown_signal().await;
1039 let _ = signalled_tx.send(true);
1040 });
1041 let signalled = {
1042 let mut rx = signalled_rx;
1043 async move {
1044 let _ = rx.wait_for(|fired| *fired).await;
1045 }
1046 };
1047 let result = serve_with_drain_deadline(
1048 async move { server.await.map_err(ServeError::from) },
1049 signalled,
1050 DRAIN_DEADLINE,
1051 )
1052 .await;
1053 #[cfg(feature = "handlers")]
1055 if let Some(handle) = scheduler {
1056 handle.abort();
1057 }
1058 gateway_prober.abort();
1059 result
1060}
1061
1062async fn serve_with_drain_deadline<Srv, Sig>(
1067 server: Srv,
1068 signalled: Sig,
1069 deadline: Duration,
1070) -> Result<(), ServeError>
1071where
1072 Srv: Future<Output = Result<(), ServeError>>,
1073 Sig: Future<Output = ()>,
1074{
1075 tokio::pin!(server);
1076 let drain_cap = async move {
1077 signalled.await;
1078 tokio::time::sleep(deadline).await;
1079 };
1080 tokio::select! {
1081 result = &mut server => result,
1082 _ = drain_cap => {
1083 tracing::warn!(
1084 deadline_s = deadline.as_secs(),
1085 "drain deadline exceeded; forcing shutdown with requests still in flight"
1086 );
1087 Ok(())
1088 }
1089 }
1090}
1091
1092pub async fn shutdown_signal() {
1095 let ctrl_c = async {
1096 let _ = tokio::signal::ctrl_c().await;
1097 };
1098 #[cfg(unix)]
1099 let terminate = async {
1100 if let Ok(mut sig) =
1101 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1102 {
1103 sig.recv().await;
1104 }
1105 };
1106 #[cfg(not(unix))]
1107 let terminate = std::future::pending::<()>();
1108
1109 tokio::select! {
1110 _ = ctrl_c => {}
1111 _ = terminate => {}
1112 }
1113 tracing::info!("shutdown signal received; draining");
1114}
1115
1116async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1120 match daemon.generation() {
1121 Some(gen) => format!("ok gen={gen}"),
1122 None => "ok".to_string(),
1123 }
1124}
1125
1126async fn readyz(State(deploy): State<DeployStore>) -> Response {
1128 match deploy.ready().await {
1129 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1130 Err(err) => {
1131 tracing::warn!(error = %err, "readiness probe failed");
1132 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1133 }
1134 }
1135}
1136
1137#[derive(Clone)]
1142pub struct RequestId(pub String);
1143
1144fn request_id_for(headers: &HeaderMap) -> String {
1147 if let Some(id) = headers
1148 .get("x-request-id")
1149 .and_then(|v| v.to_str().ok())
1150 .map(str::trim)
1151 .filter(|s| !s.is_empty())
1152 {
1153 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1154 }
1155 use std::sync::atomic::{AtomicU64, Ordering};
1156 static SEQ: AtomicU64 = AtomicU64::new(0);
1157 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1158 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1159}
1160
1161struct AccessLog {
1165 request_id: String,
1166 method: Method,
1167 path: String,
1168 host: String,
1169 client: String,
1170 status: u16,
1171 encoding: String,
1173 start: std::time::Instant,
1174 bytes: std::sync::atomic::AtomicU64,
1175}
1176
1177impl Drop for AccessLog {
1178 fn drop(&mut self) {
1179 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1180 srvmetrics::server_metrics().record_request(self.status, bytes);
1183 tracing::info!(
1184 target: "boatramp::access",
1185 request_id = %self.request_id,
1186 method = %self.method,
1187 path = %self.path,
1188 host = %self.host,
1189 client = %self.client,
1190 status = self.status,
1191 bytes = bytes,
1192 encoding = %self.encoding,
1193 cache_result = srvmetrics::cache_result(self.status),
1194 elapsed_ms = self.start.elapsed().as_millis() as u64,
1195 "request"
1196 );
1197 }
1198}
1199
1200async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1204 let method = request.method().clone();
1205 let path = request.uri().path().to_string();
1206 let request_id = request_id_for(request.headers());
1209 request
1210 .extensions_mut()
1211 .insert(RequestId(request_id.clone()));
1212 let host = request
1213 .headers()
1214 .get(header::HOST)
1215 .and_then(|value| value.to_str().ok())
1216 .unwrap_or("-")
1217 .to_string();
1218 let client = request
1219 .extensions()
1220 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1221 .map(|info| info.0.ip().to_string())
1222 .unwrap_or_else(|| "-".to_string());
1223
1224 let start = std::time::Instant::now();
1225 let response = next.run(request).await;
1226 let encoding = response
1227 .headers()
1228 .get(header::CONTENT_ENCODING)
1229 .and_then(|v| v.to_str().ok())
1230 .unwrap_or("identity")
1231 .to_string();
1232 let log = AccessLog {
1233 request_id,
1234 method,
1235 path,
1236 host,
1237 client,
1238 status: response.status().as_u16(),
1239 encoding,
1240 start,
1241 bytes: std::sync::atomic::AtomicU64::new(0),
1242 };
1243
1244 let (parts, body) = response.into_parts();
1248 let counted = body.into_data_stream().map(move |chunk| {
1249 if let Ok(bytes) = &chunk {
1250 log.bytes
1251 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1252 }
1253 chunk
1254 });
1255 Response::from_parts(parts, Body::from_stream(counted))
1256}
1257
1258fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1260 req_headers
1261 .get(header::IF_NONE_MATCH)
1262 .and_then(|value| value.to_str().ok())
1263 .is_some_and(|value| {
1264 value
1265 .split(',')
1266 .map(str::trim)
1267 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1268 })
1269}
1270
1271fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1272 if let Ok(value) = HeaderValue::from_str(value) {
1273 headers.insert(name, value);
1274 }
1275}
1276
1277fn not_found() -> Response {
1278 (StatusCode::NOT_FOUND, "not found\n").into_response()
1279}
1280
1281fn redirect(status: u16, location: &str) -> Response {
1282 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1283 match HeaderValue::from_str(location) {
1284 Ok(location) => {
1285 let mut headers = HeaderMap::new();
1286 headers.insert(header::LOCATION, location);
1287 (status, headers).into_response()
1288 }
1289 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1290 }
1291}
1292
1293fn deploy_error_response(err: DeployError) -> Response {
1295 let status = match &err {
1296 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1297 StatusCode::NOT_FOUND
1298 }
1299 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1300 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1301 DeployError::Conflict(_) => StatusCode::CONFLICT,
1303 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1305 _ => StatusCode::INTERNAL_SERVER_ERROR,
1306 };
1307 tracing::warn!(error = %err, "request failed");
1308 (status, format!("{err}\n")).into_response()
1309}
1310
1311fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1316 boatramp_core::project::validate_resource_name(kind, value)
1317 .err()
1318 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1319}
1320
1321#[cfg(test)]
1322mod drain_tests {
1323 use super::*;
1324
1325 #[tokio::test]
1326 async fn deadline_forces_shutdown_after_signal() {
1327 let server = std::future::pending::<Result<(), ServeError>>();
1330 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1332 assert!(result.is_ok());
1333 }
1334
1335 #[tokio::test]
1336 async fn server_finishing_first_wins() {
1337 let server = async { Ok(()) };
1340 let signalled = std::future::pending::<()>();
1341 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1342 assert!(result.is_ok());
1343 }
1344
1345 #[tokio::test]
1346 async fn deadline_does_not_trip_before_signal() {
1347 let server = async {
1351 tokio::time::sleep(Duration::from_millis(40)).await;
1352 Err(ServeError::Io(std::io::Error::other("server error")))
1353 };
1354 let signalled = std::future::pending::<()>();
1355 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1356 assert!(result.is_err());
1357 }
1358}
1359
1360#[cfg(all(test, feature = "handlers"))]
1361mod tests {
1362 use super::*;
1363 use boatramp_core::cose::{LocalSigner, TokenAlg};
1364 use boatramp_core::project::ProjectRef;
1365
1366 #[test]
1367 fn query_string_parses_and_url_decodes() {
1368 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1369 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1370 assert_eq!(q.get("city").map(String::as_str), Some("São Paulo")); assert_eq!(q.get("flag").map(String::as_str), Some("")); assert_eq!(q.get("dup").map(String::as_str), Some("1")); }
1374
1375 #[test]
1376 fn cookie_header_parses_pairs() {
1377 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1378 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1379 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1380 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1381 }
1382
1383 #[test]
1384 fn apply_vary_merges_without_duplicates() {
1385 let base = (StatusCode::OK, "x").into_response();
1386 let r = apply_vary(base, &["accept-language".into()]);
1387 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1388 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1390 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1391 assert!(v.contains("accept-language") && v.contains("cookie"));
1392 assert_eq!(v.matches("accept-language").count(), 1);
1393 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1395 assert!(plain.headers().get(header::VARY).is_none());
1396 }
1397
1398 #[tokio::test]
1402 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1403 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1404 let public = keys.public_key();
1405
1406 let resp = create_join_token(
1408 Extension(Issuer(Some(keys.clone()))),
1409 Json(CreateJoinTokenRequest {
1410 ttl_secs: Some(600),
1411 }),
1412 )
1413 .await;
1414 assert_eq!(resp.status(), StatusCode::CREATED);
1415 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1416 .await
1417 .unwrap();
1418 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1419 let token = parsed["token"].as_str().unwrap();
1420 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1421 assert!(!jti.is_empty());
1422
1423 let no_issuer = create_join_token(
1425 Extension(Issuer(None)),
1426 Json(CreateJoinTokenRequest { ttl_secs: None }),
1427 )
1428 .await;
1429 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1430 }
1431
1432 #[tokio::test]
1437 async fn function_write_path_deploy_rollback_alias_remove() {
1438 use boatramp_core::function::Lifecycle;
1439 use boatramp_core::kv::MemoryKv;
1440 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1441
1442 struct FakeStorage {
1445 present: bool,
1446 }
1447 #[async_trait::async_trait]
1448 impl Storage for FakeStorage {
1449 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1450 Err(StorageError::NotFound(String::new()))
1451 }
1452 async fn get_range(
1453 &self,
1454 _: &str,
1455 _: u64,
1456 _: Option<u64>,
1457 ) -> Result<GetObject, StorageError> {
1458 Err(StorageError::NotFound(String::new()))
1459 }
1460 async fn put(
1461 &self,
1462 _: &str,
1463 _: ByteStream,
1464 _: PutMeta,
1465 ) -> Result<ObjectMeta, StorageError> {
1466 Err(StorageError::unsupported("fake"))
1467 }
1468 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1469 if self.present {
1470 Ok(ObjectMeta {
1471 key: key.to_string(),
1472 ..Default::default()
1473 })
1474 } else {
1475 Err(StorageError::NotFound(key.to_string()))
1476 }
1477 }
1478 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1479 Ok(())
1480 }
1481 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1482 Ok(Vec::new())
1483 }
1484 }
1485
1486 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1487 let status = resp.status();
1488 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1489 .await
1490 .unwrap();
1491 let value = if bytes.is_empty() {
1492 serde_json::Value::Null
1493 } else {
1494 serde_json::from_slice(&bytes).unwrap()
1495 };
1496 (status, value)
1497 }
1498
1499 let deploy = DeployStore::new(
1500 Arc::new(FakeStorage { present: true }),
1501 Arc::new(MemoryKv::new()),
1502 );
1503 let v1 = "a".repeat(64);
1504 let v2 = "b".repeat(64);
1505
1506 let (st, body) = body_json(
1508 deploy_function(
1509 State(deploy.clone()),
1510 axum::extract::Extension(crate::ProjectContext::default()),
1511 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1512 axum::extract::Query(DeployFunctionQuery::default()),
1513 Path("greeter".to_string()),
1514 Json(FunctionUpsert {
1515 component: v1.clone(),
1516 config: Default::default(),
1517 lifecycle: Lifecycle::Independent,
1518 }),
1519 )
1520 .await,
1521 )
1522 .await;
1523 assert_eq!(st, StatusCode::OK);
1524 assert_eq!(body["active"], v1);
1525
1526 let (_, body) = body_json(
1528 deploy_function(
1529 State(deploy.clone()),
1530 axum::extract::Extension(crate::ProjectContext::default()),
1531 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1532 axum::extract::Query(DeployFunctionQuery::default()),
1533 Path("greeter".to_string()),
1534 Json(FunctionUpsert {
1535 component: v2.clone(),
1536 config: Default::default(),
1537 lifecycle: Lifecycle::Independent,
1538 }),
1539 )
1540 .await,
1541 )
1542 .await;
1543 assert_eq!(body["active"], v2);
1544 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1545
1546 let (st, body) = body_json(
1548 rollback_function(
1549 State(deploy.clone()),
1550 axum::extract::Extension(crate::ProjectContext::default()),
1551 Path("greeter".to_string()),
1552 Json(RollbackBody { to: v1.clone() }),
1553 )
1554 .await,
1555 )
1556 .await;
1557 assert_eq!(st, StatusCode::OK);
1558 assert_eq!(body["active"], v1);
1559
1560 let resp = rollback_function(
1562 State(deploy.clone()),
1563 axum::extract::Extension(crate::ProjectContext::default()),
1564 Path("greeter".to_string()),
1565 Json(RollbackBody { to: "c".repeat(64) }),
1566 )
1567 .await;
1568 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1569
1570 let (st, body) = body_json(
1572 alias_function(
1573 State(deploy.clone()),
1574 axum::extract::Extension(crate::ProjectContext::default()),
1575 Path(("greeter".to_string(), "prod".to_string())),
1576 Json(AliasBody {
1577 version: v2.clone(),
1578 }),
1579 )
1580 .await,
1581 )
1582 .await;
1583 assert_eq!(st, StatusCode::OK);
1584 assert_eq!(body["aliases"]["prod"], v2);
1585
1586 let (st, _) = body_json(
1588 remove_function(
1589 State(deploy.clone()),
1590 axum::extract::Extension(crate::ProjectContext::default()),
1591 Path("greeter".to_string()),
1592 )
1593 .await,
1594 )
1595 .await;
1596 assert_eq!(st, StatusCode::NO_CONTENT);
1597 assert!(deploy
1598 .get_function(ProjectRef::DEFAULT, "greeter")
1599 .await
1600 .unwrap()
1601 .is_none());
1602
1603 let empty = DeployStore::new(
1605 Arc::new(FakeStorage { present: false }),
1606 Arc::new(MemoryKv::new()),
1607 );
1608 let resp = deploy_function(
1609 State(empty),
1610 axum::extract::Extension(crate::ProjectContext::default()),
1611 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1612 axum::extract::Query(DeployFunctionQuery::default()),
1613 Path("orphan".to_string()),
1614 Json(FunctionUpsert {
1615 component: v1.clone(),
1616 config: Default::default(),
1617 lifecycle: Lifecycle::default(),
1618 }),
1619 )
1620 .await;
1621 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1622 }
1623
1624 struct StubControl {
1628 admits: std::sync::Mutex<Vec<(String, String)>>,
1629 respond: StubJoin,
1630 }
1631 #[derive(Clone, Copy)]
1632 enum StubJoin {
1633 Admit,
1634 Spent,
1635 Invalid,
1636 Revoked,
1637 }
1638
1639 #[async_trait::async_trait]
1640 impl MeshControl for StubControl {
1641 async fn admit(
1642 &self,
1643 mesh_pubkey_hex: &str,
1644 jti: &str,
1645 _proof: &[u8],
1646 _proof_iat: u64,
1647 _now: u64,
1648 _advertise_addr: Option<&str>,
1649 ) -> Result<JoinOutcome, String> {
1650 self.admits
1651 .lock()
1652 .unwrap()
1653 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1654 Ok(match self.respond {
1655 StubJoin::Admit => JoinOutcome::Admitted {
1656 members: vec!["signed-member".to_string()],
1657 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1658 },
1659 StubJoin::Spent => JoinOutcome::TokenSpent,
1660 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1661 StubJoin::Revoked => JoinOutcome::Revoked,
1662 })
1663 }
1664 async fn rotate_key(&self) -> Result<String, String> {
1665 Ok("cafe".to_string())
1666 }
1667 async fn revoke(&self, _node: u64) -> Result<(), String> {
1668 Ok(())
1669 }
1670 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1671 Ok(Vec::new())
1672 }
1673 async fn promote(&self, _node: u64) -> Result<(), String> {
1674 Ok(())
1675 }
1676 }
1677
1678 #[tokio::test]
1682 async fn cluster_join_dispatches_and_maps_outcomes() {
1683 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1684 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1685 let auth = Auth::with_key(keys.public_key(), kv);
1686 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1687 let req = |proof: &str| JoinRequest {
1688 token: token.clone(),
1689 mesh_pubkey: "302a300506032b6570032100feed".into(),
1690 possession_proof: proof.to_string(),
1691 proof_iat: now_unix(),
1692 advertise_addr: Some("https://joiner:7000".into()),
1693 };
1694
1695 let admitter = Arc::new(StubControl {
1697 admits: std::sync::Mutex::new(Vec::new()),
1698 respond: StubJoin::Admit,
1699 });
1700 let resp = cluster_join(
1701 Extension(auth.clone()),
1702 Extension(MeshControlHandle(Some(admitter.clone()))),
1703 Json(req("aa01")),
1704 )
1705 .await;
1706 assert_eq!(resp.status(), StatusCode::OK);
1707 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1708
1709 let spent = Arc::new(StubControl {
1711 admits: std::sync::Mutex::new(Vec::new()),
1712 respond: StubJoin::Spent,
1713 });
1714 assert_eq!(
1715 cluster_join(
1716 Extension(auth.clone()),
1717 Extension(MeshControlHandle(Some(spent))),
1718 Json(req("aa01")),
1719 )
1720 .await
1721 .status(),
1722 StatusCode::CONFLICT
1723 );
1724 let invalid = Arc::new(StubControl {
1725 admits: std::sync::Mutex::new(Vec::new()),
1726 respond: StubJoin::Invalid,
1727 });
1728 assert_eq!(
1729 cluster_join(
1730 Extension(auth.clone()),
1731 Extension(MeshControlHandle(Some(invalid))),
1732 Json(req("aa01")),
1733 )
1734 .await
1735 .status(),
1736 StatusCode::FORBIDDEN
1737 );
1738 let revoked = Arc::new(StubControl {
1740 admits: std::sync::Mutex::new(Vec::new()),
1741 respond: StubJoin::Revoked,
1742 });
1743 assert_eq!(
1744 cluster_join(
1745 Extension(auth.clone()),
1746 Extension(MeshControlHandle(Some(revoked))),
1747 Json(req("aa01")),
1748 )
1749 .await
1750 .status(),
1751 StatusCode::FORBIDDEN
1752 );
1753
1754 let ok = Arc::new(StubControl {
1756 admits: std::sync::Mutex::new(Vec::new()),
1757 respond: StubJoin::Admit,
1758 });
1759 assert_eq!(
1760 cluster_join(
1761 Extension(auth.clone()),
1762 Extension(MeshControlHandle(Some(ok))),
1763 Json(req("not-hex")),
1764 )
1765 .await
1766 .status(),
1767 StatusCode::BAD_REQUEST
1768 );
1769
1770 let none = cluster_join(
1772 Extension(auth),
1773 Extension(MeshControlHandle(None)),
1774 Json(req("aa01")),
1775 )
1776 .await;
1777 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1778 }
1779
1780 #[tokio::test]
1784 async fn bootstrap_mints_the_first_token_once() {
1785 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1786 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1787 let public = keys.public_key();
1788 let deploy = DeployStore::new(
1789 Arc::new(MemStorage::default()),
1790 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1791 );
1792 let secret = "s3cr3t-bootstrap-value";
1793 let gate = BootstrapGate::new(Some(secret));
1794 let issuer = Issuer(Some(keys.clone()));
1795 let bearer = |s: &str| {
1796 let mut h = HeaderMap::new();
1797 h.insert(
1798 AUTHORIZATION,
1799 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1800 );
1801 h
1802 };
1803 let req = || BootstrapRequest {
1804 roles: vec!["admin".to_string()],
1805 ttl_secs: None,
1806 };
1807
1808 let bad = bootstrap_token(
1810 State(deploy.clone()),
1811 Extension(issuer.clone()),
1812 Extension(gate.clone()),
1813 bearer("wrong"),
1814 Json(req()),
1815 )
1816 .await;
1817 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1818
1819 let ok = bootstrap_token(
1821 State(deploy.clone()),
1822 Extension(issuer.clone()),
1823 Extension(gate.clone()),
1824 bearer(secret),
1825 Json(req()),
1826 )
1827 .await;
1828 assert_eq!(ok.status(), StatusCode::CREATED);
1829 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1830 .await
1831 .unwrap();
1832 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1833 let token = json["token"].as_str().unwrap();
1834 let id = json["id"].as_str().unwrap();
1835 let verified = cose::verify(token, &public, now_unix()).unwrap();
1836 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1837 assert!(deploy
1838 .list_token_meta()
1839 .await
1840 .unwrap()
1841 .iter()
1842 .any(|m| m.revocation_id == id));
1843
1844 let reuse = bootstrap_token(
1846 State(deploy.clone()),
1847 Extension(issuer.clone()),
1848 Extension(gate),
1849 bearer(secret),
1850 Json(req()),
1851 )
1852 .await;
1853 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1854
1855 let disabled = bootstrap_token(
1857 State(deploy),
1858 Extension(issuer),
1859 Extension(BootstrapGate(None)),
1860 bearer(secret),
1861 Json(req()),
1862 )
1863 .await;
1864 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1865 }
1866
1867 #[tokio::test]
1870 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1871 let control = Arc::new(StubControl {
1872 admits: std::sync::Mutex::new(Vec::new()),
1873 respond: StubJoin::Admit,
1874 });
1875 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1876 assert_eq!(resp.status(), StatusCode::OK);
1877 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1878 .await
1879 .unwrap();
1880 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1881 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1882
1883 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1884 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1885 }
1886
1887 #[test]
1888 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1889 use boatramp_core::security::SecurityProfile;
1890 let strict = SecurityProfile::MultiTenant.preset();
1891 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
1895 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1896 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1897
1898 assert!(gateway_addr_allowed(public, &strict));
1900 assert!(!gateway_addr_allowed(private, &strict));
1901 assert!(!gateway_addr_allowed(loopback, &strict));
1902 assert!(!gateway_addr_allowed(metadata, &strict));
1903
1904 assert!(gateway_addr_allowed(public, &loose));
1907 assert!(gateway_addr_allowed(private, &loose));
1908 assert!(gateway_addr_allowed(loopback, &loose));
1909 assert!(!gateway_addr_allowed(metadata, &loose));
1910 }
1911
1912 #[test]
1913 fn resolve_env_merges_static_and_host_secrets() {
1914 use boatramp_core::config::HandlersSiteConfig;
1915
1916 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
1918
1919 let deploy_env = std::collections::BTreeMap::from([
1920 ("GREETING".to_string(), "hi".to_string()),
1921 ("OVERRIDE_ME".to_string(), "static".to_string()),
1922 ]);
1923 let site_handlers = HandlersSiteConfig {
1924 enabled: true,
1925 secrets: std::collections::BTreeMap::from([
1926 (
1928 "SECRET_TOKEN".to_string(),
1929 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1930 ),
1931 (
1932 "OVERRIDE_ME".to_string(),
1933 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1934 ),
1935 (
1936 "MISSING".to_string(),
1937 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
1938 ),
1939 ]),
1940 ..Default::default()
1941 };
1942 let env = resolve_env("blog", &deploy_env, &site_handlers);
1943
1944 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
1948 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
1949 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
1950 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
1951
1952 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
1953 }
1954
1955 fn req() -> Request {
1956 Request::builder()
1957 .uri("/")
1958 .header(header::HOST, "example.com")
1959 .body(Body::empty())
1960 .unwrap()
1961 }
1962
1963 #[test]
1964 fn forwarded_headers_set_standard_triple() {
1965 let mut request = req();
1966 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1967 let h = request.headers();
1968 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
1969 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
1970 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
1971 }
1972
1973 #[test]
1974 fn forwarded_for_overwrites_spoofed_value() {
1975 let mut request = Request::builder()
1978 .uri("/")
1979 .header(header::HOST, "example.com")
1980 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
1981 .body(Body::empty())
1982 .unwrap();
1983 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1984 let values: Vec<_> = request
1985 .headers()
1986 .get_all("x-forwarded-for")
1987 .iter()
1988 .collect();
1989 assert_eq!(values.len(), 1);
1990 assert_eq!(values[0], "203.0.113.7");
1991 }
1992
1993 #[test]
1994 fn forwarded_proto_preserves_upstream_tls() {
1995 let mut request = Request::builder()
1997 .uri("/")
1998 .header(header::HOST, "example.com")
1999 .header("x-forwarded-proto", "https")
2000 .body(Body::empty())
2001 .unwrap();
2002 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2003 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2004 }
2005
2006 #[test]
2007 fn forwarded_host_absent_when_no_host_header() {
2008 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2009 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2010 assert!(request.headers().get("x-forwarded-host").is_none());
2011 assert_eq!(
2012 request.headers().get("x-forwarded-for").unwrap(),
2013 "203.0.113.7"
2014 );
2015 }
2016
2017 use boatramp_core::kv::{KvStore, MemoryKv};
2020 use boatramp_core::messaging::{LogMessaging, Messaging};
2021 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2022
2023 const EVENT_CONSUMER: &[u8] =
2024 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2025
2026 #[derive(Default)]
2027 struct MemStorage {
2028 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2029 }
2030
2031 #[async_trait::async_trait]
2032 impl boatramp_core::Storage for MemStorage {
2033 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2034 let bytes = self
2035 .objects
2036 .lock()
2037 .unwrap()
2038 .get(key)
2039 .cloned()
2040 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2041 let body: ByteStream =
2042 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2043 Ok(GetObject {
2044 meta: ObjectMeta {
2045 key: key.to_string(),
2046 ..Default::default()
2047 },
2048 body,
2049 })
2050 }
2051 async fn get_range(
2052 &self,
2053 key: &str,
2054 _: u64,
2055 _: Option<u64>,
2056 ) -> Result<GetObject, StorageError> {
2057 self.get(key).await
2058 }
2059 async fn put(
2060 &self,
2061 key: &str,
2062 mut body: ByteStream,
2063 _: PutMeta,
2064 ) -> Result<ObjectMeta, StorageError> {
2065 use futures::StreamExt;
2066 let mut buf = Vec::new();
2067 while let Some(chunk) = body.next().await {
2068 buf.extend_from_slice(&chunk?);
2069 }
2070 self.objects.lock().unwrap().insert(key.to_string(), buf);
2071 Ok(ObjectMeta {
2072 key: key.to_string(),
2073 ..Default::default()
2074 })
2075 }
2076 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2077 self.objects
2078 .lock()
2079 .unwrap()
2080 .get(key)
2081 .map(|_| ObjectMeta {
2082 key: key.to_string(),
2083 ..Default::default()
2084 })
2085 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2086 }
2087 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2088 self.objects.lock().unwrap().remove(key);
2089 Ok(())
2090 }
2091 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2092 Ok(Vec::new())
2093 }
2094 }
2095
2096 fn observed_state(
2098 workload: &str,
2099 healthy: bool,
2100 phase: boatramp_core::compute::ReplicaPhase,
2101 ) -> boatramp_core::compute::ObservedInstance {
2102 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2103 boatramp_core::compute::ObservedInstance {
2104 handle: InstanceHandle {
2105 workload: workload.into(),
2106 replica: 0,
2107 backend_ref: "ref-0".into(),
2108 },
2109 node: 1,
2110 backend: "vmm".into(),
2111 endpoint: Endpoint {
2112 scheme: Scheme::Http,
2113 host: "10.0.0.2".into(),
2114 port: 80,
2115 },
2116 region: None,
2117 healthy,
2118 phase,
2119 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2120 workload: workload.into(),
2121 replica: 0,
2122 data_ref: "snap-0".into(),
2123 }),
2124 }
2125 }
2126
2127 #[tokio::test]
2128 async fn has_parked_replica_detects_a_zeroed_replica() {
2129 use boatramp_core::compute::ReplicaPhase;
2130 let storage = Arc::new(MemStorage::default());
2131 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2132 let deploy = DeployStore::new(storage, kv);
2133
2134 assert!(!has_parked_replica(&deploy, "w").await);
2136 deploy
2138 .set_replica_state(
2139 ProjectRef::DEFAULT,
2140 &observed_state("w", true, ReplicaPhase::Running),
2141 )
2142 .await
2143 .unwrap();
2144 assert!(!has_parked_replica(&deploy, "w").await);
2145 deploy
2147 .set_replica_state(
2148 ProjectRef::DEFAULT,
2149 &observed_state("w", false, ReplicaPhase::Zero),
2150 )
2151 .await
2152 .unwrap();
2153 assert!(has_parked_replica(&deploy, "w").await);
2154 }
2155
2156 #[tokio::test]
2157 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2158 use boatramp_core::compute::ReplicaPhase;
2159 let storage = Arc::new(MemStorage::default());
2160 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2161 let deploy = DeployStore::new(storage, kv);
2162
2163 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2165 assert!(empty.is_empty());
2166
2167 deploy
2169 .set_replica_state(
2170 ProjectRef::DEFAULT,
2171 &observed_state("w", true, ReplicaPhase::Running),
2172 )
2173 .await
2174 .unwrap();
2175 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2176 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2177 }
2178
2179 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2183 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2184 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2185 let storage = Arc::new(MemStorage::default());
2186 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2187 let mq = LogMessaging::new(storage, kv.clone());
2188 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2189 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2190 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2191 let topic = "blog/orders/created";
2192
2193 for _ in 0..3 {
2195 mq.publish(topic, b"ok").await.unwrap();
2196 }
2197 loop {
2198 let acked = dispatch_consumer_batch(
2199 &engine,
2200 &mq,
2201 &metrics::Metrics::default(),
2202 "blog",
2203 topic,
2204 "blog/",
2205 &hash,
2206 EVENT_CONSUMER,
2207 &bindings,
2208 Limits::default(),
2209 Duration::from_secs(30),
2210 5,
2211 10,
2212 )
2213 .await;
2214 if acked == 0 {
2215 break;
2216 }
2217 }
2218 assert_eq!(
2219 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2220 Some(b"3".to_vec())
2221 );
2222
2223 mq.publish(topic, b"fail").await.unwrap();
2226 for _ in 0..5 {
2227 dispatch_consumer_batch(
2228 &engine,
2229 &mq,
2230 &metrics::Metrics::default(),
2231 "blog",
2232 topic,
2233 "blog/",
2234 &hash,
2235 EVENT_CONSUMER,
2236 &bindings,
2237 Limits::default(),
2238 Duration::ZERO,
2239 2,
2240 10,
2241 )
2242 .await;
2243 }
2244 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2245 assert_eq!(
2247 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2248 Some(b"3".to_vec())
2249 );
2250 }
2251
2252 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2256 async fn scheduler_runs_current_consumers_not_previews() {
2257 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2258 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2259 use boatramp_handlers::{HandlerEngine, Limits};
2260 use futures::StreamExt;
2261
2262 let storage = Arc::new(MemStorage::default());
2263 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2264 let deploy = DeployStore::new(storage.clone(), kv.clone());
2265 let messaging: Arc<dyn Messaging> =
2266 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2267
2268 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2270 let stream: ByteStream =
2271 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2272 .boxed();
2273 deploy.put_blob(&hash, stream).await.unwrap();
2274 let mut files = std::collections::BTreeMap::new();
2275 files.insert(
2276 "consumer.wasm".to_string(),
2277 FileEntry {
2278 hash: hash.clone(),
2279 size: EVENT_CONSUMER.len() as u64,
2280 content_type: None,
2281 variants: std::collections::BTreeMap::new(),
2282 },
2283 );
2284 let manifest = Manifest {
2285 files,
2286 config: DeployConfig {
2287 consumers: vec![ConsumerConfig {
2288 topic: "orders/created".into(),
2289 component: "consumer.wasm".into(),
2290 imports: vec!["wasi:keyvalue".into()],
2291 }],
2292 ..Default::default()
2293 },
2294 ..Default::default()
2295 };
2296 let id = deploy.put_manifest(&manifest).await.unwrap();
2297 deploy
2298 .activate(ProjectRef::DEFAULT, "blog", &id)
2299 .await
2300 .unwrap();
2301 deploy
2302 .set_site_config(
2303 ProjectRef::DEFAULT,
2304 "blog",
2305 &SiteConfig {
2306 handlers: Some(HandlersSiteConfig {
2307 enabled: true,
2308 allow_imports: vec!["wasi:keyvalue".into()],
2309 ..Default::default()
2310 }),
2311 ..Default::default()
2312 },
2313 )
2314 .await
2315 .unwrap();
2316
2317 messaging
2319 .publish("blog/orders/created", b"live")
2320 .await
2321 .unwrap();
2322 messaging
2323 .publish("blog/_preview/abc/orders/created", b"preview")
2324 .await
2325 .unwrap();
2326
2327 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2328 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2329 let inner = rt.inner.clone().unwrap();
2330 let mut cache = std::collections::HashMap::new();
2331 let mut crons = std::collections::HashMap::new();
2332 let now = CronNow {
2333 minute: 0,
2334 hour: 0,
2335 dom: 1,
2336 month: 1,
2337 dow: 0,
2338 minute_stamp: 0,
2339 };
2340 for _ in 0..3 {
2341 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, now)
2342 .await
2343 .unwrap();
2344 }
2345
2346 assert_eq!(
2348 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2349 Some(b"1".to_vec())
2350 );
2351 assert_eq!(
2354 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2355 .await
2356 .unwrap(),
2357 None
2358 );
2359 }
2360
2361 const KV_COUNTER: &[u8] =
2366 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2367
2368 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2373 async fn function_invoker_runs_target_buffers_and_meters() {
2374 use boatramp_core::deploy::DeployStore;
2375 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2376 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2377 use futures::StreamExt;
2378
2379 const HTTP_200: &[u8] =
2382 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2383
2384 let storage = Arc::new(MemStorage::default());
2385 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2386 let deploy = DeployStore::new(storage.clone(), kv.clone());
2387
2388 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2389 let stream: ByteStream =
2390 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2391 deploy.put_blob(&hash, stream).await.unwrap();
2392 let function = Function {
2393 name: "target".into(),
2394 owner: Owner::Project("default".into()),
2395 versions: vec![FunctionVersion {
2396 id: "v1".into(),
2397 component: hash.clone(),
2398 created: 0,
2399 lifecycle: Lifecycle::Independent,
2400 }],
2401 active: "v1".into(),
2402 aliases: Default::default(),
2403 config: Default::default(),
2404 };
2405 deploy
2406 .put_function(ProjectRef::DEFAULT, &function)
2407 .await
2408 .unwrap();
2409
2410 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2411 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2412 rt.set_invoker(deploy.clone());
2413 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2414
2415 let request = || InvokeRequest {
2416 method: "GET".into(),
2417 path: "/".into(),
2418 headers: vec![],
2419 body: vec![],
2420 };
2421
2422 let response = invoker.invoke("target", request(), 1).await.unwrap();
2424 assert_eq!(response.status, 200);
2425
2426 let metering = deploy
2428 .get_metering(ProjectRef::DEFAULT, "target")
2429 .await
2430 .unwrap()
2431 .unwrap();
2432 assert_eq!(metering.invocations, 1);
2433
2434 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2436 assert!(matches!(err, InvokeError::NotFound));
2437 }
2438
2439 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2445 async fn federation_runner_enforces_the_safelist_before_planning() {
2446 use boatramp_core::deploy::DeployStore;
2447 use boatramp_core::project::ProjectRef;
2448 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2449
2450 let storage = Arc::new(MemStorage::default());
2451 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2452 let deploy = DeployStore::new(storage.clone(), kv.clone());
2453 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2454 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2455 rt.set_invoker(deploy.clone());
2456 let runner = rt
2457 .inner
2458 .as_ref()
2459 .unwrap()
2460 .federation_runner
2461 .get()
2462 .unwrap()
2463 .scoped(ProjectRef::new("default"));
2464
2465 let req = |query: &str| GraphqlRequest {
2466 query: Some(query.to_string()),
2467 persisted_hash: None,
2468 variables: "{}".to_string(),
2469 operation_name: None,
2470 authorization: None,
2471 };
2472
2473 assert!(matches!(
2475 runner.run(req("{ me { id } }"), 1).await,
2476 Err(SupergraphRunError::NotSafelisted)
2477 ));
2478
2479 let query = "{ me { id } }";
2482 let hash = crate::graphql_apq::sha256_hex(query);
2483 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2484 .await
2485 .unwrap();
2486 assert!(matches!(
2487 runner.run(req(query), 1).await,
2488 Err(SupergraphRunError::PlanFailed(_))
2489 ));
2490
2491 let persisted = GraphqlRequest {
2493 query: None,
2494 persisted_hash: Some("deadbeef".to_string()),
2495 variables: "{}".to_string(),
2496 operation_name: None,
2497 authorization: None,
2498 };
2499 assert!(matches!(
2500 runner.run(persisted, 1).await,
2501 Err(SupergraphRunError::NotSafelisted)
2502 ));
2503 }
2504
2505 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2511 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2512 use crate::scheduler::{run_scheduler_tick, CronNow};
2513 use boatramp_core::deploy::DeployStore;
2514 use boatramp_core::function::{
2515 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2516 };
2517 use boatramp_handlers::{HandlerEngine, Limits};
2518 use futures::StreamExt;
2519
2520 const HTTP_200: &[u8] =
2521 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2522
2523 let storage = Arc::new(MemStorage::default());
2524 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2525 let deploy = DeployStore::new(storage.clone(), kv.clone());
2526
2527 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2528 let stream: ByteStream =
2529 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2530 deploy.put_blob(&hash, stream).await.unwrap();
2531
2532 let acme = ProjectRef::new("acme");
2534 let function = Function {
2535 name: "worker".into(),
2536 owner: Owner::Project("acme".into()),
2537 versions: vec![FunctionVersion {
2538 id: "v1".into(),
2539 component: hash.clone(),
2540 created: 0,
2541 lifecycle: Lifecycle::Independent,
2542 }],
2543 active: "v1".into(),
2544 aliases: Default::default(),
2545 config: Default::default(),
2546 };
2547 deploy.put_function(acme, &function).await.unwrap();
2548 let inv = Invocation {
2549 id: "inv1".into(),
2550 function: "worker".into(),
2551 version: "v1".into(),
2552 mode: InvokeMode::Async,
2553 status: InvocationStatus::Queued,
2554 idempotency_key: None,
2555 attempts: 0,
2556 request_b64: None,
2557 request_content_type: None,
2558 result: None,
2559 created: 0,
2560 updated: 0,
2561 };
2562 deploy.put_invocation(acme, &inv).await.unwrap();
2563
2564 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2565 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2566 let inner = rt.inner.as_ref().unwrap();
2567
2568 let mut wasm_cache = std::collections::HashMap::new();
2571 let mut cron_state = std::collections::HashMap::new();
2572 let now = CronNow {
2573 minute: 0,
2574 hour: 0,
2575 dom: 1,
2576 month: 1,
2577 dow: 0,
2578 minute_stamp: 0,
2579 };
2580 run_scheduler_tick(inner, &deploy, &mut wasm_cache, &mut cron_state, now)
2581 .await
2582 .unwrap();
2583
2584 let settled = deploy
2586 .get_invocation(acme, "worker", "inv1")
2587 .await
2588 .unwrap()
2589 .expect("invocation still present in acme");
2590 assert_eq!(settled.status, InvocationStatus::Succeeded);
2591 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2593 assert_eq!(metering.invocations, 1);
2594 assert!(deploy
2596 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2597 .await
2598 .unwrap()
2599 .is_none());
2600 assert!(deploy
2601 .get_metering(ProjectRef::DEFAULT, "worker")
2602 .await
2603 .unwrap()
2604 .is_none());
2605 }
2606
2607 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2622 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2623 use boatramp_core::deploy::DeployStore;
2624 use boatramp_core::function::{
2625 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2626 };
2627 use boatramp_handlers::{HandlerEngine, Limits};
2628 use futures::StreamExt;
2629
2630 let storage = Arc::new(MemStorage::default());
2631 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2632 let deploy = DeployStore::new(storage.clone(), kv.clone());
2633
2634 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2637 let stream: ByteStream =
2638 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2639 deploy.put_blob(&hash, stream).await.unwrap();
2640
2641 let store = Function {
2646 name: "store".into(),
2647 owner: Owner::Project("default".into()),
2648 versions: vec![FunctionVersion {
2649 id: "v1".into(),
2650 component: hash.clone(),
2651 created: 0,
2652 lifecycle: Lifecycle::Independent,
2653 }],
2654 active: "v1".into(),
2655 aliases: Default::default(),
2656 config: FunctionConfig {
2657 imports: vec!["wasi:keyvalue".into()],
2658 ..Default::default()
2659 },
2660 };
2661 let acme = ProjectRef::new("acme");
2662 let globex = ProjectRef::new("globex");
2663
2664 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2665 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2666 let inner = rt.inner.as_ref().unwrap();
2667
2668 let request = || {
2669 axum::http::Request::builder()
2670 .method("GET")
2671 .uri("/")
2672 .body(axum::body::Body::empty())
2673 .unwrap()
2674 };
2675
2676 let component = store.resolve(&store.active).unwrap().to_owned();
2679 for project in [acme, globex, ProjectRef::DEFAULT] {
2680 let (response, _) =
2681 execute_function(inner, &deploy, project, &store, &component, request(), 0).await;
2682 assert!(response.status().is_success(), "invocation should succeed");
2683 }
2684
2685 assert_eq!(
2688 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
2689 Some(b"1".to_vec()),
2690 "acme's write must be tenant-qualified"
2691 );
2692 assert_eq!(
2693 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
2694 Some(b"1".to_vec()),
2695 "globex's write must be tenant-qualified"
2696 );
2697 assert_eq!(
2698 kv.get("hkv/fn/store/hits").await.unwrap(),
2699 Some(b"1".to_vec()),
2700 "the default project must keep the byte-identical pre-project key"
2701 );
2702 }
2705
2706 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2710 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
2711 use boatramp_core::config::{
2712 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2713 };
2714 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2715 use boatramp_handlers::{HandlerEngine, Limits};
2716 use futures::StreamExt;
2717 use std::sync::atomic::Ordering;
2718
2719 let storage = Arc::new(MemStorage::default());
2720 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2721 let deploy = DeployStore::new(storage.clone(), kv.clone());
2722
2723 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2724 let stream: ByteStream =
2725 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2726 deploy.put_blob(&hash, stream).await.unwrap();
2727 let mut files = std::collections::BTreeMap::new();
2728 files.insert(
2729 "counter.wasm".to_string(),
2730 FileEntry {
2731 hash: hash.clone(),
2732 size: KV_COUNTER.len() as u64,
2733 content_type: None,
2734 variants: std::collections::BTreeMap::new(),
2735 },
2736 );
2737 let manifest = Manifest {
2738 files,
2739 config: DeployConfig {
2740 handlers: vec![HandlerConfig {
2741 route: "/".into(),
2742 methods: Vec::new(),
2743 component: "counter.wasm".into(),
2744 imports: vec!["wasi:keyvalue".into()],
2745 limits: None,
2746 env: std::collections::BTreeMap::new(),
2747 invoke_targets: Vec::new(),
2748 }],
2749 crons: vec![CronConfig {
2750 schedule: "* * * * *".into(),
2751 route: "/".into(),
2752 overlap: Overlap::Skip,
2753 }],
2754 ..Default::default()
2755 },
2756 ..Default::default()
2757 };
2758 let id = deploy.put_manifest(&manifest).await.unwrap();
2759 deploy
2760 .activate(ProjectRef::DEFAULT, "blog", &id)
2761 .await
2762 .unwrap();
2763 deploy
2764 .set_site_config(
2765 ProjectRef::DEFAULT,
2766 "blog",
2767 &SiteConfig {
2768 handlers: Some(HandlersSiteConfig {
2769 enabled: true,
2770 allow_imports: vec!["wasi:keyvalue".into()],
2771 ..Default::default()
2772 }),
2773 ..Default::default()
2774 },
2775 )
2776 .await
2777 .unwrap();
2778
2779 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2780 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2781 let inner = rt.inner.clone().unwrap();
2782 let mut wasm = std::collections::HashMap::new();
2783 let mut crons = std::collections::HashMap::new();
2784 let at = |stamp| CronNow {
2785 minute: 0,
2786 hour: 0,
2787 dom: 1,
2788 month: 1,
2789 dow: 0,
2790 minute_stamp: stamp,
2791 };
2792
2793 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2795 .await
2796 .unwrap();
2797 for h in handles {
2798 h.await.unwrap();
2799 }
2800 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2801
2802 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2804 .await
2805 .unwrap();
2806 assert!(handles.is_empty());
2807 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2808
2809 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(101))
2811 .await
2812 .unwrap();
2813 for h in handles {
2814 h.await.unwrap();
2815 }
2816 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2817
2818 crons
2822 .get("default|blog|cron|0")
2823 .unwrap()
2824 .running
2825 .store(true, Ordering::Release);
2826 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(102))
2827 .await
2828 .unwrap();
2829 assert!(handles.is_empty());
2830 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2831 }
2832
2833 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2837 async fn cron_leader_gate_suppresses_crons_off_leader() {
2838 use boatramp_core::config::{
2839 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2840 };
2841 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2842 use boatramp_handlers::{HandlerEngine, Limits};
2843 use futures::StreamExt;
2844
2845 let storage = Arc::new(MemStorage::default());
2846 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2847 let deploy = DeployStore::new(storage.clone(), kv.clone());
2848
2849 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2850 let stream: ByteStream =
2851 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2852 deploy.put_blob(&hash, stream).await.unwrap();
2853 let mut files = std::collections::BTreeMap::new();
2854 files.insert(
2855 "counter.wasm".to_string(),
2856 FileEntry {
2857 hash: hash.clone(),
2858 size: KV_COUNTER.len() as u64,
2859 content_type: None,
2860 variants: std::collections::BTreeMap::new(),
2861 },
2862 );
2863 let manifest = Manifest {
2864 files,
2865 config: DeployConfig {
2866 handlers: vec![HandlerConfig {
2867 route: "/".into(),
2868 methods: Vec::new(),
2869 component: "counter.wasm".into(),
2870 imports: vec!["wasi:keyvalue".into()],
2871 limits: None,
2872 env: std::collections::BTreeMap::new(),
2873 invoke_targets: Vec::new(),
2874 }],
2875 crons: vec![CronConfig {
2876 schedule: "* * * * *".into(),
2877 route: "/".into(),
2878 overlap: Overlap::Skip,
2879 }],
2880 ..Default::default()
2881 },
2882 ..Default::default()
2883 };
2884 let id = deploy.put_manifest(&manifest).await.unwrap();
2885 deploy
2886 .activate(ProjectRef::DEFAULT, "blog", &id)
2887 .await
2888 .unwrap();
2889 deploy
2890 .set_site_config(
2891 ProjectRef::DEFAULT,
2892 "blog",
2893 &SiteConfig {
2894 handlers: Some(HandlersSiteConfig {
2895 enabled: true,
2896 allow_imports: vec!["wasi:keyvalue".into()],
2897 ..Default::default()
2898 }),
2899 ..Default::default()
2900 },
2901 )
2902 .await
2903 .unwrap();
2904
2905 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2906 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2907 rt.set_cron_leader_gate(Arc::new(|| false));
2909 let inner = rt.inner.clone().unwrap();
2910 let mut wasm = std::collections::HashMap::new();
2911 let mut crons = std::collections::HashMap::new();
2912 let now = CronNow {
2913 minute: 0,
2914 hour: 0,
2915 dom: 1,
2916 month: 1,
2917 dow: 0,
2918 minute_stamp: 100,
2919 };
2920
2921 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, now)
2922 .await
2923 .unwrap();
2924 assert!(handles.is_empty(), "a non-leader must not fire crons");
2926 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
2927 }
2928
2929 #[tokio::test]
2936 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
2937 use boatramp_core::config::HandlersSiteConfig;
2938 use boatramp_core::project::ProjectRef;
2939 use boatramp_handlers::{HandlerEngine, Limits};
2940
2941 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
2942 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
2943 let sql_dir =
2945 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
2946 let _ = std::fs::remove_dir_all(&sql_dir);
2947 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
2948 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
2949
2950 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2951 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
2952 let inner = rt.inner.as_ref().unwrap();
2953
2954 let site = HandlersSiteConfig {
2956 enabled: true,
2957 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
2958 ..Default::default()
2959 };
2960 let env = std::collections::BTreeMap::new();
2961 let build = |imports: &[&str]| {
2962 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
2963 let site = &site;
2964 let env = &env;
2965 async move {
2966 crate::handler_dispatch::build_bindings(
2967 inner,
2968 ProjectRef::new("default"),
2969 "shop",
2970 "shop",
2971 None,
2972 &imports,
2973 site,
2974 env,
2975 &[],
2976 0,
2977 None,
2978 )
2979 .await
2980 .sql_database_names()
2981 }
2982 };
2983
2984 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
2987 assert_eq!(
2989 build(&["sql", "sql:*"]).await,
2990 vec!["", "privileged", "product"]
2991 );
2992 assert!(build(&["sql:secret"]).await.is_empty());
2994 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
2996
2997 let _ = std::fs::remove_dir_all(&sql_dir);
2998 }
2999}