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 async_drain_gate: Arc<tokio::sync::Semaphore>,
226 kv: Arc<dyn boatramp_core::kv::KvStore>,
227 storage: Arc<dyn boatramp_core::Storage>,
228 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
232 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
235 site_semaphores:
238 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
239 stream_semaphores:
243 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
244 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
247 metrics: metrics::Metrics,
250 logs: Arc<logs::LogStore>,
252 #[cfg(feature = "handlers")]
256 graphql_cache: graphql_cache::GraphqlCache,
257 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
263 max_blob_bytes: std::sync::OnceLock<u64>,
267 max_component_bytes: std::sync::OnceLock<u64>,
272 function_meter_locks:
276 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
277 function_semaphores:
280 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
281 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
286 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
290 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
298 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
303}
304
305pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
308
309impl HandlerRuntime {
310 pub fn disabled() -> Self {
312 Self::default()
313 }
314
315 #[cfg(feature = "handlers")]
321 pub fn new(
322 engine: boatramp_handlers::HandlerEngine,
323 kv: Arc<dyn boatramp_core::kv::KvStore>,
324 storage: Arc<dyn boatramp_core::Storage>,
325 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
326 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
327 ) -> Self {
328 let async_drain_slots = engine.async_max_concurrency().max(1);
331 Self {
332 inner: Some(Arc::new(HandlerRuntimeInner {
333 engine,
334 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
335 kv,
336 storage,
337 sql,
338 messaging,
339 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
340 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
341 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
342 metrics: metrics::Metrics::default(),
343 logs: Arc::new(logs::LogStore::default()),
344 #[cfg(feature = "handlers")]
345 graphql_cache: graphql_cache::GraphqlCache::default(),
346 cron_leader_gate: std::sync::OnceLock::new(),
347 max_blob_bytes: std::sync::OnceLock::new(),
348 max_component_bytes: std::sync::OnceLock::new(),
349 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
350 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
351 watch_provider: std::sync::OnceLock::new(),
352 provision_tier: std::sync::OnceLock::new(),
353 invoker: std::sync::OnceLock::new(),
354 federation_runner: std::sync::OnceLock::new(),
355 })),
356 }
357 }
358
359 #[cfg(feature = "handlers")]
362 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
363 self.inner.as_ref().and_then(|inner| inner.sql.clone())
364 }
365
366 #[cfg(feature = "handlers")]
370 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
371 self.inner
372 .as_ref()
373 .and_then(|inner| inner.invoker.get().cloned())
374 }
375
376 #[cfg(feature = "handlers")]
380 pub(crate) async fn introspect_subgraph_sdl(
381 &self,
382 deploy: &DeployStore,
383 project: boatramp_core::project::ProjectRef<'_>,
384 function: &boatramp_core::function::Function,
385 component: &str,
386 ) -> Result<String, function_runtime::SubgraphSdlError> {
387 match self.inner.as_ref() {
388 Some(inner) => {
389 function_runtime::introspect_service_sdl(
390 inner, deploy, project, function, component,
391 )
392 .await
393 }
394 None => Err(function_runtime::SubgraphSdlError::Unavailable),
395 }
396 }
397
398 #[cfg(feature = "handlers")]
404 pub fn set_invoker(&self, deploy: DeployStore) {
405 if let Some(inner) = self.inner.as_ref() {
406 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
407 deploy,
408 Arc::downgrade(inner),
409 ));
410 let _ = inner.invoker.set(invoker);
411 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
414 inner,
415 )));
416 let _ = inner.federation_runner.set(runner);
417 }
418 }
419
420 #[cfg(feature = "handlers")]
424 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
425 self.inner.as_ref().and_then(|inner| inner.sql.clone())
426 }
427
428 #[cfg(feature = "handlers")]
432 pub fn set_watch_provider(
433 &self,
434 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
435 ) {
436 if let Some(inner) = self.inner.as_ref() {
437 let _ = inner.watch_provider.set(provider);
438 }
439 }
440
441 #[cfg(feature = "handlers")]
445 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
446 if let Some(inner) = self.inner.as_ref() {
447 let _ = inner.provision_tier.set(tier);
448 }
449 }
450
451 #[cfg(feature = "handlers")]
455 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
456 if let Some(inner) = self.inner.as_ref() {
457 let _ = inner.max_blob_bytes.set(max_bytes);
458 }
459 }
460
461 #[cfg(feature = "handlers")]
464 pub fn set_max_component_bytes(&self, max_bytes: u64) {
465 if let Some(inner) = self.inner.as_ref() {
466 let _ = inner.max_component_bytes.set(max_bytes);
467 }
468 }
469
470 #[cfg(feature = "handlers")]
475 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
476 if let Some(inner) = self.inner.as_ref() {
477 let _ = inner.cron_leader_gate.set(gate);
478 }
479 }
480
481 #[cfg(feature = "handlers")]
488 async fn precheck_activation(
489 &self,
490 deploy: &DeployStore,
491 manifest: &Manifest,
492 site_config: Option<&SiteConfig>,
493 ) -> Result<(), String> {
494 let Some(inner) = self.inner.as_ref() else {
495 return Ok(());
496 };
497 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
500 return Ok(());
501 }
502 let site_handlers = site_config
504 .and_then(|c| c.handlers.as_ref())
505 .filter(|h| h.enabled)
506 .ok_or_else(|| {
507 "deployment ships handlers/consumers but the site has them disabled".to_string()
508 })?;
509 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
510
511 for handler in &manifest.config.handlers {
513 precheck_component(
514 deploy,
515 manifest,
516 site_handlers,
517 inner,
518 max_component,
519 &handler.imports,
520 &handler.component,
521 &format!("handler {:?}", handler.route),
522 )
523 .await?;
524 }
525 for consumer in &manifest.config.consumers {
526 precheck_component(
527 deploy,
528 manifest,
529 site_handlers,
530 inner,
531 max_component,
532 &consumer.imports,
533 &consumer.component,
534 &format!("consumer {:?}", consumer.topic),
535 )
536 .await?;
537 }
538 Ok(())
539 }
540
541 #[cfg(not(feature = "handlers"))]
542 async fn precheck_activation(
543 &self,
544 _deploy: &DeployStore,
545 _manifest: &Manifest,
546 _site_config: Option<&SiteConfig>,
547 ) -> Result<(), String> {
548 Ok(())
549 }
550}
551
552#[derive(Default, Clone)]
558pub struct ServerOptions {
559 pub limits: ServerLimits,
561 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
564 pub default_site: Option<String>,
567 pub implicit_routing: bool,
573 pub protect_previews: bool,
578 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
582 pub issuer: Option<Arc<dyn Signer>>,
586 pub bootstrap_secret: Option<String>,
591 pub bootstrap_attestation: Option<String>,
597 pub mesh_control: Option<Arc<dyn MeshControl>>,
601 pub cors_allowed_origins: Vec<String>,
611 #[cfg(feature = "oidc")]
614 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
615 pub posture: boatramp_core::security::SecurityPosture,
619 pub served_over_tls: bool,
624 pub pop_origin: Option<String>,
631 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
635 #[cfg(feature = "console")]
639 pub console: Option<console::ConsoleMount>,
640}
641
642#[derive(Clone, Copy)]
646struct ServedOverTls(bool);
647
648#[derive(Clone, Copy, Default)]
653struct ImplicitRouting(bool);
654
655const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
667
668pub struct DaemonRuntime {
669 baseline: boatramp_core::daemon_config::ConfigBaseline,
670 state: std::sync::RwLock<DaemonState>,
671 reload: tokio::sync::Notify,
674}
675
676struct DaemonState {
677 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
678 generation: Option<String>,
679}
680
681pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
686 #[cfg(feature = "console")]
690 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
691 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
692 None => (false, None, None),
693 };
694 #[cfg(not(feature = "console"))]
695 let (console_enabled, console_host, console_path) = (false, None, None);
696 boatramp_core::daemon_config::ConfigBaseline {
697 default_site: options.default_site.clone(),
698 protect_previews: options.protect_previews,
699 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
700 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
701 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
702 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
703 compute_vcpus: 0,
704 compute_mem_mib: 0,
705 console_enabled,
706 console_host,
707 console_path,
708 max_upload_ceiling: options.posture.max_upload_bytes,
709 max_concurrent_uploads_ceiling: None,
710 posture: options.posture,
711 }
712}
713
714impl DaemonRuntime {
715 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
719 let effective =
720 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
721 Self {
722 baseline,
723 state: std::sync::RwLock::new(DaemonState {
724 effective,
725 generation: None,
726 }),
727 reload: tokio::sync::Notify::new(),
728 }
729 }
730
731 pub fn notify_reload(&self) {
735 self.reload.notify_one();
736 }
737
738 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
740 self.state
741 .read()
742 .expect("daemon config lock")
743 .effective
744 .clone()
745 }
746
747 pub fn generation(&self) -> Option<String> {
750 self.state
751 .read()
752 .expect("daemon config lock")
753 .generation
754 .clone()
755 }
756
757 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
759 &self.baseline
760 }
761
762 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
765 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
766 let generation = deploy.daemon_config_generation().await?;
767 let effective = Arc::new(cfg.resolve(&self.baseline));
768 *self.state.write().expect("daemon config lock") = DaemonState {
769 effective,
770 generation,
771 };
772 Ok(())
773 }
774}
775
776#[derive(Clone, Copy, Default)]
779struct PreviewPolicy {
780 protect: bool,
781}
782
783#[derive(Clone, Default)]
788struct Issuer(Option<Arc<dyn Signer>>);
789
790#[derive(Clone, Default)]
795struct BootstrapGate(Option<Arc<BootstrapInner>>);
796
797struct BootstrapInner {
798 secret_hash: String,
801 lock: tokio::sync::Mutex<()>,
804}
805
806impl BootstrapGate {
807 fn new(secret: Option<&str>) -> Self {
808 Self(secret.filter(|s| !s.is_empty()).map(|s| {
809 Arc::new(BootstrapInner {
810 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
811 lock: tokio::sync::Mutex::new(()),
812 })
813 }))
814 }
815}
816
817#[async_trait::async_trait]
821pub trait MeshControl: Send + Sync {
822 async fn admit(
830 &self,
831 mesh_pubkey_hex: &str,
832 jti: &str,
833 possession_proof: &[u8],
834 proof_iat: u64,
835 now: u64,
836 advertise_addr: Option<&str>,
837 ) -> Result<JoinOutcome, String>;
838
839 async fn rotate_key(&self) -> Result<String, String>;
843
844 async fn revoke(&self, node: u64) -> Result<(), String>;
848
849 async fn members(&self) -> Result<Vec<MeshMember>, String>;
853
854 async fn promote(&self, node: u64) -> Result<(), String>;
857}
858
859pub enum JoinOutcome {
861 Admitted {
864 members: Vec<String>,
866 addrs: std::collections::BTreeMap<u64, String>,
868 },
869 TokenSpent,
871 ProofInvalid,
873 Revoked,
876}
877
878#[derive(Debug, Clone, Serialize)]
880pub struct MeshMember {
881 pub node: u64,
883 pub voter: bool,
885 pub caught_up: bool,
887 pub leader: bool,
889 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub addr: Option<String>,
894}
895
896#[derive(Clone, Default)]
899struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
900
901#[cfg(feature = "oidc")]
903#[derive(Clone, Default)]
904struct OidcState(Option<Arc<oidc::OidcVerifier>>);
905
906#[cfg(feature = "oidc")]
909const EXCHANGE_TTL_SECS: u64 = 3600;
910
911use boatramp_core::time::now_unix;
912
913#[derive(Clone)]
915struct CorsState(Arc<Vec<String>>);
916
917const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
919const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
922const CORS_MAX_AGE: &str = "600";
924
925fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
929 allowed.iter().any(|a| a == "*" || a == origin)
930}
931
932async fn cors(
939 State(allowed): State<CorsState>,
940 request: Request,
941 next: axum::middleware::Next,
942) -> Response {
943 let origin = request
944 .headers()
945 .get(header::ORIGIN)
946 .and_then(|v| v.to_str().ok())
947 .filter(|o| cors_origin_allowed(&allowed.0, o))
948 .map(str::to_string);
949 let is_preflight = request.method() == Method::OPTIONS
951 && request
952 .headers()
953 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
954 if is_preflight {
955 let allow_headers = request
957 .headers()
958 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
959 .and_then(|v| v.to_str().ok())
960 .map(str::to_string)
961 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
962 let mut response = Response::new(Body::empty());
963 *response.status_mut() = StatusCode::NO_CONTENT;
964 if let Some(origin) = origin {
965 let headers = response.headers_mut();
966 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
967 set_header(headers, header::VARY, "Origin");
968 set_header(
969 headers,
970 header::ACCESS_CONTROL_ALLOW_METHODS,
971 CORS_ALLOW_METHODS,
972 );
973 set_header(
974 headers,
975 header::ACCESS_CONTROL_ALLOW_HEADERS,
976 &allow_headers,
977 );
978 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
979 }
980 return response;
981 }
982 let mut response = next.run(request).await;
983 if let Some(origin) = origin {
984 let headers = response.headers_mut();
985 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
986 if let Ok(value) = HeaderValue::from_str("Origin") {
989 headers.append(header::VARY, value);
990 }
991 }
992 response
993}
994
995const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1000
1001#[derive(Debug, thiserror::Error)]
1003pub enum ServeError {
1004 #[error("server I/O: {0}")]
1006 Io(#[from] std::io::Error),
1007}
1008
1009pub async fn serve(
1012 addr: SocketAddr,
1013 deploy: DeployStore,
1014 auth: Auth,
1015 handlers: HandlerRuntime,
1016) -> Result<(), ServeError> {
1017 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1018}
1019
1020pub async fn serve_with(
1022 addr: SocketAddr,
1023 deploy: DeployStore,
1024 auth: Auth,
1025 handlers: HandlerRuntime,
1026 options: ServerOptions,
1027) -> Result<(), ServeError> {
1028 let listener = tokio::net::TcpListener::bind(addr).await?;
1029 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1030 #[cfg(feature = "handlers")]
1033 let scheduler = handlers.spawn_scheduler(deploy.clone());
1034 let gateway_prober = gateway::spawn_active_health_prober();
1038 let app = router_with(deploy, auth, handlers, options)
1041 .into_make_service_with_connect_info::<SocketAddr>();
1042
1043 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1047 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
1048 shutdown_signal().await;
1049 let _ = signalled_tx.send(true);
1050 });
1051 let signalled = {
1052 let mut rx = signalled_rx;
1053 async move {
1054 let _ = rx.wait_for(|fired| *fired).await;
1055 }
1056 };
1057 let result = serve_with_drain_deadline(
1058 async move { server.await.map_err(ServeError::from) },
1059 signalled,
1060 DRAIN_DEADLINE,
1061 )
1062 .await;
1063 #[cfg(feature = "handlers")]
1065 if let Some(handle) = scheduler {
1066 handle.abort();
1067 }
1068 gateway_prober.abort();
1069 result
1070}
1071
1072async fn serve_with_drain_deadline<Srv, Sig>(
1077 server: Srv,
1078 signalled: Sig,
1079 deadline: Duration,
1080) -> Result<(), ServeError>
1081where
1082 Srv: Future<Output = Result<(), ServeError>>,
1083 Sig: Future<Output = ()>,
1084{
1085 tokio::pin!(server);
1086 let drain_cap = async move {
1087 signalled.await;
1088 tokio::time::sleep(deadline).await;
1089 };
1090 tokio::select! {
1091 result = &mut server => result,
1092 _ = drain_cap => {
1093 tracing::warn!(
1094 deadline_s = deadline.as_secs(),
1095 "drain deadline exceeded; forcing shutdown with requests still in flight"
1096 );
1097 Ok(())
1098 }
1099 }
1100}
1101
1102pub async fn shutdown_signal() {
1105 let ctrl_c = async {
1106 let _ = tokio::signal::ctrl_c().await;
1107 };
1108 #[cfg(unix)]
1109 let terminate = async {
1110 if let Ok(mut sig) =
1111 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1112 {
1113 sig.recv().await;
1114 }
1115 };
1116 #[cfg(not(unix))]
1117 let terminate = std::future::pending::<()>();
1118
1119 tokio::select! {
1120 _ = ctrl_c => {}
1121 _ = terminate => {}
1122 }
1123 tracing::info!("shutdown signal received; draining");
1124}
1125
1126async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1130 match daemon.generation() {
1131 Some(gen) => format!("ok gen={gen}"),
1132 None => "ok".to_string(),
1133 }
1134}
1135
1136async fn readyz(State(deploy): State<DeployStore>) -> Response {
1138 match deploy.ready().await {
1139 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1140 Err(err) => {
1141 tracing::warn!(error = %err, "readiness probe failed");
1142 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1143 }
1144 }
1145}
1146
1147#[derive(Clone)]
1152pub struct RequestId(pub String);
1153
1154fn request_id_for(headers: &HeaderMap) -> String {
1157 if let Some(id) = headers
1158 .get("x-request-id")
1159 .and_then(|v| v.to_str().ok())
1160 .map(str::trim)
1161 .filter(|s| !s.is_empty())
1162 {
1163 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1164 }
1165 use std::sync::atomic::{AtomicU64, Ordering};
1166 static SEQ: AtomicU64 = AtomicU64::new(0);
1167 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1168 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1169}
1170
1171struct AccessLog {
1175 request_id: String,
1176 method: Method,
1177 path: String,
1178 host: String,
1179 client: String,
1180 status: u16,
1181 encoding: String,
1183 start: std::time::Instant,
1184 bytes: std::sync::atomic::AtomicU64,
1185}
1186
1187impl Drop for AccessLog {
1188 fn drop(&mut self) {
1189 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1190 srvmetrics::server_metrics().record_request(self.status, bytes);
1193 tracing::info!(
1194 target: "boatramp::access",
1195 request_id = %self.request_id,
1196 method = %self.method,
1197 path = %self.path,
1198 host = %self.host,
1199 client = %self.client,
1200 status = self.status,
1201 bytes = bytes,
1202 encoding = %self.encoding,
1203 cache_result = srvmetrics::cache_result(self.status),
1204 elapsed_ms = self.start.elapsed().as_millis() as u64,
1205 "request"
1206 );
1207 }
1208}
1209
1210async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1214 let method = request.method().clone();
1215 let path = request.uri().path().to_string();
1216 let request_id = request_id_for(request.headers());
1219 request
1220 .extensions_mut()
1221 .insert(RequestId(request_id.clone()));
1222 let host = request
1223 .headers()
1224 .get(header::HOST)
1225 .and_then(|value| value.to_str().ok())
1226 .unwrap_or("-")
1227 .to_string();
1228 let client = request
1229 .extensions()
1230 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1231 .map(|info| info.0.ip().to_string())
1232 .unwrap_or_else(|| "-".to_string());
1233
1234 let start = std::time::Instant::now();
1235 let response = next.run(request).await;
1236 let encoding = response
1237 .headers()
1238 .get(header::CONTENT_ENCODING)
1239 .and_then(|v| v.to_str().ok())
1240 .unwrap_or("identity")
1241 .to_string();
1242 let log = AccessLog {
1243 request_id,
1244 method,
1245 path,
1246 host,
1247 client,
1248 status: response.status().as_u16(),
1249 encoding,
1250 start,
1251 bytes: std::sync::atomic::AtomicU64::new(0),
1252 };
1253
1254 let (parts, body) = response.into_parts();
1258 let counted = body.into_data_stream().map(move |chunk| {
1259 if let Ok(bytes) = &chunk {
1260 log.bytes
1261 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1262 }
1263 chunk
1264 });
1265 Response::from_parts(parts, Body::from_stream(counted))
1266}
1267
1268fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1270 req_headers
1271 .get(header::IF_NONE_MATCH)
1272 .and_then(|value| value.to_str().ok())
1273 .is_some_and(|value| {
1274 value
1275 .split(',')
1276 .map(str::trim)
1277 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1278 })
1279}
1280
1281fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1282 if let Ok(value) = HeaderValue::from_str(value) {
1283 headers.insert(name, value);
1284 }
1285}
1286
1287fn not_found() -> Response {
1288 (StatusCode::NOT_FOUND, "not found\n").into_response()
1289}
1290
1291fn redirect(status: u16, location: &str) -> Response {
1292 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1293 match HeaderValue::from_str(location) {
1294 Ok(location) => {
1295 let mut headers = HeaderMap::new();
1296 headers.insert(header::LOCATION, location);
1297 (status, headers).into_response()
1298 }
1299 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1300 }
1301}
1302
1303fn deploy_error_response(err: DeployError) -> Response {
1305 let status = match &err {
1306 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1307 StatusCode::NOT_FOUND
1308 }
1309 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1310 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1311 DeployError::Conflict(_) => StatusCode::CONFLICT,
1313 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1315 _ => StatusCode::INTERNAL_SERVER_ERROR,
1316 };
1317 tracing::warn!(error = %err, "request failed");
1318 (status, format!("{err}\n")).into_response()
1319}
1320
1321fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1326 boatramp_core::project::validate_resource_name(kind, value)
1327 .err()
1328 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1329}
1330
1331#[cfg(test)]
1332mod drain_tests {
1333 use super::*;
1334
1335 #[tokio::test]
1336 async fn deadline_forces_shutdown_after_signal() {
1337 let server = std::future::pending::<Result<(), ServeError>>();
1340 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1342 assert!(result.is_ok());
1343 }
1344
1345 #[tokio::test]
1346 async fn server_finishing_first_wins() {
1347 let server = async { Ok(()) };
1350 let signalled = std::future::pending::<()>();
1351 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1352 assert!(result.is_ok());
1353 }
1354
1355 #[tokio::test]
1356 async fn deadline_does_not_trip_before_signal() {
1357 let server = async {
1361 tokio::time::sleep(Duration::from_millis(40)).await;
1362 Err(ServeError::Io(std::io::Error::other("server error")))
1363 };
1364 let signalled = std::future::pending::<()>();
1365 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1366 assert!(result.is_err());
1367 }
1368}
1369
1370#[cfg(all(test, feature = "handlers"))]
1371mod tests {
1372 use super::*;
1373 use boatramp_core::cose::{LocalSigner, TokenAlg};
1374 use boatramp_core::project::ProjectRef;
1375
1376 #[test]
1377 fn query_string_parses_and_url_decodes() {
1378 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1379 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1380 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")); }
1384
1385 #[test]
1386 fn cookie_header_parses_pairs() {
1387 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1388 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1389 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1390 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1391 }
1392
1393 #[test]
1394 fn apply_vary_merges_without_duplicates() {
1395 let base = (StatusCode::OK, "x").into_response();
1396 let r = apply_vary(base, &["accept-language".into()]);
1397 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1398 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1400 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1401 assert!(v.contains("accept-language") && v.contains("cookie"));
1402 assert_eq!(v.matches("accept-language").count(), 1);
1403 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1405 assert!(plain.headers().get(header::VARY).is_none());
1406 }
1407
1408 #[tokio::test]
1412 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1413 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1414 let public = keys.public_key();
1415
1416 let resp = create_join_token(
1418 Extension(Issuer(Some(keys.clone()))),
1419 Json(CreateJoinTokenRequest {
1420 ttl_secs: Some(600),
1421 }),
1422 )
1423 .await;
1424 assert_eq!(resp.status(), StatusCode::CREATED);
1425 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1426 .await
1427 .unwrap();
1428 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1429 let token = parsed["token"].as_str().unwrap();
1430 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1431 assert!(!jti.is_empty());
1432
1433 let no_issuer = create_join_token(
1435 Extension(Issuer(None)),
1436 Json(CreateJoinTokenRequest { ttl_secs: None }),
1437 )
1438 .await;
1439 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1440 }
1441
1442 #[tokio::test]
1447 async fn function_write_path_deploy_rollback_alias_remove() {
1448 use boatramp_core::function::Lifecycle;
1449 use boatramp_core::kv::MemoryKv;
1450 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1451
1452 struct FakeStorage {
1455 present: bool,
1456 }
1457 #[async_trait::async_trait]
1458 impl Storage for FakeStorage {
1459 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1460 Err(StorageError::NotFound(String::new()))
1461 }
1462 async fn get_range(
1463 &self,
1464 _: &str,
1465 _: u64,
1466 _: Option<u64>,
1467 ) -> Result<GetObject, StorageError> {
1468 Err(StorageError::NotFound(String::new()))
1469 }
1470 async fn put(
1471 &self,
1472 _: &str,
1473 _: ByteStream,
1474 _: PutMeta,
1475 ) -> Result<ObjectMeta, StorageError> {
1476 Err(StorageError::unsupported("fake"))
1477 }
1478 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1479 if self.present {
1480 Ok(ObjectMeta {
1481 key: key.to_string(),
1482 ..Default::default()
1483 })
1484 } else {
1485 Err(StorageError::NotFound(key.to_string()))
1486 }
1487 }
1488 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1489 Ok(())
1490 }
1491 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1492 Ok(Vec::new())
1493 }
1494 }
1495
1496 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1497 let status = resp.status();
1498 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1499 .await
1500 .unwrap();
1501 let value = if bytes.is_empty() {
1502 serde_json::Value::Null
1503 } else {
1504 serde_json::from_slice(&bytes).unwrap()
1505 };
1506 (status, value)
1507 }
1508
1509 let deploy = DeployStore::new(
1510 Arc::new(FakeStorage { present: true }),
1511 Arc::new(MemoryKv::new()),
1512 );
1513 let v1 = "a".repeat(64);
1514 let v2 = "b".repeat(64);
1515
1516 let (st, body) = body_json(
1518 deploy_function(
1519 State(deploy.clone()),
1520 axum::extract::Extension(crate::ProjectContext::default()),
1521 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1522 axum::extract::Query(DeployFunctionQuery::default()),
1523 Path("greeter".to_string()),
1524 Json(FunctionUpsert {
1525 component: v1.clone(),
1526 config: Default::default(),
1527 lifecycle: Lifecycle::Independent,
1528 }),
1529 )
1530 .await,
1531 )
1532 .await;
1533 assert_eq!(st, StatusCode::OK);
1534 assert_eq!(body["active"], v1);
1535
1536 let (_, body) = body_json(
1538 deploy_function(
1539 State(deploy.clone()),
1540 axum::extract::Extension(crate::ProjectContext::default()),
1541 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1542 axum::extract::Query(DeployFunctionQuery::default()),
1543 Path("greeter".to_string()),
1544 Json(FunctionUpsert {
1545 component: v2.clone(),
1546 config: Default::default(),
1547 lifecycle: Lifecycle::Independent,
1548 }),
1549 )
1550 .await,
1551 )
1552 .await;
1553 assert_eq!(body["active"], v2);
1554 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1555
1556 let (st, body) = body_json(
1558 rollback_function(
1559 State(deploy.clone()),
1560 axum::extract::Extension(crate::ProjectContext::default()),
1561 Path("greeter".to_string()),
1562 Json(RollbackBody { to: v1.clone() }),
1563 )
1564 .await,
1565 )
1566 .await;
1567 assert_eq!(st, StatusCode::OK);
1568 assert_eq!(body["active"], v1);
1569
1570 let resp = rollback_function(
1572 State(deploy.clone()),
1573 axum::extract::Extension(crate::ProjectContext::default()),
1574 Path("greeter".to_string()),
1575 Json(RollbackBody { to: "c".repeat(64) }),
1576 )
1577 .await;
1578 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1579
1580 let (st, body) = body_json(
1582 alias_function(
1583 State(deploy.clone()),
1584 axum::extract::Extension(crate::ProjectContext::default()),
1585 Path(("greeter".to_string(), "prod".to_string())),
1586 Json(AliasBody {
1587 version: v2.clone(),
1588 }),
1589 )
1590 .await,
1591 )
1592 .await;
1593 assert_eq!(st, StatusCode::OK);
1594 assert_eq!(body["aliases"]["prod"], v2);
1595
1596 let (st, _) = body_json(
1598 remove_function(
1599 State(deploy.clone()),
1600 axum::extract::Extension(crate::ProjectContext::default()),
1601 Path("greeter".to_string()),
1602 )
1603 .await,
1604 )
1605 .await;
1606 assert_eq!(st, StatusCode::NO_CONTENT);
1607 assert!(deploy
1608 .get_function(ProjectRef::DEFAULT, "greeter")
1609 .await
1610 .unwrap()
1611 .is_none());
1612
1613 let empty = DeployStore::new(
1615 Arc::new(FakeStorage { present: false }),
1616 Arc::new(MemoryKv::new()),
1617 );
1618 let resp = deploy_function(
1619 State(empty),
1620 axum::extract::Extension(crate::ProjectContext::default()),
1621 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1622 axum::extract::Query(DeployFunctionQuery::default()),
1623 Path("orphan".to_string()),
1624 Json(FunctionUpsert {
1625 component: v1.clone(),
1626 config: Default::default(),
1627 lifecycle: Lifecycle::default(),
1628 }),
1629 )
1630 .await;
1631 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1632 }
1633
1634 struct StubControl {
1638 admits: std::sync::Mutex<Vec<(String, String)>>,
1639 respond: StubJoin,
1640 }
1641 #[derive(Clone, Copy)]
1642 enum StubJoin {
1643 Admit,
1644 Spent,
1645 Invalid,
1646 Revoked,
1647 }
1648
1649 #[async_trait::async_trait]
1650 impl MeshControl for StubControl {
1651 async fn admit(
1652 &self,
1653 mesh_pubkey_hex: &str,
1654 jti: &str,
1655 _proof: &[u8],
1656 _proof_iat: u64,
1657 _now: u64,
1658 _advertise_addr: Option<&str>,
1659 ) -> Result<JoinOutcome, String> {
1660 self.admits
1661 .lock()
1662 .unwrap()
1663 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1664 Ok(match self.respond {
1665 StubJoin::Admit => JoinOutcome::Admitted {
1666 members: vec!["signed-member".to_string()],
1667 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1668 },
1669 StubJoin::Spent => JoinOutcome::TokenSpent,
1670 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1671 StubJoin::Revoked => JoinOutcome::Revoked,
1672 })
1673 }
1674 async fn rotate_key(&self) -> Result<String, String> {
1675 Ok("cafe".to_string())
1676 }
1677 async fn revoke(&self, _node: u64) -> Result<(), String> {
1678 Ok(())
1679 }
1680 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1681 Ok(Vec::new())
1682 }
1683 async fn promote(&self, _node: u64) -> Result<(), String> {
1684 Ok(())
1685 }
1686 }
1687
1688 #[tokio::test]
1692 async fn cluster_join_dispatches_and_maps_outcomes() {
1693 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1694 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1695 let auth = Auth::with_key(keys.public_key(), kv);
1696 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1697 let req = |proof: &str| JoinRequest {
1698 token: token.clone(),
1699 mesh_pubkey: "302a300506032b6570032100feed".into(),
1700 possession_proof: proof.to_string(),
1701 proof_iat: now_unix(),
1702 advertise_addr: Some("https://joiner:7000".into()),
1703 };
1704
1705 let admitter = Arc::new(StubControl {
1707 admits: std::sync::Mutex::new(Vec::new()),
1708 respond: StubJoin::Admit,
1709 });
1710 let resp = cluster_join(
1711 Extension(auth.clone()),
1712 Extension(MeshControlHandle(Some(admitter.clone()))),
1713 Json(req("aa01")),
1714 )
1715 .await;
1716 assert_eq!(resp.status(), StatusCode::OK);
1717 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1718
1719 let spent = Arc::new(StubControl {
1721 admits: std::sync::Mutex::new(Vec::new()),
1722 respond: StubJoin::Spent,
1723 });
1724 assert_eq!(
1725 cluster_join(
1726 Extension(auth.clone()),
1727 Extension(MeshControlHandle(Some(spent))),
1728 Json(req("aa01")),
1729 )
1730 .await
1731 .status(),
1732 StatusCode::CONFLICT
1733 );
1734 let invalid = Arc::new(StubControl {
1735 admits: std::sync::Mutex::new(Vec::new()),
1736 respond: StubJoin::Invalid,
1737 });
1738 assert_eq!(
1739 cluster_join(
1740 Extension(auth.clone()),
1741 Extension(MeshControlHandle(Some(invalid))),
1742 Json(req("aa01")),
1743 )
1744 .await
1745 .status(),
1746 StatusCode::FORBIDDEN
1747 );
1748 let revoked = Arc::new(StubControl {
1750 admits: std::sync::Mutex::new(Vec::new()),
1751 respond: StubJoin::Revoked,
1752 });
1753 assert_eq!(
1754 cluster_join(
1755 Extension(auth.clone()),
1756 Extension(MeshControlHandle(Some(revoked))),
1757 Json(req("aa01")),
1758 )
1759 .await
1760 .status(),
1761 StatusCode::FORBIDDEN
1762 );
1763
1764 let ok = Arc::new(StubControl {
1766 admits: std::sync::Mutex::new(Vec::new()),
1767 respond: StubJoin::Admit,
1768 });
1769 assert_eq!(
1770 cluster_join(
1771 Extension(auth.clone()),
1772 Extension(MeshControlHandle(Some(ok))),
1773 Json(req("not-hex")),
1774 )
1775 .await
1776 .status(),
1777 StatusCode::BAD_REQUEST
1778 );
1779
1780 let none = cluster_join(
1782 Extension(auth),
1783 Extension(MeshControlHandle(None)),
1784 Json(req("aa01")),
1785 )
1786 .await;
1787 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1788 }
1789
1790 #[tokio::test]
1794 async fn bootstrap_mints_the_first_token_once() {
1795 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1796 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1797 let public = keys.public_key();
1798 let deploy = DeployStore::new(
1799 Arc::new(MemStorage::default()),
1800 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1801 );
1802 let secret = "s3cr3t-bootstrap-value";
1803 let gate = BootstrapGate::new(Some(secret));
1804 let issuer = Issuer(Some(keys.clone()));
1805 let bearer = |s: &str| {
1806 let mut h = HeaderMap::new();
1807 h.insert(
1808 AUTHORIZATION,
1809 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1810 );
1811 h
1812 };
1813 let req = || BootstrapRequest {
1814 roles: vec!["admin".to_string()],
1815 ttl_secs: None,
1816 };
1817
1818 let bad = bootstrap_token(
1820 State(deploy.clone()),
1821 Extension(issuer.clone()),
1822 Extension(gate.clone()),
1823 bearer("wrong"),
1824 Json(req()),
1825 )
1826 .await;
1827 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1828
1829 let ok = bootstrap_token(
1831 State(deploy.clone()),
1832 Extension(issuer.clone()),
1833 Extension(gate.clone()),
1834 bearer(secret),
1835 Json(req()),
1836 )
1837 .await;
1838 assert_eq!(ok.status(), StatusCode::CREATED);
1839 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1840 .await
1841 .unwrap();
1842 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1843 let token = json["token"].as_str().unwrap();
1844 let id = json["id"].as_str().unwrap();
1845 let verified = cose::verify(token, &public, now_unix()).unwrap();
1846 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1847 assert!(deploy
1848 .list_token_meta()
1849 .await
1850 .unwrap()
1851 .iter()
1852 .any(|m| m.revocation_id == id));
1853
1854 let reuse = bootstrap_token(
1856 State(deploy.clone()),
1857 Extension(issuer.clone()),
1858 Extension(gate),
1859 bearer(secret),
1860 Json(req()),
1861 )
1862 .await;
1863 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1864
1865 let disabled = bootstrap_token(
1867 State(deploy),
1868 Extension(issuer),
1869 Extension(BootstrapGate(None)),
1870 bearer(secret),
1871 Json(req()),
1872 )
1873 .await;
1874 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1875 }
1876
1877 #[tokio::test]
1880 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1881 let control = Arc::new(StubControl {
1882 admits: std::sync::Mutex::new(Vec::new()),
1883 respond: StubJoin::Admit,
1884 });
1885 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1886 assert_eq!(resp.status(), StatusCode::OK);
1887 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1888 .await
1889 .unwrap();
1890 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1891 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1892
1893 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1894 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1895 }
1896
1897 #[test]
1898 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1899 use boatramp_core::security::SecurityProfile;
1900 let strict = SecurityProfile::MultiTenant.preset();
1901 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
1905 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1906 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1907
1908 assert!(gateway_addr_allowed(public, &strict));
1910 assert!(!gateway_addr_allowed(private, &strict));
1911 assert!(!gateway_addr_allowed(loopback, &strict));
1912 assert!(!gateway_addr_allowed(metadata, &strict));
1913
1914 assert!(gateway_addr_allowed(public, &loose));
1917 assert!(gateway_addr_allowed(private, &loose));
1918 assert!(gateway_addr_allowed(loopback, &loose));
1919 assert!(!gateway_addr_allowed(metadata, &loose));
1920 }
1921
1922 #[test]
1923 fn resolve_env_merges_static_and_host_secrets() {
1924 use boatramp_core::config::HandlersSiteConfig;
1925
1926 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
1928
1929 let deploy_env = std::collections::BTreeMap::from([
1930 ("GREETING".to_string(), "hi".to_string()),
1931 ("OVERRIDE_ME".to_string(), "static".to_string()),
1932 ]);
1933 let site_handlers = HandlersSiteConfig {
1934 enabled: true,
1935 secrets: std::collections::BTreeMap::from([
1936 (
1938 "SECRET_TOKEN".to_string(),
1939 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1940 ),
1941 (
1942 "OVERRIDE_ME".to_string(),
1943 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1944 ),
1945 (
1946 "MISSING".to_string(),
1947 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
1948 ),
1949 ]),
1950 ..Default::default()
1951 };
1952 let env = resolve_env("blog", &deploy_env, &site_handlers);
1953
1954 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
1958 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
1959 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
1960 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
1961
1962 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
1963 }
1964
1965 fn req() -> Request {
1966 Request::builder()
1967 .uri("/")
1968 .header(header::HOST, "example.com")
1969 .body(Body::empty())
1970 .unwrap()
1971 }
1972
1973 #[test]
1974 fn forwarded_headers_set_standard_triple() {
1975 let mut request = req();
1976 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1977 let h = request.headers();
1978 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
1979 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
1980 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
1981 }
1982
1983 #[test]
1984 fn forwarded_for_overwrites_spoofed_value() {
1985 let mut request = Request::builder()
1988 .uri("/")
1989 .header(header::HOST, "example.com")
1990 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
1991 .body(Body::empty())
1992 .unwrap();
1993 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1994 let values: Vec<_> = request
1995 .headers()
1996 .get_all("x-forwarded-for")
1997 .iter()
1998 .collect();
1999 assert_eq!(values.len(), 1);
2000 assert_eq!(values[0], "203.0.113.7");
2001 }
2002
2003 #[test]
2004 fn forwarded_proto_preserves_upstream_tls() {
2005 let mut request = Request::builder()
2007 .uri("/")
2008 .header(header::HOST, "example.com")
2009 .header("x-forwarded-proto", "https")
2010 .body(Body::empty())
2011 .unwrap();
2012 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2013 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2014 }
2015
2016 #[test]
2017 fn forwarded_host_absent_when_no_host_header() {
2018 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2019 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2020 assert!(request.headers().get("x-forwarded-host").is_none());
2021 assert_eq!(
2022 request.headers().get("x-forwarded-for").unwrap(),
2023 "203.0.113.7"
2024 );
2025 }
2026
2027 use boatramp_core::kv::{KvStore, MemoryKv};
2030 use boatramp_core::messaging::{LogMessaging, Messaging};
2031 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2032
2033 const EVENT_CONSUMER: &[u8] =
2034 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2035
2036 #[derive(Default)]
2037 struct MemStorage {
2038 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2039 }
2040
2041 #[async_trait::async_trait]
2042 impl boatramp_core::Storage for MemStorage {
2043 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2044 let bytes = self
2045 .objects
2046 .lock()
2047 .unwrap()
2048 .get(key)
2049 .cloned()
2050 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2051 let body: ByteStream =
2052 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2053 Ok(GetObject {
2054 meta: ObjectMeta {
2055 key: key.to_string(),
2056 ..Default::default()
2057 },
2058 body,
2059 })
2060 }
2061 async fn get_range(
2062 &self,
2063 key: &str,
2064 _: u64,
2065 _: Option<u64>,
2066 ) -> Result<GetObject, StorageError> {
2067 self.get(key).await
2068 }
2069 async fn put(
2070 &self,
2071 key: &str,
2072 mut body: ByteStream,
2073 _: PutMeta,
2074 ) -> Result<ObjectMeta, StorageError> {
2075 use futures::StreamExt;
2076 let mut buf = Vec::new();
2077 while let Some(chunk) = body.next().await {
2078 buf.extend_from_slice(&chunk?);
2079 }
2080 self.objects.lock().unwrap().insert(key.to_string(), buf);
2081 Ok(ObjectMeta {
2082 key: key.to_string(),
2083 ..Default::default()
2084 })
2085 }
2086 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2087 self.objects
2088 .lock()
2089 .unwrap()
2090 .get(key)
2091 .map(|_| ObjectMeta {
2092 key: key.to_string(),
2093 ..Default::default()
2094 })
2095 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2096 }
2097 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2098 self.objects.lock().unwrap().remove(key);
2099 Ok(())
2100 }
2101 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2102 Ok(Vec::new())
2103 }
2104 }
2105
2106 fn observed_state(
2108 workload: &str,
2109 healthy: bool,
2110 phase: boatramp_core::compute::ReplicaPhase,
2111 ) -> boatramp_core::compute::ObservedInstance {
2112 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2113 boatramp_core::compute::ObservedInstance {
2114 handle: InstanceHandle {
2115 workload: workload.into(),
2116 replica: 0,
2117 backend_ref: "ref-0".into(),
2118 },
2119 node: 1,
2120 backend: "vmm".into(),
2121 endpoint: Endpoint {
2122 scheme: Scheme::Http,
2123 host: "10.0.0.2".into(),
2124 port: 80,
2125 },
2126 region: None,
2127 healthy,
2128 phase,
2129 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2130 workload: workload.into(),
2131 replica: 0,
2132 data_ref: "snap-0".into(),
2133 }),
2134 }
2135 }
2136
2137 #[tokio::test]
2138 async fn has_parked_replica_detects_a_zeroed_replica() {
2139 use boatramp_core::compute::ReplicaPhase;
2140 let storage = Arc::new(MemStorage::default());
2141 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2142 let deploy = DeployStore::new(storage, kv);
2143
2144 assert!(!has_parked_replica(&deploy, "w").await);
2146 deploy
2148 .set_replica_state(
2149 ProjectRef::DEFAULT,
2150 &observed_state("w", true, ReplicaPhase::Running),
2151 )
2152 .await
2153 .unwrap();
2154 assert!(!has_parked_replica(&deploy, "w").await);
2155 deploy
2157 .set_replica_state(
2158 ProjectRef::DEFAULT,
2159 &observed_state("w", false, ReplicaPhase::Zero),
2160 )
2161 .await
2162 .unwrap();
2163 assert!(has_parked_replica(&deploy, "w").await);
2164 }
2165
2166 #[tokio::test]
2167 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2168 use boatramp_core::compute::ReplicaPhase;
2169 let storage = Arc::new(MemStorage::default());
2170 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2171 let deploy = DeployStore::new(storage, kv);
2172
2173 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2175 assert!(empty.is_empty());
2176
2177 deploy
2179 .set_replica_state(
2180 ProjectRef::DEFAULT,
2181 &observed_state("w", true, ReplicaPhase::Running),
2182 )
2183 .await
2184 .unwrap();
2185 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2186 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2187 }
2188
2189 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2193 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2194 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2195 let storage = Arc::new(MemStorage::default());
2196 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2197 let mq = LogMessaging::new(storage, kv.clone());
2198 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2199 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2200 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2201 let topic = "blog/orders/created";
2202
2203 for _ in 0..3 {
2205 mq.publish(topic, b"ok").await.unwrap();
2206 }
2207 loop {
2208 let acked = dispatch_consumer_batch(
2209 &engine,
2210 &mq,
2211 &metrics::Metrics::default(),
2212 "blog",
2213 topic,
2214 "blog/",
2215 &hash,
2216 EVENT_CONSUMER,
2217 &bindings,
2218 Limits::default(),
2219 Duration::from_secs(30),
2220 5,
2221 10,
2222 )
2223 .await;
2224 if acked == 0 {
2225 break;
2226 }
2227 }
2228 assert_eq!(
2229 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2230 Some(b"3".to_vec())
2231 );
2232
2233 mq.publish(topic, b"fail").await.unwrap();
2236 for _ in 0..5 {
2237 dispatch_consumer_batch(
2238 &engine,
2239 &mq,
2240 &metrics::Metrics::default(),
2241 "blog",
2242 topic,
2243 "blog/",
2244 &hash,
2245 EVENT_CONSUMER,
2246 &bindings,
2247 Limits::default(),
2248 Duration::ZERO,
2249 2,
2250 10,
2251 )
2252 .await;
2253 }
2254 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2255 assert_eq!(
2257 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2258 Some(b"3".to_vec())
2259 );
2260 }
2261
2262 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2266 async fn scheduler_runs_current_consumers_not_previews() {
2267 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2268 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2269 use boatramp_handlers::{HandlerEngine, Limits};
2270 use futures::StreamExt;
2271
2272 let storage = Arc::new(MemStorage::default());
2273 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2274 let deploy = DeployStore::new(storage.clone(), kv.clone());
2275 let messaging: Arc<dyn Messaging> =
2276 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2277
2278 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2280 let stream: ByteStream =
2281 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2282 .boxed();
2283 deploy.put_blob(&hash, stream).await.unwrap();
2284 let mut files = std::collections::BTreeMap::new();
2285 files.insert(
2286 "consumer.wasm".to_string(),
2287 FileEntry {
2288 hash: hash.clone(),
2289 size: EVENT_CONSUMER.len() as u64,
2290 content_type: None,
2291 variants: std::collections::BTreeMap::new(),
2292 },
2293 );
2294 let manifest = Manifest {
2295 files,
2296 config: DeployConfig {
2297 consumers: vec![ConsumerConfig {
2298 topic: "orders/created".into(),
2299 component: "consumer.wasm".into(),
2300 imports: vec!["wasi:keyvalue".into()],
2301 }],
2302 ..Default::default()
2303 },
2304 ..Default::default()
2305 };
2306 let id = deploy.put_manifest(&manifest).await.unwrap();
2307 deploy
2308 .activate(ProjectRef::DEFAULT, "blog", &id)
2309 .await
2310 .unwrap();
2311 deploy
2312 .set_site_config(
2313 ProjectRef::DEFAULT,
2314 "blog",
2315 &SiteConfig {
2316 handlers: Some(HandlersSiteConfig {
2317 enabled: true,
2318 allow_imports: vec!["wasi:keyvalue".into()],
2319 ..Default::default()
2320 }),
2321 ..Default::default()
2322 },
2323 )
2324 .await
2325 .unwrap();
2326
2327 messaging
2329 .publish("blog/orders/created", b"live")
2330 .await
2331 .unwrap();
2332 messaging
2333 .publish("blog/_preview/abc/orders/created", b"preview")
2334 .await
2335 .unwrap();
2336
2337 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2338 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2339 let inner = rt.inner.clone().unwrap();
2340 let mut cache = std::collections::HashMap::new();
2341 let mut crons = std::collections::HashMap::new();
2342 let now = CronNow {
2343 minute: 0,
2344 hour: 0,
2345 dom: 1,
2346 month: 1,
2347 dow: 0,
2348 minute_stamp: 0,
2349 };
2350 for _ in 0..3 {
2351 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, now)
2352 .await
2353 .unwrap();
2354 }
2355
2356 assert_eq!(
2358 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2359 Some(b"1".to_vec())
2360 );
2361 assert_eq!(
2364 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2365 .await
2366 .unwrap(),
2367 None
2368 );
2369 }
2370
2371 const KV_COUNTER: &[u8] =
2376 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2377
2378 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2383 async fn function_invoker_runs_target_buffers_and_meters() {
2384 use boatramp_core::deploy::DeployStore;
2385 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2386 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2387 use futures::StreamExt;
2388
2389 const HTTP_200: &[u8] =
2392 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2393
2394 let storage = Arc::new(MemStorage::default());
2395 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2396 let deploy = DeployStore::new(storage.clone(), kv.clone());
2397
2398 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2399 let stream: ByteStream =
2400 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2401 deploy.put_blob(&hash, stream).await.unwrap();
2402 let function = Function {
2403 name: "target".into(),
2404 owner: Owner::Project("default".into()),
2405 versions: vec![FunctionVersion {
2406 id: "v1".into(),
2407 component: hash.clone(),
2408 created: 0,
2409 lifecycle: Lifecycle::Independent,
2410 }],
2411 active: "v1".into(),
2412 aliases: Default::default(),
2413 config: Default::default(),
2414 };
2415 deploy
2416 .put_function(ProjectRef::DEFAULT, &function)
2417 .await
2418 .unwrap();
2419
2420 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2421 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2422 rt.set_invoker(deploy.clone());
2423 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2424
2425 let request = || InvokeRequest {
2426 method: "GET".into(),
2427 path: "/".into(),
2428 headers: vec![],
2429 body: vec![],
2430 };
2431
2432 let response = invoker.invoke("target", request(), 1).await.unwrap();
2434 assert_eq!(response.status, 200);
2435
2436 let metering = deploy
2438 .get_metering(ProjectRef::DEFAULT, "target")
2439 .await
2440 .unwrap()
2441 .unwrap();
2442 assert_eq!(metering.invocations, 1);
2443
2444 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2446 assert!(matches!(err, InvokeError::NotFound));
2447 }
2448
2449 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2455 async fn federation_runner_enforces_the_safelist_before_planning() {
2456 use boatramp_core::deploy::DeployStore;
2457 use boatramp_core::project::ProjectRef;
2458 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2459
2460 let storage = Arc::new(MemStorage::default());
2461 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2462 let deploy = DeployStore::new(storage.clone(), kv.clone());
2463 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2464 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2465 rt.set_invoker(deploy.clone());
2466 let runner = rt
2467 .inner
2468 .as_ref()
2469 .unwrap()
2470 .federation_runner
2471 .get()
2472 .unwrap()
2473 .scoped(ProjectRef::new("default"));
2474
2475 let req = |query: &str| GraphqlRequest {
2476 query: Some(query.to_string()),
2477 persisted_hash: None,
2478 variables: "{}".to_string(),
2479 operation_name: None,
2480 authorization: None,
2481 };
2482
2483 assert!(matches!(
2485 runner.run(req("{ me { id } }"), 1).await,
2486 Err(SupergraphRunError::NotSafelisted)
2487 ));
2488
2489 let query = "{ me { id } }";
2492 let hash = crate::graphql_apq::sha256_hex(query);
2493 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2494 .await
2495 .unwrap();
2496 assert!(matches!(
2497 runner.run(req(query), 1).await,
2498 Err(SupergraphRunError::PlanFailed(_))
2499 ));
2500
2501 let persisted = GraphqlRequest {
2503 query: None,
2504 persisted_hash: Some("deadbeef".to_string()),
2505 variables: "{}".to_string(),
2506 operation_name: None,
2507 authorization: None,
2508 };
2509 assert!(matches!(
2510 runner.run(persisted, 1).await,
2511 Err(SupergraphRunError::NotSafelisted)
2512 ));
2513 }
2514
2515 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2521 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2522 use crate::scheduler::{run_scheduler_tick, CronNow};
2523 use boatramp_core::deploy::DeployStore;
2524 use boatramp_core::function::{
2525 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2526 };
2527 use boatramp_handlers::{HandlerEngine, Limits};
2528 use futures::StreamExt;
2529
2530 const HTTP_200: &[u8] =
2531 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2532
2533 let storage = Arc::new(MemStorage::default());
2534 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2535 let deploy = DeployStore::new(storage.clone(), kv.clone());
2536
2537 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2538 let stream: ByteStream =
2539 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2540 deploy.put_blob(&hash, stream).await.unwrap();
2541
2542 let acme = ProjectRef::new("acme");
2544 let function = Function {
2545 name: "worker".into(),
2546 owner: Owner::Project("acme".into()),
2547 versions: vec![FunctionVersion {
2548 id: "v1".into(),
2549 component: hash.clone(),
2550 created: 0,
2551 lifecycle: Lifecycle::Independent,
2552 }],
2553 active: "v1".into(),
2554 aliases: Default::default(),
2555 config: Default::default(),
2556 };
2557 deploy.put_function(acme, &function).await.unwrap();
2558 let inv = Invocation {
2559 id: "inv1".into(),
2560 function: "worker".into(),
2561 version: "v1".into(),
2562 mode: InvokeMode::Async,
2563 status: InvocationStatus::Queued,
2564 idempotency_key: None,
2565 attempts: 0,
2566 lease_expires: None,
2567 request_b64: None,
2568 request_content_type: None,
2569 result: None,
2570 created: 0,
2571 updated: 0,
2572 };
2573 deploy.put_invocation(acme, &inv).await.unwrap();
2574
2575 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2576 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2577 let inner = rt.inner.as_ref().unwrap();
2578
2579 let mut wasm_cache = std::collections::HashMap::new();
2582 let mut cron_state = std::collections::HashMap::new();
2583 let now = CronNow {
2584 minute: 0,
2585 hour: 0,
2586 dom: 1,
2587 month: 1,
2588 dow: 0,
2589 minute_stamp: 0,
2590 };
2591 run_scheduler_tick(inner, &deploy, &mut wasm_cache, &mut cron_state, now)
2592 .await
2593 .unwrap();
2594
2595 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
2598 assert_eq!(settled.status, InvocationStatus::Succeeded);
2600 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2602 assert_eq!(metering.invocations, 1);
2603 assert!(deploy
2605 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2606 .await
2607 .unwrap()
2608 .is_none());
2609 assert!(deploy
2610 .get_metering(ProjectRef::DEFAULT, "worker")
2611 .await
2612 .unwrap()
2613 .is_none());
2614 }
2615
2616 #[cfg(feature = "handlers")]
2620 async fn poll_invocation_settled(
2621 deploy: &boatramp_core::deploy::DeployStore,
2622 project: ProjectRef<'_>,
2623 function: &str,
2624 id: &str,
2625 ) -> boatramp_core::function::Invocation {
2626 use boatramp_core::function::InvocationStatus;
2627 for _ in 0..200 {
2628 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
2629 if matches!(
2630 inv.status,
2631 InvocationStatus::Succeeded | InvocationStatus::Failed
2632 ) {
2633 return inv;
2634 }
2635 }
2636 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2637 }
2638 panic!("invocation {function}/{id} never settled");
2639 }
2640
2641 #[cfg(feature = "handlers")]
2646 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2647 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
2648 use crate::scheduler::{run_scheduler_tick, CronNow};
2649 use boatramp_core::deploy::DeployStore;
2650 use boatramp_core::function::{
2651 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2652 };
2653 use boatramp_handlers::{HandlerEngine, Limits};
2654 use futures::StreamExt;
2655
2656 const HTTP_200: &[u8] =
2657 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2658
2659 let storage = Arc::new(MemStorage::default());
2660 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2661 let deploy = DeployStore::new(storage.clone(), kv.clone());
2662 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2663 let stream: ByteStream =
2664 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2665 deploy.put_blob(&hash, stream).await.unwrap();
2666
2667 let function = Function {
2668 name: "worker".into(),
2669 owner: Owner::Project("default".into()),
2670 versions: vec![FunctionVersion {
2671 id: "v1".into(),
2672 component: hash.clone(),
2673 created: 0,
2674 lifecycle: Lifecycle::Independent,
2675 }],
2676 active: "v1".into(),
2677 aliases: Default::default(),
2678 config: Default::default(),
2679 };
2680 deploy
2681 .put_function(ProjectRef::DEFAULT, &function)
2682 .await
2683 .unwrap();
2684
2685 let base = Invocation {
2688 id: String::new(),
2689 function: "worker".into(),
2690 version: "v1".into(),
2691 mode: InvokeMode::Async,
2692 status: InvocationStatus::Running,
2693 idempotency_key: None,
2694 attempts: 1,
2695 lease_expires: None,
2696 request_b64: None,
2697 request_content_type: None,
2698 result: None,
2699 created: 0,
2700 updated: 0,
2701 };
2702 let orphan = Invocation {
2703 id: "orphan".into(),
2704 lease_expires: Some(1),
2705 ..base.clone()
2706 };
2707 deploy
2708 .put_invocation(ProjectRef::DEFAULT, &orphan)
2709 .await
2710 .unwrap();
2711 let live = Invocation {
2712 id: "live".into(),
2713 lease_expires: Some(u64::MAX),
2714 ..base.clone()
2715 };
2716 deploy
2717 .put_invocation(ProjectRef::DEFAULT, &live)
2718 .await
2719 .unwrap();
2720
2721 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2722 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2723 let inner = rt.inner.as_ref().unwrap();
2724
2725 let now = CronNow {
2726 minute: 0,
2727 hour: 0,
2728 dom: 1,
2729 month: 1,
2730 dow: 0,
2731 minute_stamp: 0,
2732 };
2733 let mut wasm_cache = std::collections::HashMap::new();
2734 let mut cron_state = std::collections::HashMap::new();
2735 run_scheduler_tick(inner, &deploy, &mut wasm_cache, &mut cron_state, now)
2736 .await
2737 .unwrap();
2738
2739 let settled =
2741 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
2742 assert_eq!(settled.status, InvocationStatus::Succeeded);
2743 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
2744 assert_eq!(
2745 settled.lease_expires, None,
2746 "a settled invocation drops its lease"
2747 );
2748 let live_after = deploy
2750 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
2751 .await
2752 .unwrap()
2753 .unwrap();
2754 assert_eq!(live_after.status, InvocationStatus::Running);
2755 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
2756 assert_eq!(live_after.lease_expires, Some(u64::MAX));
2757 }
2758
2759 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2774 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2775 use boatramp_core::deploy::DeployStore;
2776 use boatramp_core::function::{
2777 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2778 };
2779 use boatramp_handlers::{HandlerEngine, Limits};
2780 use futures::StreamExt;
2781
2782 let storage = Arc::new(MemStorage::default());
2783 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2784 let deploy = DeployStore::new(storage.clone(), kv.clone());
2785
2786 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2789 let stream: ByteStream =
2790 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2791 deploy.put_blob(&hash, stream).await.unwrap();
2792
2793 let store = Function {
2798 name: "store".into(),
2799 owner: Owner::Project("default".into()),
2800 versions: vec![FunctionVersion {
2801 id: "v1".into(),
2802 component: hash.clone(),
2803 created: 0,
2804 lifecycle: Lifecycle::Independent,
2805 }],
2806 active: "v1".into(),
2807 aliases: Default::default(),
2808 config: FunctionConfig {
2809 imports: vec!["wasi:keyvalue".into()],
2810 ..Default::default()
2811 },
2812 };
2813 let acme = ProjectRef::new("acme");
2814 let globex = ProjectRef::new("globex");
2815
2816 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2817 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2818 let inner = rt.inner.as_ref().unwrap();
2819
2820 let request = || {
2821 axum::http::Request::builder()
2822 .method("GET")
2823 .uri("/")
2824 .body(axum::body::Body::empty())
2825 .unwrap()
2826 };
2827
2828 let component = store.resolve(&store.active).unwrap().to_owned();
2831 for project in [acme, globex, ProjectRef::DEFAULT] {
2832 let (response, _) = execute_function(
2833 inner,
2834 &deploy,
2835 project,
2836 &store,
2837 &component,
2838 request(),
2839 0,
2840 boatramp_handlers::Lane::Sync,
2841 )
2842 .await;
2843 assert!(response.status().is_success(), "invocation should succeed");
2844 }
2845
2846 assert_eq!(
2849 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
2850 Some(b"1".to_vec()),
2851 "acme's write must be tenant-qualified"
2852 );
2853 assert_eq!(
2854 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
2855 Some(b"1".to_vec()),
2856 "globex's write must be tenant-qualified"
2857 );
2858 assert_eq!(
2859 kv.get("hkv/fn/store/hits").await.unwrap(),
2860 Some(b"1".to_vec()),
2861 "the default project must keep the byte-identical pre-project key"
2862 );
2863 }
2866
2867 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2871 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
2872 use boatramp_core::config::{
2873 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2874 };
2875 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2876 use boatramp_handlers::{HandlerEngine, Limits};
2877 use futures::StreamExt;
2878 use std::sync::atomic::Ordering;
2879
2880 let storage = Arc::new(MemStorage::default());
2881 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2882 let deploy = DeployStore::new(storage.clone(), kv.clone());
2883
2884 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2885 let stream: ByteStream =
2886 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2887 deploy.put_blob(&hash, stream).await.unwrap();
2888 let mut files = std::collections::BTreeMap::new();
2889 files.insert(
2890 "counter.wasm".to_string(),
2891 FileEntry {
2892 hash: hash.clone(),
2893 size: KV_COUNTER.len() as u64,
2894 content_type: None,
2895 variants: std::collections::BTreeMap::new(),
2896 },
2897 );
2898 let manifest = Manifest {
2899 files,
2900 config: DeployConfig {
2901 handlers: vec![HandlerConfig {
2902 route: "/".into(),
2903 methods: Vec::new(),
2904 component: "counter.wasm".into(),
2905 imports: vec!["wasi:keyvalue".into()],
2906 limits: None,
2907 env: std::collections::BTreeMap::new(),
2908 invoke_targets: Vec::new(),
2909 }],
2910 crons: vec![CronConfig {
2911 schedule: "* * * * *".into(),
2912 route: "/".into(),
2913 overlap: Overlap::Skip,
2914 }],
2915 ..Default::default()
2916 },
2917 ..Default::default()
2918 };
2919 let id = deploy.put_manifest(&manifest).await.unwrap();
2920 deploy
2921 .activate(ProjectRef::DEFAULT, "blog", &id)
2922 .await
2923 .unwrap();
2924 deploy
2925 .set_site_config(
2926 ProjectRef::DEFAULT,
2927 "blog",
2928 &SiteConfig {
2929 handlers: Some(HandlersSiteConfig {
2930 enabled: true,
2931 allow_imports: vec!["wasi:keyvalue".into()],
2932 ..Default::default()
2933 }),
2934 ..Default::default()
2935 },
2936 )
2937 .await
2938 .unwrap();
2939
2940 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2941 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2942 let inner = rt.inner.clone().unwrap();
2943 let mut wasm = std::collections::HashMap::new();
2944 let mut crons = std::collections::HashMap::new();
2945 let at = |stamp| CronNow {
2946 minute: 0,
2947 hour: 0,
2948 dom: 1,
2949 month: 1,
2950 dow: 0,
2951 minute_stamp: stamp,
2952 };
2953
2954 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2956 .await
2957 .unwrap();
2958 for h in handles {
2959 h.await.unwrap();
2960 }
2961 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2962
2963 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2965 .await
2966 .unwrap();
2967 assert!(handles.is_empty());
2968 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2969
2970 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(101))
2972 .await
2973 .unwrap();
2974 for h in handles {
2975 h.await.unwrap();
2976 }
2977 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2978
2979 crons
2983 .get("default|blog|cron|0")
2984 .unwrap()
2985 .running
2986 .store(true, Ordering::Release);
2987 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(102))
2988 .await
2989 .unwrap();
2990 assert!(handles.is_empty());
2991 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2992 }
2993
2994 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2998 async fn cron_leader_gate_suppresses_crons_off_leader() {
2999 use boatramp_core::config::{
3000 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3001 };
3002 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3003 use boatramp_handlers::{HandlerEngine, Limits};
3004 use futures::StreamExt;
3005
3006 let storage = Arc::new(MemStorage::default());
3007 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3008 let deploy = DeployStore::new(storage.clone(), kv.clone());
3009
3010 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3011 let stream: ByteStream =
3012 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3013 deploy.put_blob(&hash, stream).await.unwrap();
3014 let mut files = std::collections::BTreeMap::new();
3015 files.insert(
3016 "counter.wasm".to_string(),
3017 FileEntry {
3018 hash: hash.clone(),
3019 size: KV_COUNTER.len() as u64,
3020 content_type: None,
3021 variants: std::collections::BTreeMap::new(),
3022 },
3023 );
3024 let manifest = Manifest {
3025 files,
3026 config: DeployConfig {
3027 handlers: vec![HandlerConfig {
3028 route: "/".into(),
3029 methods: Vec::new(),
3030 component: "counter.wasm".into(),
3031 imports: vec!["wasi:keyvalue".into()],
3032 limits: None,
3033 env: std::collections::BTreeMap::new(),
3034 invoke_targets: Vec::new(),
3035 }],
3036 crons: vec![CronConfig {
3037 schedule: "* * * * *".into(),
3038 route: "/".into(),
3039 overlap: Overlap::Skip,
3040 }],
3041 ..Default::default()
3042 },
3043 ..Default::default()
3044 };
3045 let id = deploy.put_manifest(&manifest).await.unwrap();
3046 deploy
3047 .activate(ProjectRef::DEFAULT, "blog", &id)
3048 .await
3049 .unwrap();
3050 deploy
3051 .set_site_config(
3052 ProjectRef::DEFAULT,
3053 "blog",
3054 &SiteConfig {
3055 handlers: Some(HandlersSiteConfig {
3056 enabled: true,
3057 allow_imports: vec!["wasi:keyvalue".into()],
3058 ..Default::default()
3059 }),
3060 ..Default::default()
3061 },
3062 )
3063 .await
3064 .unwrap();
3065
3066 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3067 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3068 rt.set_cron_leader_gate(Arc::new(|| false));
3070 let inner = rt.inner.clone().unwrap();
3071 let mut wasm = std::collections::HashMap::new();
3072 let mut crons = std::collections::HashMap::new();
3073 let now = CronNow {
3074 minute: 0,
3075 hour: 0,
3076 dom: 1,
3077 month: 1,
3078 dow: 0,
3079 minute_stamp: 100,
3080 };
3081
3082 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, now)
3083 .await
3084 .unwrap();
3085 assert!(handles.is_empty(), "a non-leader must not fire crons");
3087 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3088 }
3089
3090 #[tokio::test]
3097 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3098 use boatramp_core::config::HandlersSiteConfig;
3099 use boatramp_core::project::ProjectRef;
3100 use boatramp_handlers::{HandlerEngine, Limits};
3101
3102 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3103 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3104 let sql_dir =
3106 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3107 let _ = std::fs::remove_dir_all(&sql_dir);
3108 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3109 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3110
3111 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3112 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3113 let inner = rt.inner.as_ref().unwrap();
3114
3115 let site = HandlersSiteConfig {
3117 enabled: true,
3118 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3119 ..Default::default()
3120 };
3121 let env = std::collections::BTreeMap::new();
3122 let build = |imports: &[&str]| {
3123 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3124 let site = &site;
3125 let env = &env;
3126 async move {
3127 crate::handler_dispatch::build_bindings(
3128 inner,
3129 ProjectRef::new("default"),
3130 "shop",
3131 "shop",
3132 None,
3133 &imports,
3134 site,
3135 env,
3136 &[],
3137 0,
3138 None,
3139 )
3140 .await
3141 .sql_database_names()
3142 }
3143 };
3144
3145 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3148 assert_eq!(
3150 build(&["sql", "sql:*"]).await,
3151 vec!["", "privileged", "product"]
3152 );
3153 assert!(build(&["sql:secret"]).await.is_empty());
3155 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3157
3158 let _ = std::fs::remove_dir_all(&sql_dir);
3159 }
3160}