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, compute_dns, compute_dns_resolve, compute_exec, compute_ipam,
44 compute_netdiag, compute_reconcile, compute_restart, compute_set_health, compute_status,
45 create_deployment, current_deployment, delete_compute, delete_compute_volume, delete_site,
46 get_compute, get_daemon_config, get_deployment, get_site_config, invalidate_cache,
47 list_aliases, list_compute, list_compute_volumes, list_deployments, list_sites, prune_delete,
48 prune_report, put_blob, put_compute, put_daemon_config, put_site_config, remove_alias,
49 rollback_daemon_config, scrub_blobs, set_alias, sql_exec, sql_ping, sql_query,
50};
51#[cfg(feature = "handlers")]
52pub(crate) use admin_api::{
53 delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
54 list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
55 put_graphql_subgraph, register_graphql_safelist,
56};
57#[cfg(feature = "admin")]
59mod admin_controller;
60mod auth;
61#[cfg(feature = "console")]
62pub mod console;
63mod content;
64mod control_api;
65#[cfg(feature = "email")]
68mod email_spool;
69#[cfg(feature = "admin")]
70pub use admin_controller::ServerAdminController;
71#[cfg(feature = "compression")]
72pub(crate) use content::maybe_compress;
73pub(crate) use content::multipart_byteranges;
74pub(crate) use content::{
75 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
76};
77pub(crate) use control_api::{
78 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
79 cluster_revoke, cluster_rotate_key, create_join_token, create_token, delete_email_profile,
80 delete_secret, get_authz_policy, list_email_profiles, list_root_anchors, list_secrets,
81 list_tokens, put_authz_policy, remove_root_anchor, revoke_token, set_email_profile, set_secret,
82 show_email_profile,
83};
84#[cfg(all(test, feature = "handlers"))]
85use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
86#[cfg(feature = "email")]
87pub use email_spool::NodeEmailSpool;
88mod domain_verify;
89pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
90pub mod envelope;
91#[cfg(feature = "handlers")]
92mod graphql_apq;
93#[cfg(feature = "handlers")]
94mod graphql_cache;
95#[cfg(feature = "handlers")]
96mod graphql_data;
97#[cfg(feature = "handlers")]
98mod graphql_federation;
99#[cfg(feature = "handlers")]
100mod graphql_gateway;
101#[cfg(feature = "handlers")]
102mod graphql_graphiql;
103#[cfg(feature = "handlers")]
104mod graphql_guard;
105#[cfg(feature = "handlers")]
106mod graphql_plan;
107#[cfg(feature = "handlers")]
108mod graphql_registry;
109#[cfg(feature = "handlers")]
110mod graphql_subscription;
111#[cfg(feature = "handlers")]
112mod handler_cache;
113#[cfg(feature = "handlers")]
114mod handler_dispatch;
115#[cfg(feature = "handlers")]
116pub(crate) use handler_dispatch::{
117 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
118 read_blob_fully, resolve_secret_env,
119};
120#[cfg(all(feature = "handlers", test))]
121use handler_dispatch::{resolve_env, set_forwarded_headers};
122mod function_api;
123pub(crate) use function_api::{
124 alias_function, deploy_function, list_functions, remove_function, rollback_function,
125};
126#[cfg(feature = "handlers")]
131pub use function_api::{
132 component_requires, host_capability_features, host_capability_features_detailed, unmet_requires,
133};
134#[cfg(all(test, feature = "handlers"))]
135use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
136pub use function_api::{CapabilityFeature, Lifecycle};
139mod gateway;
140mod host;
141pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
142#[cfg(feature = "http3")]
143mod http3;
144mod limits;
145#[cfg(feature = "handlers")]
146mod logs;
147#[cfg(feature = "handlers")]
148mod metrics;
149#[cfg(feature = "oidc")]
150mod oidc;
151mod operator;
152pub(crate) use operator::prometheus_metrics;
153#[cfg(feature = "handlers")]
154pub(crate) use operator::{
155 operator_dlq, operator_function_logs, operator_function_logs_stream, operator_handler_stats,
156 operator_logs, operator_logs_stream,
157};
158mod proxy;
159pub use proxy::spawn_compute_reconcile;
160pub(crate) use proxy::{
161 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
162 proxy, COMPUTE_WAKE_TIMEOUT,
163};
164mod splice;
165mod http_serve;
168pub use http_serve::{
169 alpn_h1_h2, serve_plaintext, serve_plaintext_listener, serve_router_conn, serve_tls,
170 serve_tls_listener, ReloadableTls, ServeInput,
171};
172#[cfg(feature = "handlers")]
174pub(crate) use proxy::is_upgrade_request;
175#[cfg(all(test, feature = "handlers"))]
176use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
177mod project_api;
178pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
179mod project_scope;
180pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
181mod ratelimit;
182mod routes;
183pub use routes::{router, router_with, router_with_fast};
184#[cfg(feature = "mcp")]
185mod mcp_http;
186#[cfg(feature = "handlers")]
187mod scheduler;
188mod serve_pipeline;
189#[cfg(feature = "handlers")]
190mod tenant_resolve;
191pub use serve_pipeline::{http_redirect_router, FastServe};
192#[cfg(test)]
193mod hotpath_test;
194#[cfg(all(test, feature = "handlers"))]
195use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
196pub(crate) use serve_pipeline::{
197 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
198 BootstrapAttestation,
199};
200pub mod signer;
203mod srvmetrics;
204#[cfg(all(feature = "handlers", test))]
205use scheduler::run_scheduler_tick;
206#[cfg(feature = "handlers")]
207pub(crate) use scheduler::{
208 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
209};
210#[cfg(feature = "handlers")]
211use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
212#[cfg(feature = "handlers")]
213mod function_runtime;
214#[cfg(feature = "handlers")]
215pub(crate) use function_runtime::{
216 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
217 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
218 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
219 put_trigger_handler, webhook_ingress,
220};
221#[cfg(feature = "handlers")]
222mod stream;
223#[cfg(feature = "handlers")]
224mod workflow;
225pub use auth::{require_auth, Auth};
226#[cfg(feature = "http3")]
227pub use http3::{
228 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
229 Http3Error,
230};
231pub use limits::{ServerLimits, UploadGuard};
232#[cfg(feature = "oidc")]
233pub use oidc::{OidcConfig, OidcError, OidcVerifier};
234use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
235#[cfg(feature = "handlers")]
236pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
237#[cfg(feature = "handlers")]
238pub(crate) use workflow::{
239 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
240 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
241};
242pub use srvmetrics::{server_metrics, ServerMetrics};
245
246#[derive(Clone, Default)]
251pub struct HandlerRuntime {
252 #[cfg(feature = "handlers")]
253 inner: Option<Arc<HandlerRuntimeInner>>,
254}
255
256#[cfg(feature = "handlers")]
257struct HandlerRuntimeInner {
258 engine: boatramp_handlers::HandlerEngine,
259 async_drain_gate: Arc<tokio::sync::Semaphore>,
265 kv: Arc<dyn boatramp_core::kv::KvStore>,
266 storage: Arc<dyn boatramp_core::Storage>,
267 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
271 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
274 site_semaphores:
277 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
278 stream_semaphores:
282 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
283 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
286 metrics: metrics::Metrics,
289 logs: Arc<logs::LogStore>,
291 #[cfg(feature = "handlers")]
295 graphql_cache: graphql_cache::GraphqlCache,
296 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
302 max_blob_bytes: std::sync::OnceLock<u64>,
306 max_component_bytes: std::sync::OnceLock<u64>,
311 allow_env_secret_refs: std::sync::OnceLock<bool>,
320 require_tenancy_declaration: std::sync::OnceLock<bool>,
326 allow_cross_tenant_db: std::sync::OnceLock<bool>,
331 secret_store: std::sync::OnceLock<Arc<boatramp_core::secret_store::SecretStore>>,
337 function_meter_locks:
341 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
342 function_semaphores:
345 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
346 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
351 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
355 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
363 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
368 #[cfg(feature = "email")]
373 email_profile_store: std::sync::OnceLock<Arc<boatramp_core::email_config::EmailProfileStore>>,
374 #[cfg(feature = "email")]
379 email_spool: std::sync::OnceLock<Arc<dyn boatramp_handlers::EmailSpool>>,
380 #[cfg(feature = "admin")]
384 admin_controller: std::sync::OnceLock<Arc<admin_controller::ServerAdminController>>,
385 #[cfg(feature = "admin")]
389 admin_surfaces:
390 std::sync::OnceLock<std::collections::BTreeSet<boatramp_handlers::AdminSurface>>,
391}
392
393pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
396
397impl HandlerRuntime {
398 pub fn disabled() -> Self {
400 Self::default()
401 }
402
403 #[cfg(feature = "handlers")]
409 pub fn new(
410 engine: boatramp_handlers::HandlerEngine,
411 kv: Arc<dyn boatramp_core::kv::KvStore>,
412 storage: Arc<dyn boatramp_core::Storage>,
413 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
414 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
415 ) -> Self {
416 let async_drain_slots = engine.async_max_concurrency().max(1);
419 Self {
420 inner: Some(Arc::new(HandlerRuntimeInner {
421 engine,
422 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
423 kv,
424 storage,
425 sql,
426 messaging,
427 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
428 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
429 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
430 metrics: metrics::Metrics::default(),
431 logs: Arc::new(logs::LogStore::default()),
432 #[cfg(feature = "handlers")]
433 graphql_cache: graphql_cache::GraphqlCache::default(),
434 cron_leader_gate: std::sync::OnceLock::new(),
435 max_blob_bytes: std::sync::OnceLock::new(),
436 max_component_bytes: std::sync::OnceLock::new(),
437 allow_env_secret_refs: std::sync::OnceLock::new(),
438 require_tenancy_declaration: std::sync::OnceLock::new(),
439 allow_cross_tenant_db: std::sync::OnceLock::new(),
440 secret_store: std::sync::OnceLock::new(),
441 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
442 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
443 watch_provider: std::sync::OnceLock::new(),
444 provision_tier: std::sync::OnceLock::new(),
445 invoker: std::sync::OnceLock::new(),
446 federation_runner: std::sync::OnceLock::new(),
447 #[cfg(feature = "email")]
448 email_profile_store: std::sync::OnceLock::new(),
449 #[cfg(feature = "email")]
450 email_spool: std::sync::OnceLock::new(),
451 #[cfg(feature = "admin")]
452 admin_controller: std::sync::OnceLock::new(),
453 #[cfg(feature = "admin")]
454 admin_surfaces: std::sync::OnceLock::new(),
455 })),
456 }
457 }
458
459 #[cfg(feature = "handlers")]
462 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
463 self.inner.as_ref().and_then(|inner| inner.sql.clone())
464 }
465
466 #[cfg(feature = "handlers")]
470 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
471 self.inner
472 .as_ref()
473 .and_then(|inner| inner.invoker.get().cloned())
474 }
475
476 #[cfg(feature = "handlers")]
480 pub(crate) async fn introspect_subgraph_sdl(
481 &self,
482 deploy: &DeployStore,
483 project: boatramp_core::project::ProjectRef<'_>,
484 function: &boatramp_core::function::Function,
485 component: &str,
486 ) -> Result<String, function_runtime::SubgraphSdlError> {
487 match self.inner.as_ref() {
488 Some(inner) => {
489 function_runtime::introspect_service_sdl(
490 inner, deploy, project, function, component,
491 )
492 .await
493 }
494 None => Err(function_runtime::SubgraphSdlError::Unavailable),
495 }
496 }
497
498 #[cfg(feature = "handlers")]
504 pub fn set_invoker(&self, deploy: DeployStore) {
505 if let Some(inner) = self.inner.as_ref() {
506 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
507 deploy,
508 Arc::downgrade(inner),
509 ));
510 let _ = inner.invoker.set(invoker);
511 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
514 inner,
515 )));
516 let _ = inner.federation_runner.set(runner);
517 }
518 }
519
520 #[cfg(feature = "handlers")]
524 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
525 self.inner.as_ref().and_then(|inner| inner.sql.clone())
526 }
527
528 #[cfg(feature = "handlers")]
532 pub fn set_watch_provider(
533 &self,
534 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
535 ) {
536 if let Some(inner) = self.inner.as_ref() {
537 let _ = inner.watch_provider.set(provider);
538 }
539 }
540
541 #[cfg(feature = "handlers")]
545 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
546 if let Some(inner) = self.inner.as_ref() {
547 let _ = inner.provision_tier.set(tier);
548 }
549 }
550
551 #[cfg(feature = "handlers")]
555 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
556 if let Some(inner) = self.inner.as_ref() {
557 let _ = inner.max_blob_bytes.set(max_bytes);
558 }
559 }
560
561 #[cfg(feature = "handlers")]
564 pub fn set_max_component_bytes(&self, max_bytes: u64) {
565 if let Some(inner) = self.inner.as_ref() {
566 let _ = inner.max_component_bytes.set(max_bytes);
567 }
568 }
569
570 #[cfg(feature = "handlers")]
578 pub fn set_allow_env_secret_refs(&self, allow: bool) {
579 if let Some(inner) = self.inner.as_ref() {
580 let _ = inner.allow_env_secret_refs.set(allow);
581 }
582 }
583
584 #[cfg(feature = "handlers")]
589 pub fn set_tenancy_posture(&self, require_declaration: bool, allow_cross_tenant: bool) {
590 if let Some(inner) = self.inner.as_ref() {
591 let _ = inner.require_tenancy_declaration.set(require_declaration);
592 let _ = inner.allow_cross_tenant_db.set(allow_cross_tenant);
593 }
594 }
595
596 #[cfg(feature = "handlers")]
601 pub fn set_secret_store(&self, store: Arc<boatramp_core::secret_store::SecretStore>) {
602 if let Some(inner) = self.inner.as_ref() {
603 let _ = inner.secret_store.set(store);
604 }
605 }
606
607 #[cfg(feature = "email")]
611 pub fn set_email_profile_store(
612 &self,
613 store: Arc<boatramp_core::email_config::EmailProfileStore>,
614 ) {
615 if let Some(inner) = self.inner.as_ref() {
616 let _ = inner.email_profile_store.set(store);
617 }
618 }
619
620 #[cfg(feature = "email")]
625 pub fn set_email_spool(&self, spool: Arc<dyn boatramp_handlers::EmailSpool>) {
626 if let Some(inner) = self.inner.as_ref() {
627 let _ = inner.email_spool.set(spool);
628 }
629 }
630
631 #[cfg(feature = "admin")]
636 pub fn set_admin(
637 &self,
638 controller: Arc<admin_controller::ServerAdminController>,
639 surfaces: std::collections::BTreeSet<boatramp_handlers::AdminSurface>,
640 ) {
641 if let Some(inner) = self.inner.as_ref() {
642 let _ = inner.admin_controller.set(controller);
643 let _ = inner.admin_surfaces.set(surfaces);
644 }
645 }
646
647 #[cfg(feature = "handlers")]
652 pub(crate) fn allow_env_secret_refs(&self) -> bool {
653 self.inner
654 .as_ref()
655 .and_then(|inner| inner.allow_env_secret_refs.get().copied())
656 .unwrap_or(false)
657 }
658
659 #[cfg(feature = "handlers")]
664 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
665 if let Some(inner) = self.inner.as_ref() {
666 let _ = inner.cron_leader_gate.set(gate);
667 }
668 }
669
670 #[cfg(feature = "handlers")]
677 async fn precheck_activation(
678 &self,
679 deploy: &DeployStore,
680 manifest: &Manifest,
681 site_config: Option<&SiteConfig>,
682 ) -> Result<(), String> {
683 let Some(inner) = self.inner.as_ref() else {
684 return Ok(());
685 };
686 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
689 return Ok(());
690 }
691 let site_handlers = site_config
693 .and_then(|c| c.handlers.as_ref())
694 .filter(|h| h.enabled)
695 .ok_or_else(|| {
696 "deployment ships handlers/consumers but the site has them disabled".to_string()
697 })?;
698 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
699
700 let allow_env_secret_refs = inner.allow_env_secret_refs.get().copied().unwrap_or(false);
707 crate::handler_dispatch::admit_secret_refs(&site_handlers.secrets, allow_env_secret_refs)
708 .map_err(|err| format!("handler secrets: {err}"))?;
709
710 let sync_ceiling = inner.engine.sync_timeout_ms();
716 let async_ceiling = inner.engine.async_timeout_ms();
717 if let Some(ms) = site_handlers.max_timeout_ms {
718 if u64::from(ms) > sync_ceiling {
719 tracing::warn!(
720 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
721 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
722 applies only to async calls (?mode=async / triggers), capped at \
723 async_max_timeout_ms={async_ceiling}"
724 );
725 }
726 }
727
728 for handler in &manifest.config.handlers {
730 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
731 if u64::from(ms) > sync_ceiling {
732 let route = &handler.route;
733 tracing::warn!(
734 "route {route:?} declares limits.timeout_ms={ms}, above \
735 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
736 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
737 (?mode=async / a queue trigger / a #[consumer]), capped at \
738 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
739 that isn't possible — move the work to the async lane"
740 );
741 }
742 }
743 if !handler.streaming {
748 if let Some(entry) = manifest.files.get(&handler.component) {
749 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
750 if crate::function_api::component_declares_streaming_route(
751 &bytes,
752 &handler.route,
753 ) {
754 let route = &handler.route;
755 tracing::warn!(
756 "route {route:?} is a streaming handler (#[handler(stream)]) but \
757 its config lacks streaming = true: it will run on the sync request \
758 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
759 streaming = true so it serves on the dedicated streaming lane (its \
760 own concurrency budget + a much larger wall-clock)."
761 );
762 }
763 }
764 }
765 }
766 if let Some(entry) = manifest.files.get(&handler.component) {
770 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
771 let unmet = crate::function_api::unmet_requires(&bytes);
772 if !unmet.is_empty() {
773 return Err(format!(
774 "route {:?} [{}] requires capabilities this host does not implement: \
775 {}. Upgrade boatramp or enable those features — see `boatramp \
776 capabilities`.",
777 handler.route,
778 handler.methods.join(","),
779 unmet.join(", ")
780 ));
781 }
782 }
783 }
784 precheck_component(
785 deploy,
786 manifest,
787 site_handlers,
788 inner,
789 max_component,
790 &handler.imports,
791 &handler.component,
792 &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
795 false,
796 )
797 .await?;
798 }
799 for consumer in &manifest.config.consumers {
800 precheck_component(
801 deploy,
802 manifest,
803 site_handlers,
804 inner,
805 max_component,
806 &consumer.imports,
807 &consumer.component,
808 &format!("consumer {:?}", consumer.topic),
809 true,
810 )
811 .await?;
812 }
813 Ok(())
814 }
815
816 #[cfg(not(feature = "handlers"))]
817 async fn precheck_activation(
818 &self,
819 _deploy: &DeployStore,
820 _manifest: &Manifest,
821 _site_config: Option<&SiteConfig>,
822 ) -> Result<(), String> {
823 Ok(())
824 }
825}
826
827#[derive(Default, Clone)]
833pub struct ServerOptions {
834 pub limits: ServerLimits,
836 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
839 pub default_site: Option<String>,
842 pub implicit_routing: bool,
848 pub protect_previews: bool,
853 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
857 pub issuer: Option<Arc<dyn Signer>>,
861 pub bootstrap_secret: Option<String>,
866 pub bootstrap_attestation: Option<String>,
872 pub mesh_control: Option<Arc<dyn MeshControl>>,
876 pub cors_allowed_origins: Vec<String>,
886 #[cfg(feature = "oidc")]
889 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
890 pub posture: boatramp_core::security::SecurityPosture,
894 pub served_over_tls: bool,
899 pub pop_origin: Option<String>,
906 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
910 pub operator_sql: Option<Arc<dyn boatramp_core::sql::OperatorSql>>,
914 pub tenant_deprovisioner: Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>,
919 pub compute_exec: Option<Arc<dyn boatramp_core::compute::ComputeExec>>,
923 pub compute_volumes: Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>,
928 pub compute_control: Option<Arc<dyn boatramp_core::compute::ComputeControl>>,
932 pub secret_store: Option<Arc<boatramp_core::secret_store::SecretStore>>,
940 pub email_profile_store: Option<Arc<boatramp_core::email_config::EmailProfileStore>>,
948 #[cfg(feature = "console")]
952 pub console: Option<console::ConsoleMount>,
953}
954
955#[derive(Clone, Copy)]
959struct ServedOverTls(bool);
960
961#[derive(Clone, Copy, Default)]
966struct ImplicitRouting(bool);
967
968const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
980
981pub struct DaemonRuntime {
982 baseline: boatramp_core::daemon_config::ConfigBaseline,
983 state: std::sync::RwLock<DaemonState>,
984 reload: tokio::sync::Notify,
987}
988
989struct DaemonState {
990 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
991 generation: Option<String>,
992}
993
994pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
999 #[cfg(feature = "console")]
1003 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
1004 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
1005 None => (false, None, None),
1006 };
1007 #[cfg(not(feature = "console"))]
1008 let (console_enabled, console_host, console_path) = (false, None, None);
1009 boatramp_core::daemon_config::ConfigBaseline {
1010 default_site: options.default_site.clone(),
1011 protect_previews: options.protect_previews,
1012 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
1013 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
1014 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
1015 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
1016 compute_vcpus: 0,
1017 compute_mem_mib: 0,
1018 console_enabled,
1019 console_host,
1020 console_path,
1021 max_upload_ceiling: options.posture.max_upload_bytes,
1022 max_concurrent_uploads_ceiling: None,
1023 posture: options.posture,
1024 }
1025}
1026
1027impl DaemonRuntime {
1028 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
1032 let effective =
1033 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
1034 Self {
1035 baseline,
1036 state: std::sync::RwLock::new(DaemonState {
1037 effective,
1038 generation: None,
1039 }),
1040 reload: tokio::sync::Notify::new(),
1041 }
1042 }
1043
1044 pub fn notify_reload(&self) {
1048 self.reload.notify_one();
1049 }
1050
1051 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
1053 self.state
1054 .read()
1055 .expect("daemon config lock")
1056 .effective
1057 .clone()
1058 }
1059
1060 pub fn generation(&self) -> Option<String> {
1063 self.state
1064 .read()
1065 .expect("daemon config lock")
1066 .generation
1067 .clone()
1068 }
1069
1070 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
1072 &self.baseline
1073 }
1074
1075 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
1078 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
1079 let generation = deploy.daemon_config_generation().await?;
1080 let effective = Arc::new(cfg.resolve(&self.baseline));
1081 *self.state.write().expect("daemon config lock") = DaemonState {
1082 effective,
1083 generation,
1084 };
1085 Ok(())
1086 }
1087}
1088
1089#[derive(Clone, Copy, Default)]
1092struct PreviewPolicy {
1093 protect: bool,
1094}
1095
1096#[derive(Clone, Default)]
1101struct Issuer(Option<Arc<dyn Signer>>);
1102
1103#[derive(Clone, Default)]
1108struct BootstrapGate(Option<Arc<BootstrapInner>>);
1109
1110struct BootstrapInner {
1111 secret_hash: String,
1114 lock: tokio::sync::Mutex<()>,
1117}
1118
1119impl BootstrapGate {
1120 fn new(secret: Option<&str>) -> Self {
1121 Self(secret.filter(|s| !s.is_empty()).map(|s| {
1122 Arc::new(BootstrapInner {
1123 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
1124 lock: tokio::sync::Mutex::new(()),
1125 })
1126 }))
1127 }
1128}
1129
1130#[async_trait::async_trait]
1134pub trait MeshControl: Send + Sync {
1135 async fn admit(
1143 &self,
1144 mesh_pubkey_hex: &str,
1145 jti: &str,
1146 possession_proof: &[u8],
1147 proof_iat: u64,
1148 now: u64,
1149 advertise_addr: Option<&str>,
1150 ) -> Result<JoinOutcome, String>;
1151
1152 async fn rotate_key(&self) -> Result<String, String>;
1156
1157 async fn revoke(&self, node: u64) -> Result<(), String>;
1161
1162 async fn members(&self) -> Result<Vec<MeshMember>, String>;
1166
1167 async fn promote(&self, node: u64) -> Result<(), String>;
1170}
1171
1172pub enum JoinOutcome {
1174 Admitted {
1177 members: Vec<String>,
1179 addrs: std::collections::BTreeMap<u64, String>,
1181 },
1182 TokenSpent,
1184 ProofInvalid,
1186 Revoked,
1189}
1190
1191#[derive(Debug, Clone, Serialize)]
1193pub struct MeshMember {
1194 pub node: u64,
1196 pub voter: bool,
1198 pub caught_up: bool,
1200 pub leader: bool,
1202 #[serde(default, skip_serializing_if = "Option::is_none")]
1206 pub addr: Option<String>,
1207}
1208
1209#[derive(Clone, Default)]
1212struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
1213
1214#[cfg(feature = "oidc")]
1216#[derive(Clone, Default)]
1217struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1218
1219#[cfg(feature = "oidc")]
1222const EXCHANGE_TTL_SECS: u64 = 3600;
1223
1224use boatramp_core::time::now_unix;
1225
1226#[derive(Clone)]
1228struct CorsState(Arc<Vec<String>>);
1229
1230const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1232const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1235const CORS_MAX_AGE: &str = "600";
1237
1238fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1242 allowed.iter().any(|a| a == "*" || a == origin)
1243}
1244
1245async fn cors(
1252 State(allowed): State<CorsState>,
1253 request: Request,
1254 next: axum::middleware::Next,
1255) -> Response {
1256 let origin = request
1257 .headers()
1258 .get(header::ORIGIN)
1259 .and_then(|v| v.to_str().ok())
1260 .filter(|o| cors_origin_allowed(&allowed.0, o))
1261 .map(str::to_string);
1262 let is_preflight = request.method() == Method::OPTIONS
1264 && request
1265 .headers()
1266 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1267 if is_preflight {
1268 let allow_headers = request
1270 .headers()
1271 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1272 .and_then(|v| v.to_str().ok())
1273 .map(str::to_string)
1274 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1275 let mut response = Response::new(Body::empty());
1276 *response.status_mut() = StatusCode::NO_CONTENT;
1277 if let Some(origin) = origin {
1278 let headers = response.headers_mut();
1279 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1280 set_header(headers, header::VARY, "Origin");
1281 set_header(
1282 headers,
1283 header::ACCESS_CONTROL_ALLOW_METHODS,
1284 CORS_ALLOW_METHODS,
1285 );
1286 set_header(
1287 headers,
1288 header::ACCESS_CONTROL_ALLOW_HEADERS,
1289 &allow_headers,
1290 );
1291 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1292 }
1293 return response;
1294 }
1295 let mut response = next.run(request).await;
1296 if let Some(origin) = origin {
1297 let headers = response.headers_mut();
1298 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1299 if let Ok(value) = HeaderValue::from_str("Origin") {
1302 headers.append(header::VARY, value);
1303 }
1304 }
1305 response
1306}
1307
1308const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1313
1314#[derive(Debug, thiserror::Error)]
1316pub enum ServeError {
1317 #[error("server I/O: {0}")]
1319 Io(#[from] std::io::Error),
1320}
1321
1322pub async fn serve(
1325 addr: SocketAddr,
1326 deploy: DeployStore,
1327 auth: Auth,
1328 handlers: HandlerRuntime,
1329) -> Result<(), ServeError> {
1330 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1331}
1332
1333pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1343 if let Err(err) = stream.set_nodelay(true) {
1344 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1345 }
1346}
1347
1348pub async fn serve_with(
1350 addr: SocketAddr,
1351 deploy: DeployStore,
1352 auth: Auth,
1353 handlers: HandlerRuntime,
1354 options: ServerOptions,
1355) -> Result<(), ServeError> {
1356 let tcp = tokio::net::TcpListener::bind(addr).await?;
1357 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1358 let splice_ctx = splice::SpliceCtx {
1364 deploy: deploy.clone(),
1365 posture: options.posture,
1366 daemon: options.daemon_runtime.clone(),
1367 };
1368 #[cfg(feature = "handlers")]
1371 let scheduler = handlers.spawn_scheduler(deploy.clone());
1372 let gateway_prober = gateway::spawn_active_health_prober();
1376 let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1379
1380 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1384 let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1388 shutdown_signal().await;
1389 let _ = signalled_tx.send(true);
1390 });
1391 let signalled = {
1392 let mut rx = signalled_rx;
1393 async move {
1394 let _ = rx.wait_for(|fired| *fired).await;
1395 }
1396 };
1397 let result = serve_with_drain_deadline(
1398 async move { server.await.map_err(ServeError::from) },
1399 signalled,
1400 DRAIN_DEADLINE,
1401 )
1402 .await;
1403 #[cfg(feature = "handlers")]
1405 if let Some(handle) = scheduler {
1406 handle.abort();
1407 }
1408 gateway_prober.abort();
1409 result
1410}
1411
1412async fn serve_with_drain_deadline<Srv, Sig>(
1417 server: Srv,
1418 signalled: Sig,
1419 deadline: Duration,
1420) -> Result<(), ServeError>
1421where
1422 Srv: Future<Output = Result<(), ServeError>>,
1423 Sig: Future<Output = ()>,
1424{
1425 tokio::pin!(server);
1426 let drain_cap = async move {
1427 signalled.await;
1428 tokio::time::sleep(deadline).await;
1429 };
1430 tokio::select! {
1431 result = &mut server => result,
1432 _ = drain_cap => {
1433 tracing::warn!(
1434 deadline_s = deadline.as_secs(),
1435 "drain deadline exceeded; forcing shutdown with requests still in flight"
1436 );
1437 Ok(())
1438 }
1439 }
1440}
1441
1442pub async fn shutdown_signal() {
1445 let ctrl_c = async {
1446 let _ = tokio::signal::ctrl_c().await;
1447 };
1448 #[cfg(unix)]
1449 let terminate = async {
1450 if let Ok(mut sig) =
1451 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1452 {
1453 sig.recv().await;
1454 }
1455 };
1456 #[cfg(not(unix))]
1457 let terminate = std::future::pending::<()>();
1458
1459 tokio::select! {
1460 _ = ctrl_c => {}
1461 _ = terminate => {}
1462 }
1463 tracing::info!("shutdown signal received; draining");
1464}
1465
1466async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1470 match daemon.generation() {
1471 Some(gen) => format!("ok gen={gen}"),
1472 None => "ok".to_string(),
1473 }
1474}
1475
1476async fn readyz(State(deploy): State<DeployStore>) -> Response {
1478 match deploy.ready().await {
1479 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1480 Err(err) => {
1481 tracing::warn!(error = %err, "readiness probe failed");
1482 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1483 }
1484 }
1485}
1486
1487#[derive(Clone)]
1492pub struct RequestId(pub String);
1493
1494#[cfg(feature = "handlers")]
1500#[derive(Clone)]
1501pub struct DomainContext(pub String);
1502
1503fn request_id_for(headers: &HeaderMap) -> String {
1506 if let Some(id) = headers
1507 .get("x-request-id")
1508 .and_then(|v| v.to_str().ok())
1509 .map(str::trim)
1510 .filter(|s| !s.is_empty())
1511 {
1512 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1513 }
1514 use std::sync::atomic::{AtomicU64, Ordering};
1515 static SEQ: AtomicU64 = AtomicU64::new(0);
1516 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1517 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1518}
1519
1520struct AccessLog {
1524 request_id: String,
1525 method: Method,
1526 path: String,
1527 host: String,
1528 client: String,
1529 status: u16,
1530 encoding: String,
1532 start: std::time::Instant,
1533 bytes: std::sync::atomic::AtomicU64,
1534}
1535
1536impl Drop for AccessLog {
1537 fn drop(&mut self) {
1538 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1539 srvmetrics::server_metrics().record_request(self.status, bytes);
1542 tracing::info!(
1543 target: "boatramp::access",
1544 request_id = %self.request_id,
1545 method = %self.method,
1546 path = %self.path,
1547 host = %self.host,
1548 client = %self.client,
1549 status = self.status,
1550 bytes = bytes,
1551 encoding = %self.encoding,
1552 cache_result = srvmetrics::cache_result(self.status),
1553 elapsed_ms = self.start.elapsed().as_millis() as u64,
1554 "request"
1555 );
1556 }
1557}
1558
1559pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1565 let request_id = request_id_for(request.headers());
1566 request
1567 .extensions_mut()
1568 .insert(RequestId(request_id.clone()));
1569 request_id
1570}
1571
1572pub(crate) struct AccessLogCtx {
1577 request_id: String,
1578 method: Method,
1579 path: String,
1580 host: String,
1581 client: String,
1582 start: std::time::Instant,
1583}
1584
1585impl AccessLogCtx {
1586 pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1592 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1593 return None;
1594 }
1595 Some(Self {
1596 request_id,
1597 method: request.method().clone(),
1598 path: request.uri().path().to_string(),
1599 host: request
1600 .headers()
1601 .get(header::HOST)
1602 .and_then(|value| value.to_str().ok())
1603 .or_else(|| request.uri().host()) .unwrap_or("-")
1605 .to_string(),
1606 client: request
1607 .extensions()
1608 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1609 .map(|info| info.0.ip().to_string())
1610 .unwrap_or_else(|| "-".to_string()),
1611 start: std::time::Instant::now(),
1612 })
1613 }
1614
1615 pub(crate) fn finish(self, response: Response) -> Response {
1619 let encoding = response
1620 .headers()
1621 .get(header::CONTENT_ENCODING)
1622 .and_then(|v| v.to_str().ok())
1623 .unwrap_or("identity")
1624 .to_string();
1625 let log = AccessLog {
1626 request_id: self.request_id,
1627 method: self.method,
1628 path: self.path,
1629 host: self.host,
1630 client: self.client,
1631 status: response.status().as_u16(),
1632 encoding,
1633 start: self.start,
1634 bytes: std::sync::atomic::AtomicU64::new(0),
1635 };
1636 let (parts, body) = response.into_parts();
1637 let counted = body.into_data_stream().map(move |chunk| {
1638 if let Ok(bytes) = &chunk {
1639 log.bytes
1640 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1641 }
1642 chunk
1643 });
1644 Response::from_parts(parts, Body::from_stream(counted))
1645 }
1646}
1647
1648async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1653 let request_id = assign_request_id(&mut request);
1654 match AccessLogCtx::capture(&request, request_id) {
1655 None => next.run(request).await,
1656 Some(ctx) => ctx.finish(next.run(request).await),
1657 }
1658}
1659
1660fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1662 req_headers
1663 .get(header::IF_NONE_MATCH)
1664 .and_then(|value| value.to_str().ok())
1665 .is_some_and(|value| {
1666 value
1667 .split(',')
1668 .map(str::trim)
1669 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1670 })
1671}
1672
1673fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1674 if let Ok(value) = HeaderValue::from_str(value) {
1675 headers.insert(name, value);
1676 }
1677}
1678
1679fn not_found() -> Response {
1680 (StatusCode::NOT_FOUND, "not found\n").into_response()
1681}
1682
1683fn redirect(status: u16, location: &str) -> Response {
1684 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1685 match HeaderValue::from_str(location) {
1686 Ok(location) => {
1687 let mut headers = HeaderMap::new();
1688 headers.insert(header::LOCATION, location);
1689 (status, headers).into_response()
1690 }
1691 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1692 }
1693}
1694
1695fn deploy_error_response(err: DeployError) -> Response {
1697 let status = match &err {
1698 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1699 StatusCode::NOT_FOUND
1700 }
1701 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1702 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1703 DeployError::Conflict(_) => StatusCode::CONFLICT,
1705 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1707 _ => StatusCode::INTERNAL_SERVER_ERROR,
1708 };
1709 tracing::warn!(error = %err, "request failed");
1710 (status, format!("{err}\n")).into_response()
1711}
1712
1713fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1718 boatramp_core::project::validate_resource_name(kind, value)
1719 .err()
1720 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1721}
1722
1723#[cfg(test)]
1724mod drain_tests {
1725 use super::*;
1726
1727 #[tokio::test]
1728 async fn deadline_forces_shutdown_after_signal() {
1729 let server = std::future::pending::<Result<(), ServeError>>();
1732 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1734 assert!(result.is_ok());
1735 }
1736
1737 #[tokio::test]
1738 async fn server_finishing_first_wins() {
1739 let server = async { Ok(()) };
1742 let signalled = std::future::pending::<()>();
1743 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1744 assert!(result.is_ok());
1745 }
1746
1747 #[tokio::test]
1748 async fn deadline_does_not_trip_before_signal() {
1749 let server = async {
1753 tokio::time::sleep(Duration::from_millis(40)).await;
1754 Err(ServeError::Io(std::io::Error::other("server error")))
1755 };
1756 let signalled = std::future::pending::<()>();
1757 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1758 assert!(result.is_err());
1759 }
1760}
1761
1762#[cfg(all(test, feature = "handlers"))]
1763mod tests {
1764 use super::*;
1765 use boatramp_core::cose::{LocalSigner, TokenAlg};
1766 use boatramp_core::project::ProjectRef;
1767
1768 #[test]
1769 fn query_string_parses_and_url_decodes() {
1770 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1771 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1772 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")); }
1776
1777 #[test]
1778 fn cookie_header_parses_pairs() {
1779 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1780 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1781 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1782 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1783 }
1784
1785 #[test]
1786 fn apply_vary_merges_without_duplicates() {
1787 let base = (StatusCode::OK, "x").into_response();
1788 let r = apply_vary(base, &["accept-language".into()]);
1789 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1790 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1792 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1793 assert!(v.contains("accept-language") && v.contains("cookie"));
1794 assert_eq!(v.matches("accept-language").count(), 1);
1795 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1797 assert!(plain.headers().get(header::VARY).is_none());
1798 }
1799
1800 #[tokio::test]
1804 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1805 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1806 let public = keys.public_key();
1807
1808 let resp = create_join_token(
1810 Extension(Issuer(Some(keys.clone()))),
1811 Json(CreateJoinTokenRequest {
1812 ttl_secs: Some(600),
1813 }),
1814 )
1815 .await;
1816 assert_eq!(resp.status(), StatusCode::CREATED);
1817 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1818 .await
1819 .unwrap();
1820 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1821 let token = parsed["token"].as_str().unwrap();
1822 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1823 assert!(!jti.is_empty());
1824
1825 let no_issuer = create_join_token(
1827 Extension(Issuer(None)),
1828 Json(CreateJoinTokenRequest { ttl_secs: None }),
1829 )
1830 .await;
1831 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1832 }
1833
1834 #[tokio::test]
1839 async fn function_write_path_deploy_rollback_alias_remove() {
1840 use boatramp_core::function::Lifecycle;
1841 use boatramp_core::kv::MemoryKv;
1842 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1843
1844 struct FakeStorage {
1847 present: bool,
1848 }
1849 #[async_trait::async_trait]
1850 impl Storage for FakeStorage {
1851 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1852 Err(StorageError::NotFound(String::new()))
1853 }
1854 async fn get_range(
1855 &self,
1856 _: &str,
1857 _: u64,
1858 _: Option<u64>,
1859 ) -> Result<GetObject, StorageError> {
1860 Err(StorageError::NotFound(String::new()))
1861 }
1862 async fn put(
1863 &self,
1864 _: &str,
1865 _: ByteStream,
1866 _: PutMeta,
1867 ) -> Result<ObjectMeta, StorageError> {
1868 Err(StorageError::unsupported("fake"))
1869 }
1870 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1871 if self.present {
1872 Ok(ObjectMeta {
1873 key: key.to_string(),
1874 ..Default::default()
1875 })
1876 } else {
1877 Err(StorageError::NotFound(key.to_string()))
1878 }
1879 }
1880 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1881 Ok(())
1882 }
1883 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1884 Ok(Vec::new())
1885 }
1886 }
1887
1888 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1889 let status = resp.status();
1890 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1891 .await
1892 .unwrap();
1893 let value = if bytes.is_empty() {
1894 serde_json::Value::Null
1895 } else {
1896 serde_json::from_slice(&bytes).unwrap()
1897 };
1898 (status, value)
1899 }
1900
1901 let deploy = DeployStore::new(
1902 Arc::new(FakeStorage { present: true }),
1903 Arc::new(MemoryKv::new()),
1904 );
1905 let v1 = "a".repeat(64);
1906 let v2 = "b".repeat(64);
1907
1908 let (st, body) = body_json(
1910 deploy_function(
1911 State(deploy.clone()),
1912 axum::extract::Extension(crate::ProjectContext::default()),
1913 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1914 axum::extract::Query(DeployFunctionQuery::default()),
1915 Path("greeter".to_string()),
1916 Json(FunctionUpsert {
1917 component: v1.clone(),
1918 config: Default::default(),
1919 lifecycle: Lifecycle::Independent,
1920 }),
1921 )
1922 .await,
1923 )
1924 .await;
1925 assert_eq!(st, StatusCode::OK);
1926 assert_eq!(body["active"], v1);
1927
1928 let (_, body) = body_json(
1930 deploy_function(
1931 State(deploy.clone()),
1932 axum::extract::Extension(crate::ProjectContext::default()),
1933 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1934 axum::extract::Query(DeployFunctionQuery::default()),
1935 Path("greeter".to_string()),
1936 Json(FunctionUpsert {
1937 component: v2.clone(),
1938 config: Default::default(),
1939 lifecycle: Lifecycle::Independent,
1940 }),
1941 )
1942 .await,
1943 )
1944 .await;
1945 assert_eq!(body["active"], v2);
1946 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1947
1948 let (st, body) = body_json(
1950 rollback_function(
1951 State(deploy.clone()),
1952 axum::extract::Extension(crate::ProjectContext::default()),
1953 Path("greeter".to_string()),
1954 Json(RollbackBody { to: v1.clone() }),
1955 )
1956 .await,
1957 )
1958 .await;
1959 assert_eq!(st, StatusCode::OK);
1960 assert_eq!(body["active"], v1);
1961
1962 let resp = rollback_function(
1964 State(deploy.clone()),
1965 axum::extract::Extension(crate::ProjectContext::default()),
1966 Path("greeter".to_string()),
1967 Json(RollbackBody { to: "c".repeat(64) }),
1968 )
1969 .await;
1970 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1971
1972 let (st, body) = body_json(
1974 alias_function(
1975 State(deploy.clone()),
1976 axum::extract::Extension(crate::ProjectContext::default()),
1977 Path(("greeter".to_string(), "prod".to_string())),
1978 Json(AliasBody {
1979 version: v2.clone(),
1980 }),
1981 )
1982 .await,
1983 )
1984 .await;
1985 assert_eq!(st, StatusCode::OK);
1986 assert_eq!(body["aliases"]["prod"], v2);
1987
1988 let (st, _) = body_json(
1990 remove_function(
1991 State(deploy.clone()),
1992 axum::extract::Extension(crate::ProjectContext::default()),
1993 Path("greeter".to_string()),
1994 )
1995 .await,
1996 )
1997 .await;
1998 assert_eq!(st, StatusCode::NO_CONTENT);
1999 assert!(deploy
2000 .get_function(ProjectRef::DEFAULT, "greeter")
2001 .await
2002 .unwrap()
2003 .is_none());
2004
2005 let empty = DeployStore::new(
2007 Arc::new(FakeStorage { present: false }),
2008 Arc::new(MemoryKv::new()),
2009 );
2010 let resp = deploy_function(
2011 State(empty),
2012 axum::extract::Extension(crate::ProjectContext::default()),
2013 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2014 axum::extract::Query(DeployFunctionQuery::default()),
2015 Path("orphan".to_string()),
2016 Json(FunctionUpsert {
2017 component: v1.clone(),
2018 config: Default::default(),
2019 lifecycle: Lifecycle::default(),
2020 }),
2021 )
2022 .await;
2023 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2024 }
2025
2026 struct StubControl {
2030 admits: std::sync::Mutex<Vec<(String, String)>>,
2031 respond: StubJoin,
2032 }
2033 #[derive(Clone, Copy)]
2034 enum StubJoin {
2035 Admit,
2036 Spent,
2037 Invalid,
2038 Revoked,
2039 }
2040
2041 #[async_trait::async_trait]
2042 impl MeshControl for StubControl {
2043 async fn admit(
2044 &self,
2045 mesh_pubkey_hex: &str,
2046 jti: &str,
2047 _proof: &[u8],
2048 _proof_iat: u64,
2049 _now: u64,
2050 _advertise_addr: Option<&str>,
2051 ) -> Result<JoinOutcome, String> {
2052 self.admits
2053 .lock()
2054 .unwrap()
2055 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
2056 Ok(match self.respond {
2057 StubJoin::Admit => JoinOutcome::Admitted {
2058 members: vec!["signed-member".to_string()],
2059 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
2060 },
2061 StubJoin::Spent => JoinOutcome::TokenSpent,
2062 StubJoin::Invalid => JoinOutcome::ProofInvalid,
2063 StubJoin::Revoked => JoinOutcome::Revoked,
2064 })
2065 }
2066 async fn rotate_key(&self) -> Result<String, String> {
2067 Ok("cafe".to_string())
2068 }
2069 async fn revoke(&self, _node: u64) -> Result<(), String> {
2070 Ok(())
2071 }
2072 async fn members(&self) -> Result<Vec<MeshMember>, String> {
2073 Ok(Vec::new())
2074 }
2075 async fn promote(&self, _node: u64) -> Result<(), String> {
2076 Ok(())
2077 }
2078 }
2079
2080 #[tokio::test]
2084 async fn cluster_join_dispatches_and_maps_outcomes() {
2085 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2086 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2087 let auth = Auth::with_key(keys.public_key(), kv);
2088 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
2089 let req = |proof: &str| JoinRequest {
2090 token: token.clone(),
2091 mesh_pubkey: "302a300506032b6570032100feed".into(),
2092 possession_proof: proof.to_string(),
2093 proof_iat: now_unix(),
2094 advertise_addr: Some("https://joiner:7000".into()),
2095 };
2096
2097 let admitter = Arc::new(StubControl {
2099 admits: std::sync::Mutex::new(Vec::new()),
2100 respond: StubJoin::Admit,
2101 });
2102 let resp = cluster_join(
2103 Extension(auth.clone()),
2104 Extension(MeshControlHandle(Some(admitter.clone()))),
2105 Json(req("aa01")),
2106 )
2107 .await;
2108 assert_eq!(resp.status(), StatusCode::OK);
2109 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
2110
2111 let spent = Arc::new(StubControl {
2113 admits: std::sync::Mutex::new(Vec::new()),
2114 respond: StubJoin::Spent,
2115 });
2116 assert_eq!(
2117 cluster_join(
2118 Extension(auth.clone()),
2119 Extension(MeshControlHandle(Some(spent))),
2120 Json(req("aa01")),
2121 )
2122 .await
2123 .status(),
2124 StatusCode::CONFLICT
2125 );
2126 let invalid = Arc::new(StubControl {
2127 admits: std::sync::Mutex::new(Vec::new()),
2128 respond: StubJoin::Invalid,
2129 });
2130 assert_eq!(
2131 cluster_join(
2132 Extension(auth.clone()),
2133 Extension(MeshControlHandle(Some(invalid))),
2134 Json(req("aa01")),
2135 )
2136 .await
2137 .status(),
2138 StatusCode::FORBIDDEN
2139 );
2140 let revoked = Arc::new(StubControl {
2142 admits: std::sync::Mutex::new(Vec::new()),
2143 respond: StubJoin::Revoked,
2144 });
2145 assert_eq!(
2146 cluster_join(
2147 Extension(auth.clone()),
2148 Extension(MeshControlHandle(Some(revoked))),
2149 Json(req("aa01")),
2150 )
2151 .await
2152 .status(),
2153 StatusCode::FORBIDDEN
2154 );
2155
2156 let ok = Arc::new(StubControl {
2158 admits: std::sync::Mutex::new(Vec::new()),
2159 respond: StubJoin::Admit,
2160 });
2161 assert_eq!(
2162 cluster_join(
2163 Extension(auth.clone()),
2164 Extension(MeshControlHandle(Some(ok))),
2165 Json(req("not-hex")),
2166 )
2167 .await
2168 .status(),
2169 StatusCode::BAD_REQUEST
2170 );
2171
2172 let none = cluster_join(
2174 Extension(auth),
2175 Extension(MeshControlHandle(None)),
2176 Json(req("aa01")),
2177 )
2178 .await;
2179 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2180 }
2181
2182 #[tokio::test]
2186 async fn bootstrap_mints_the_first_token_once() {
2187 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
2188 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2189 let public = keys.public_key();
2190 let deploy = DeployStore::new(
2191 Arc::new(MemStorage::default()),
2192 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
2193 );
2194 let secret = "s3cr3t-bootstrap-value";
2195 let gate = BootstrapGate::new(Some(secret));
2196 let issuer = Issuer(Some(keys.clone()));
2197 let bearer = |s: &str| {
2198 let mut h = HeaderMap::new();
2199 h.insert(
2200 AUTHORIZATION,
2201 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
2202 );
2203 h
2204 };
2205 let req = || BootstrapRequest {
2206 roles: vec!["admin".to_string()],
2207 ttl_secs: None,
2208 };
2209
2210 let bad = bootstrap_token(
2212 State(deploy.clone()),
2213 Extension(issuer.clone()),
2214 Extension(gate.clone()),
2215 bearer("wrong"),
2216 Json(req()),
2217 )
2218 .await;
2219 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
2220
2221 let ok = bootstrap_token(
2223 State(deploy.clone()),
2224 Extension(issuer.clone()),
2225 Extension(gate.clone()),
2226 bearer(secret),
2227 Json(req()),
2228 )
2229 .await;
2230 assert_eq!(ok.status(), StatusCode::CREATED);
2231 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2232 .await
2233 .unwrap();
2234 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2235 let token = json["token"].as_str().unwrap();
2236 let id = json["id"].as_str().unwrap();
2237 let verified = cose::verify(token, &public, now_unix()).unwrap();
2238 assert!(verified.roles.iter().any(|r| r.name == "admin"));
2239 assert!(deploy
2240 .list_token_meta()
2241 .await
2242 .unwrap()
2243 .iter()
2244 .any(|m| m.revocation_id == id));
2245
2246 let reuse = bootstrap_token(
2248 State(deploy.clone()),
2249 Extension(issuer.clone()),
2250 Extension(gate),
2251 bearer(secret),
2252 Json(req()),
2253 )
2254 .await;
2255 assert_eq!(reuse.status(), StatusCode::CONFLICT);
2256
2257 let disabled = bootstrap_token(
2259 State(deploy),
2260 Extension(issuer),
2261 Extension(BootstrapGate(None)),
2262 bearer(secret),
2263 Json(req()),
2264 )
2265 .await;
2266 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2267 }
2268
2269 #[tokio::test]
2272 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2273 let control = Arc::new(StubControl {
2274 admits: std::sync::Mutex::new(Vec::new()),
2275 respond: StubJoin::Admit,
2276 });
2277 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2278 assert_eq!(resp.status(), StatusCode::OK);
2279 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2280 .await
2281 .unwrap();
2282 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2283 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2284
2285 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2286 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2287 }
2288
2289 #[test]
2290 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2291 use boatramp_core::security::SecurityProfile;
2292 let strict = SecurityProfile::MultiTenant.preset();
2293 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
2297 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2298 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2299
2300 assert!(gateway_addr_allowed(public, &strict));
2302 assert!(!gateway_addr_allowed(private, &strict));
2303 assert!(!gateway_addr_allowed(loopback, &strict));
2304 assert!(!gateway_addr_allowed(metadata, &strict));
2305
2306 assert!(gateway_addr_allowed(public, &loose));
2309 assert!(gateway_addr_allowed(private, &loose));
2310 assert!(gateway_addr_allowed(loopback, &loose));
2311 assert!(!gateway_addr_allowed(metadata, &loose));
2312 }
2313
2314 #[tokio::test]
2315 async fn resolve_env_merges_static_and_host_secrets() {
2316 use boatramp_core::config::HandlersSiteConfig;
2317
2318 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2320
2321 let deploy_env = std::collections::BTreeMap::from([
2322 ("GREETING".to_string(), "hi".to_string()),
2323 ("OVERRIDE_ME".to_string(), "static".to_string()),
2324 ]);
2325 let site_handlers = HandlersSiteConfig {
2326 enabled: true,
2327 secrets: std::collections::BTreeMap::from([
2328 (
2330 "SECRET_TOKEN".to_string(),
2331 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2332 ),
2333 (
2334 "OVERRIDE_ME".to_string(),
2335 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2336 ),
2337 (
2338 "MISSING".to_string(),
2339 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2340 ),
2341 ]),
2342 ..Default::default()
2343 };
2344 let env = resolve_env(
2347 "blog",
2348 boatramp_core::project::ProjectRef::DEFAULT,
2349 &deploy_env,
2350 &site_handlers,
2351 true,
2352 None,
2353 )
2354 .await
2355 .expect("resolves");
2356
2357 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2361 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2362 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2363 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2364
2365 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2366 }
2367
2368 #[tokio::test]
2369 async fn multi_tenant_posture_refuses_a_host_env_handler_secret() {
2370 use boatramp_core::config::HandlersSiteConfig;
2371
2372 std::env::set_var("BOATRAMP_TEST_OTHER_TENANT_SECRET", "leak-me");
2377 let deploy_env = std::collections::BTreeMap::new();
2378 let bare = HandlersSiteConfig {
2379 enabled: true,
2380 secrets: std::collections::BTreeMap::from([(
2381 "STOLEN".to_string(),
2382 "BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2383 )]),
2384 ..Default::default()
2385 };
2386 let err = resolve_env(
2387 "evil",
2388 boatramp_core::project::ProjectRef::DEFAULT,
2389 &deploy_env,
2390 &bare,
2391 false,
2392 None,
2393 )
2394 .await
2395 .expect_err("multi-tenant must refuse a bare host-env ref");
2396 assert!(
2397 err.contains("STOLEN"),
2398 "error names the offending guest var: {err}"
2399 );
2400 assert!(
2401 err.contains("multi-tenant"),
2402 "error steers the tenant: {err}"
2403 );
2404 assert!(
2405 !err.contains("leak-me"),
2406 "the host value must never appear (never read): {err}"
2407 );
2408
2409 let explicit = HandlersSiteConfig {
2411 enabled: true,
2412 secrets: std::collections::BTreeMap::from([(
2413 "STOLEN".to_string(),
2414 "env:BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2415 )]),
2416 ..Default::default()
2417 };
2418 assert!(resolve_env(
2419 "evil",
2420 boatramp_core::project::ProjectRef::DEFAULT,
2421 &deploy_env,
2422 &explicit,
2423 false,
2424 None,
2425 )
2426 .await
2427 .is_err());
2428
2429 let reserved = HandlersSiteConfig {
2431 enabled: true,
2432 secrets: std::collections::BTreeMap::from([(
2433 "TOKEN".to_string(),
2434 "vault:kv/data/app#token".to_string(),
2435 )]),
2436 ..Default::default()
2437 };
2438 let err = resolve_env(
2439 "evil",
2440 boatramp_core::project::ProjectRef::DEFAULT,
2441 &deploy_env,
2442 &reserved,
2443 true,
2444 None,
2445 )
2446 .await
2447 .expect_err("a reserved scheme is not yet supported, even under single-tenant");
2448 assert!(err.contains("not yet supported"), "{err}");
2449
2450 let arbitrary = HandlersSiteConfig {
2454 enabled: true,
2455 secrets: std::collections::BTreeMap::from([(
2456 "KEY".to_string(),
2457 "aws:sm/prod/apikey".to_string(),
2458 )]),
2459 ..Default::default()
2460 };
2461 let err = resolve_env(
2462 "evil",
2463 boatramp_core::project::ProjectRef::DEFAULT,
2464 &deploy_env,
2465 &arbitrary,
2466 true,
2467 None,
2468 )
2469 .await
2470 .expect_err("any unknown scheme is reserved, even under single-tenant");
2471 assert!(
2472 err.contains("not yet supported") && err.contains("aws"),
2473 "provider-neutral reservation names the scheme: {err}"
2474 );
2475
2476 std::env::remove_var("BOATRAMP_TEST_OTHER_TENANT_SECRET");
2477 }
2478
2479 #[tokio::test]
2480 async fn function_resolve_secret_env_reads_host_and_matches_handler_semantics() {
2481 std::env::set_var("BOATRAMP_TEST_FN_SECRET", "fnsecret");
2486
2487 let static_env = std::collections::BTreeMap::from([
2488 ("STAGE".to_string(), "prod".to_string()),
2489 ("OVERRIDE_ME".to_string(), "static".to_string()),
2490 ]);
2491 let secrets = std::collections::BTreeMap::from([
2492 ("DB_URL".to_string(), "BOATRAMP_TEST_FN_SECRET".to_string()),
2494 (
2496 "OVERRIDE_ME".to_string(),
2497 "BOATRAMP_TEST_FN_SECRET".to_string(),
2498 ),
2499 (
2501 "MISSING".to_string(),
2502 "BOATRAMP_TEST_FN_NOT_SET".to_string(),
2503 ),
2504 ]);
2505 let env = resolve_secret_env(
2507 "fn/api",
2508 boatramp_core::project::ProjectRef::DEFAULT,
2509 &static_env,
2510 &secrets,
2511 true,
2512 None,
2513 )
2514 .await
2515 .expect("resolves");
2516
2517 assert!(env.contains(&("STAGE".to_string(), "prod".to_string())));
2518 assert!(env.contains(&("DB_URL".to_string(), "fnsecret".to_string())));
2520 assert!(env.contains(&("OVERRIDE_ME".to_string(), "fnsecret".to_string())));
2522 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2524
2525 std::env::remove_var("BOATRAMP_TEST_FN_SECRET");
2526 }
2527
2528 #[tokio::test]
2529 async fn multi_tenant_posture_refuses_a_host_env_function_secret() {
2530 std::env::set_var("BOATRAMP_TEST_FN_LEAK", "leak-me");
2535 let static_env = std::collections::BTreeMap::new();
2536 let secrets = std::collections::BTreeMap::from([(
2537 "DB_URL".to_string(),
2538 "BOATRAMP_TEST_FN_LEAK".to_string(),
2539 )]);
2540
2541 let err = resolve_secret_env(
2543 "fn/api",
2544 boatramp_core::project::ProjectRef::DEFAULT,
2545 &static_env,
2546 &secrets,
2547 false,
2548 None,
2549 )
2550 .await
2551 .expect_err("multi-tenant must refuse a function host-env ref");
2552 assert!(
2553 err.contains("DB_URL"),
2554 "error names the offending guest var: {err}"
2555 );
2556 assert!(
2557 !err.contains("leak-me"),
2558 "host value must never appear: {err}"
2559 );
2560
2561 let env = resolve_secret_env(
2563 "fn/api",
2564 boatramp_core::project::ProjectRef::DEFAULT,
2565 &static_env,
2566 &secrets,
2567 true,
2568 None,
2569 )
2570 .await
2571 .expect("resolves");
2572 assert!(env.contains(&("DB_URL".to_string(), "leak-me".to_string())));
2573
2574 std::env::remove_var("BOATRAMP_TEST_FN_LEAK");
2575 }
2576
2577 #[tokio::test]
2578 async fn boatramp_scheme_resolves_from_the_project_scoped_store() {
2579 use boatramp_core::project::ProjectRef;
2580 use boatramp_core::secret_store::SecretStore;
2581 use std::sync::Arc;
2582
2583 struct XorEnvelope;
2585 #[async_trait::async_trait]
2586 impl boatramp_core::envelope::KeyEnvelope for XorEnvelope {
2587 async fn wrap(
2588 &self,
2589 p: &[u8],
2590 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2591 Ok(p.iter().map(|b| b ^ 0x5a).collect())
2592 }
2593 async fn unwrap(
2594 &self,
2595 c: &[u8],
2596 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2597 Ok(c.iter().map(|b| b ^ 0x5a).collect())
2598 }
2599 }
2600
2601 let store = SecretStore::new(
2602 Arc::new(boatramp_core::kv::MemoryKv::new()),
2603 Arc::new(XorEnvelope),
2604 );
2605 store
2606 .set(ProjectRef::new("acme"), "api-key", b"s3cr3t")
2607 .await
2608 .unwrap();
2609
2610 let static_env = std::collections::BTreeMap::new();
2611 let secrets = std::collections::BTreeMap::from([
2612 ("API_KEY".to_string(), "boatramp:api-key".to_string()),
2613 ("MISSING".to_string(), "boatramp:not-set".to_string()),
2614 ]);
2615
2616 let env = resolve_secret_env(
2619 "site",
2620 ProjectRef::new("acme"),
2621 &static_env,
2622 &secrets,
2623 false,
2624 Some(&store),
2625 )
2626 .await
2627 .expect("boatramp refs resolve without the host-env gate");
2628 assert!(env.contains(&("API_KEY".to_string(), "s3cr3t".to_string())));
2629 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2631
2632 let other_secrets = std::collections::BTreeMap::from([(
2634 "API_KEY".to_string(),
2635 "boatramp:api-key".to_string(),
2636 )]);
2637 let other = resolve_secret_env(
2638 "site",
2639 ProjectRef::new("globex"),
2640 &static_env,
2641 &other_secrets,
2642 false,
2643 Some(&store),
2644 )
2645 .await
2646 .expect("resolves (a foreign project's secret is simply absent → skipped)");
2647 assert!(
2648 !other.iter().any(|(k, _)| k == "API_KEY"),
2649 "a tenant must not read another project's secret"
2650 );
2651
2652 let err = resolve_secret_env(
2654 "site",
2655 ProjectRef::new("acme"),
2656 &static_env,
2657 &other_secrets,
2658 false,
2659 None,
2660 )
2661 .await
2662 .expect_err("no store configured must fail closed");
2663 assert!(err.contains("no internal secret store"), "{err}");
2664 }
2665
2666 fn req() -> Request {
2667 Request::builder()
2668 .uri("/")
2669 .header(header::HOST, "example.com")
2670 .body(Body::empty())
2671 .unwrap()
2672 }
2673
2674 #[test]
2675 fn forwarded_headers_set_standard_triple() {
2676 let mut request = req();
2677 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2678 let h = request.headers();
2679 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2680 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2681 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2682 }
2683
2684 #[test]
2685 fn forwarded_for_overwrites_spoofed_value() {
2686 let mut request = Request::builder()
2689 .uri("/")
2690 .header(header::HOST, "example.com")
2691 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2692 .body(Body::empty())
2693 .unwrap();
2694 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2695 let values: Vec<_> = request
2696 .headers()
2697 .get_all("x-forwarded-for")
2698 .iter()
2699 .collect();
2700 assert_eq!(values.len(), 1);
2701 assert_eq!(values[0], "203.0.113.7");
2702 }
2703
2704 #[test]
2705 fn forwarded_proto_preserves_upstream_tls() {
2706 let mut request = Request::builder()
2708 .uri("/")
2709 .header(header::HOST, "example.com")
2710 .header("x-forwarded-proto", "https")
2711 .body(Body::empty())
2712 .unwrap();
2713 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2714 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2715 }
2716
2717 #[test]
2718 fn forwarded_host_absent_when_no_host_header() {
2719 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2720 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2721 assert!(request.headers().get("x-forwarded-host").is_none());
2722 assert_eq!(
2723 request.headers().get("x-forwarded-for").unwrap(),
2724 "203.0.113.7"
2725 );
2726 }
2727
2728 use boatramp_core::kv::{KvStore, MemoryKv};
2731 use boatramp_core::messaging::{LogMessaging, Messaging};
2732 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2733
2734 const EVENT_CONSUMER: &[u8] =
2735 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2736
2737 #[derive(Default)]
2738 struct MemStorage {
2739 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2740 }
2741
2742 #[async_trait::async_trait]
2743 impl boatramp_core::Storage for MemStorage {
2744 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2745 let bytes = self
2746 .objects
2747 .lock()
2748 .unwrap()
2749 .get(key)
2750 .cloned()
2751 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2752 let body: ByteStream =
2753 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2754 Ok(GetObject {
2755 meta: ObjectMeta {
2756 key: key.to_string(),
2757 ..Default::default()
2758 },
2759 body,
2760 })
2761 }
2762 async fn get_range(
2763 &self,
2764 key: &str,
2765 _: u64,
2766 _: Option<u64>,
2767 ) -> Result<GetObject, StorageError> {
2768 self.get(key).await
2769 }
2770 async fn put(
2771 &self,
2772 key: &str,
2773 mut body: ByteStream,
2774 _: PutMeta,
2775 ) -> Result<ObjectMeta, StorageError> {
2776 use futures::StreamExt;
2777 let mut buf = Vec::new();
2778 while let Some(chunk) = body.next().await {
2779 buf.extend_from_slice(&chunk?);
2780 }
2781 self.objects.lock().unwrap().insert(key.to_string(), buf);
2782 Ok(ObjectMeta {
2783 key: key.to_string(),
2784 ..Default::default()
2785 })
2786 }
2787 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2788 self.objects
2789 .lock()
2790 .unwrap()
2791 .get(key)
2792 .map(|_| ObjectMeta {
2793 key: key.to_string(),
2794 ..Default::default()
2795 })
2796 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2797 }
2798 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2799 self.objects.lock().unwrap().remove(key);
2800 Ok(())
2801 }
2802 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2803 Ok(Vec::new())
2804 }
2805 }
2806
2807 fn observed_state_in(
2810 project: &str,
2811 workload: &str,
2812 host: &str,
2813 healthy: bool,
2814 phase: boatramp_core::compute::ReplicaPhase,
2815 ) -> boatramp_core::compute::ObservedInstance {
2816 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2817 boatramp_core::compute::ObservedInstance {
2818 handle: InstanceHandle {
2819 project: project.into(),
2820 workload: workload.into(),
2821 replica: 0,
2822 backend_ref: "ref-0".into(),
2823 },
2824 node: 1,
2825 backend: "vmm".into(),
2826 endpoint: Endpoint {
2827 scheme: Scheme::Http,
2828 host: host.into(),
2829 port: 80,
2830 },
2831 region: None,
2832 healthy,
2833 started_at: None,
2834 phase,
2835 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2836 project: project.into(),
2837 workload: workload.into(),
2838 replica: 0,
2839 data_ref: "snap-0".into(),
2840 }),
2841 }
2842 }
2843
2844 fn observed_state(
2846 workload: &str,
2847 healthy: bool,
2848 phase: boatramp_core::compute::ReplicaPhase,
2849 ) -> boatramp_core::compute::ObservedInstance {
2850 observed_state_in("default", workload, "10.0.0.2", healthy, phase)
2851 }
2852
2853 #[tokio::test]
2854 async fn has_parked_replica_detects_a_zeroed_replica() {
2855 use boatramp_core::compute::ReplicaPhase;
2856 let storage = Arc::new(MemStorage::default());
2857 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2858 let deploy = DeployStore::new(storage, kv);
2859
2860 assert!(!has_parked_replica(&deploy, "default", "w").await);
2862 deploy
2864 .set_replica_state(
2865 ProjectRef::DEFAULT,
2866 &observed_state("w", true, ReplicaPhase::Running),
2867 )
2868 .await
2869 .unwrap();
2870 assert!(!has_parked_replica(&deploy, "default", "w").await);
2871 deploy
2873 .set_replica_state(
2874 ProjectRef::DEFAULT,
2875 &observed_state("w", false, ReplicaPhase::Zero),
2876 )
2877 .await
2878 .unwrap();
2879 assert!(has_parked_replica(&deploy, "default", "w").await);
2880 }
2881
2882 #[tokio::test]
2883 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2884 use boatramp_core::compute::ReplicaPhase;
2885 let storage = Arc::new(MemStorage::default());
2886 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2887 let deploy = DeployStore::new(storage, kv);
2888
2889 let empty = await_warm(
2891 &deploy,
2892 "default",
2893 "w",
2894 std::time::Duration::from_millis(150),
2895 )
2896 .await;
2897 assert!(empty.is_empty());
2898
2899 deploy
2901 .set_replica_state(
2902 ProjectRef::DEFAULT,
2903 &observed_state("w", true, ReplicaPhase::Running),
2904 )
2905 .await
2906 .unwrap();
2907 let warm = await_warm(&deploy, "default", "w", std::time::Duration::from_secs(5)).await;
2908 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2909 }
2910
2911 #[tokio::test]
2917 async fn compute_endpoints_are_project_scoped() {
2918 use boatramp_core::compute::ReplicaPhase;
2919 let storage = Arc::new(MemStorage::default());
2920 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2921 let deploy = DeployStore::new(storage, kv);
2922
2923 deploy
2925 .set_replica_state(
2926 ProjectRef::new("acme"),
2927 &observed_state_in("acme", "web", "10.0.0.5", true, ReplicaPhase::Running),
2928 )
2929 .await
2930 .unwrap();
2931 deploy
2932 .set_replica_state(
2933 ProjectRef::DEFAULT,
2934 &observed_state_in("default", "web", "10.0.0.9", true, ReplicaPhase::Running),
2935 )
2936 .await
2937 .unwrap();
2938
2939 assert_eq!(
2941 compute_endpoints(&deploy, "acme", "web").await,
2942 vec!["http://10.0.0.5:80".to_string()],
2943 "acme's web resolves against acme, not default"
2944 );
2945 assert_eq!(
2947 compute_endpoints(&deploy, "default", "web").await,
2948 vec!["http://10.0.0.9:80".to_string()]
2949 );
2950 assert!(compute_endpoints(&deploy, "beta", "web").await.is_empty());
2953 }
2954
2955 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2959 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2960 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2961 let storage = Arc::new(MemStorage::default());
2962 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2963 let mq = LogMessaging::new(storage, kv.clone());
2964 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2965 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2966 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2967 let topic = "blog/orders/created";
2968
2969 for _ in 0..3 {
2971 mq.publish(topic, b"ok").await.unwrap();
2972 }
2973 loop {
2974 let acked = dispatch_consumer_batch(
2975 &engine,
2976 &mq,
2977 &metrics::Metrics::default(),
2978 "blog",
2979 topic,
2980 "blog/",
2981 "",
2982 boatramp_core::messaging::StartPosition::Latest,
2983 &hash,
2984 EVENT_CONSUMER,
2985 &bindings,
2986 Limits::default(),
2987 Duration::from_secs(30),
2988 5,
2989 10,
2990 )
2991 .await;
2992 if acked == 0 {
2993 break;
2994 }
2995 }
2996 assert_eq!(
2997 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2998 Some(b"3".to_vec())
2999 );
3000
3001 mq.publish(topic, b"fail").await.unwrap();
3004 for _ in 0..5 {
3005 dispatch_consumer_batch(
3006 &engine,
3007 &mq,
3008 &metrics::Metrics::default(),
3009 "blog",
3010 topic,
3011 "blog/",
3012 "",
3013 boatramp_core::messaging::StartPosition::Latest,
3014 &hash,
3015 EVENT_CONSUMER,
3016 &bindings,
3017 Limits::default(),
3018 Duration::ZERO,
3019 2,
3020 10,
3021 )
3022 .await;
3023 }
3024 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
3025 assert_eq!(
3027 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3028 Some(b"3".to_vec())
3029 );
3030 }
3031
3032 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3037 async fn consumer_groups_fan_out_through_the_dispatcher() {
3038 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3039 let storage = Arc::new(MemStorage::default());
3040 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3041 let mq = LogMessaging::new(storage, kv.clone());
3042 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3043 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3044 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3045 let topic = "blog/orders/created";
3046 let start = boatramp_core::messaging::StartPosition::Latest;
3047
3048 for g in ["billing", "audit"] {
3051 let n = dispatch_consumer_batch(
3052 &engine,
3053 &mq,
3054 &metrics::Metrics::default(),
3055 "blog",
3056 topic,
3057 "blog/",
3058 g,
3059 start,
3060 &hash,
3061 EVENT_CONSUMER,
3062 &bindings,
3063 Limits::default(),
3064 Duration::from_secs(30),
3065 5,
3066 10,
3067 )
3068 .await;
3069 assert_eq!(n, 0, "no events yet for group {g}");
3070 }
3071 mq.publish(topic, b"ok").await.unwrap();
3072
3073 for g in ["billing", "audit"] {
3075 let n = dispatch_consumer_batch(
3076 &engine,
3077 &mq,
3078 &metrics::Metrics::default(),
3079 "blog",
3080 topic,
3081 "blog/",
3082 g,
3083 start,
3084 &hash,
3085 EVENT_CONSUMER,
3086 &bindings,
3087 Limits::default(),
3088 Duration::from_secs(30),
3089 5,
3090 10,
3091 )
3092 .await;
3093 assert_eq!(n, 1, "group {g} should receive the message");
3094 }
3095 assert_eq!(
3097 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3098 Some(b"2".to_vec())
3099 );
3100 }
3101
3102 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3106 async fn scheduler_runs_current_consumers_not_previews() {
3107 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
3108 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3109 use boatramp_handlers::{HandlerEngine, Limits};
3110 use futures::StreamExt;
3111
3112 let storage = Arc::new(MemStorage::default());
3113 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3114 let deploy = DeployStore::new(storage.clone(), kv.clone());
3115 let messaging: Arc<dyn Messaging> =
3116 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
3117
3118 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3120 let stream: ByteStream =
3121 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
3122 .boxed();
3123 deploy.put_blob(&hash, stream).await.unwrap();
3124 let mut files = std::collections::BTreeMap::new();
3125 files.insert(
3126 "consumer.wasm".to_string(),
3127 FileEntry {
3128 hash: hash.clone(),
3129 size: EVENT_CONSUMER.len() as u64,
3130 content_type: None,
3131 variants: std::collections::BTreeMap::new(),
3132 },
3133 );
3134 let manifest = Manifest {
3135 files,
3136 config: DeployConfig {
3137 consumers: vec![ConsumerConfig {
3138 topic: "orders/created".into(),
3139 component: "consumer.wasm".into(),
3140 imports: vec!["wasi:keyvalue".into()],
3141 group: String::new(),
3142 start: Default::default(),
3143 }],
3144 ..Default::default()
3145 },
3146 ..Default::default()
3147 };
3148 let id = deploy.put_manifest(&manifest).await.unwrap();
3149 deploy
3150 .activate(ProjectRef::DEFAULT, "blog", &id)
3151 .await
3152 .unwrap();
3153 deploy
3154 .set_site_config(
3155 ProjectRef::DEFAULT,
3156 "blog",
3157 &SiteConfig {
3158 handlers: Some(HandlersSiteConfig {
3159 enabled: true,
3160 allow_imports: vec!["wasi:keyvalue".into()],
3161 ..Default::default()
3162 }),
3163 ..Default::default()
3164 },
3165 )
3166 .await
3167 .unwrap();
3168
3169 messaging
3171 .publish("blog/orders/created", b"live")
3172 .await
3173 .unwrap();
3174 messaging
3175 .publish("blog/_preview/abc/orders/created", b"preview")
3176 .await
3177 .unwrap();
3178
3179 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3180 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
3181 let inner = rt.inner.clone().unwrap();
3182 let mut cache = std::collections::HashMap::new();
3183 let mut crons = std::collections::HashMap::new();
3184 let mut sweep = std::collections::HashMap::new();
3185 let now = CronNow {
3186 minute: 0,
3187 hour: 0,
3188 dom: 1,
3189 month: 1,
3190 dow: 0,
3191 minute_stamp: 0,
3192 };
3193 for _ in 0..3 {
3194 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
3195 .await
3196 .unwrap();
3197 }
3198
3199 assert_eq!(
3201 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3202 Some(b"1".to_vec())
3203 );
3204 assert_eq!(
3207 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
3208 .await
3209 .unwrap(),
3210 None
3211 );
3212 }
3213
3214 const KV_COUNTER: &[u8] =
3219 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
3220
3221 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3226 async fn function_invoker_runs_target_buffers_and_meters() {
3227 use boatramp_core::deploy::DeployStore;
3228 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
3229 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
3230 use futures::StreamExt;
3231
3232 const HTTP_200: &[u8] =
3235 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3236
3237 let storage = Arc::new(MemStorage::default());
3238 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3239 let deploy = DeployStore::new(storage.clone(), kv.clone());
3240
3241 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3242 let stream: ByteStream =
3243 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3244 deploy.put_blob(&hash, stream).await.unwrap();
3245 let function = Function {
3246 name: "target".into(),
3247 owner: Owner::Project("default".into()),
3248 versions: vec![FunctionVersion {
3249 id: "v1".into(),
3250 component: hash.clone(),
3251 created: 0,
3252 lifecycle: Lifecycle::Independent,
3253 }],
3254 active: "v1".into(),
3255 aliases: Default::default(),
3256 config: Default::default(),
3257 };
3258 deploy
3259 .put_function(ProjectRef::DEFAULT, &function)
3260 .await
3261 .unwrap();
3262
3263 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3264 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
3265 rt.set_invoker(deploy.clone());
3266 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
3267
3268 let request = || InvokeRequest {
3269 method: "GET".into(),
3270 path: "/".into(),
3271 headers: vec![],
3272 body: vec![],
3273 };
3274
3275 let response = invoker.invoke("target", request(), 1).await.unwrap();
3277 assert_eq!(response.status, 200);
3278
3279 let metering = deploy
3281 .get_metering(ProjectRef::DEFAULT, "target")
3282 .await
3283 .unwrap()
3284 .unwrap();
3285 assert_eq!(metering.invocations, 1);
3286
3287 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
3289 assert!(matches!(err, InvokeError::NotFound));
3290 }
3291
3292 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3298 async fn federation_runner_enforces_the_safelist_before_planning() {
3299 use boatramp_core::deploy::DeployStore;
3300 use boatramp_core::project::ProjectRef;
3301 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
3302
3303 let storage = Arc::new(MemStorage::default());
3304 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3305 let deploy = DeployStore::new(storage.clone(), kv.clone());
3306 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3307 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3308 rt.set_invoker(deploy.clone());
3309 let runner = rt
3310 .inner
3311 .as_ref()
3312 .unwrap()
3313 .federation_runner
3314 .get()
3315 .unwrap()
3316 .scoped(ProjectRef::new("default"));
3317
3318 let req = |query: &str| GraphqlRequest {
3319 query: Some(query.to_string()),
3320 persisted_hash: None,
3321 variables: "{}".to_string(),
3322 operation_name: None,
3323 authorization: None,
3324 };
3325
3326 assert!(matches!(
3328 runner.run(req("{ me { id } }"), 1).await,
3329 Err(SupergraphRunError::NotSafelisted)
3330 ));
3331
3332 let query = "{ me { id } }";
3335 let hash = crate::graphql_apq::sha256_hex(query);
3336 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
3337 .await
3338 .unwrap();
3339 assert!(matches!(
3340 runner.run(req(query), 1).await,
3341 Err(SupergraphRunError::PlanFailed(_))
3342 ));
3343
3344 let persisted = GraphqlRequest {
3346 query: None,
3347 persisted_hash: Some("deadbeef".to_string()),
3348 variables: "{}".to_string(),
3349 operation_name: None,
3350 authorization: None,
3351 };
3352 assert!(matches!(
3353 runner.run(persisted, 1).await,
3354 Err(SupergraphRunError::NotSafelisted)
3355 ));
3356 }
3357
3358 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3364 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
3365 use crate::scheduler::{run_scheduler_tick, CronNow};
3366 use boatramp_core::deploy::DeployStore;
3367 use boatramp_core::function::{
3368 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3369 };
3370 use boatramp_handlers::{HandlerEngine, Limits};
3371 use futures::StreamExt;
3372
3373 const HTTP_200: &[u8] =
3374 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3375
3376 let storage = Arc::new(MemStorage::default());
3377 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3378 let deploy = DeployStore::new(storage.clone(), kv.clone());
3379
3380 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3381 let stream: ByteStream =
3382 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3383 deploy.put_blob(&hash, stream).await.unwrap();
3384
3385 let acme = ProjectRef::new("acme");
3387 let function = Function {
3388 name: "worker".into(),
3389 owner: Owner::Project("acme".into()),
3390 versions: vec![FunctionVersion {
3391 id: "v1".into(),
3392 component: hash.clone(),
3393 created: 0,
3394 lifecycle: Lifecycle::Independent,
3395 }],
3396 active: "v1".into(),
3397 aliases: Default::default(),
3398 config: Default::default(),
3399 };
3400 deploy.put_function(acme, &function).await.unwrap();
3401 let inv = Invocation {
3402 id: "inv1".into(),
3403 function: "worker".into(),
3404 version: "v1".into(),
3405 mode: InvokeMode::Async,
3406 status: InvocationStatus::Queued,
3407 idempotency_key: None,
3408 attempts: 0,
3409 lease_expires: None,
3410 request_b64: None,
3411 request_content_type: None,
3412 result: None,
3413 created: 0,
3414 updated: 0,
3415 };
3416 deploy.put_invocation(acme, &inv).await.unwrap();
3417
3418 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3419 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3420 let inner = rt.inner.as_ref().unwrap();
3421
3422 let mut wasm_cache = std::collections::HashMap::new();
3425 let mut cron_state = std::collections::HashMap::new();
3426 let mut sweep = std::collections::HashMap::new();
3427 let now = CronNow {
3428 minute: 0,
3429 hour: 0,
3430 dom: 1,
3431 month: 1,
3432 dow: 0,
3433 minute_stamp: 0,
3434 };
3435 run_scheduler_tick(
3436 inner,
3437 &deploy,
3438 &mut wasm_cache,
3439 &mut cron_state,
3440 &mut sweep,
3441 now,
3442 )
3443 .await
3444 .unwrap();
3445
3446 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
3449 assert_eq!(settled.status, InvocationStatus::Succeeded);
3451 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
3453 assert_eq!(metering.invocations, 1);
3454 assert!(deploy
3456 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
3457 .await
3458 .unwrap()
3459 .is_none());
3460 assert!(deploy
3461 .get_metering(ProjectRef::DEFAULT, "worker")
3462 .await
3463 .unwrap()
3464 .is_none());
3465 }
3466
3467 #[cfg(feature = "handlers")]
3471 async fn poll_invocation_settled(
3472 deploy: &boatramp_core::deploy::DeployStore,
3473 project: ProjectRef<'_>,
3474 function: &str,
3475 id: &str,
3476 ) -> boatramp_core::function::Invocation {
3477 use boatramp_core::function::InvocationStatus;
3478 for _ in 0..200 {
3479 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
3480 if matches!(
3481 inv.status,
3482 InvocationStatus::Succeeded | InvocationStatus::Failed
3483 ) {
3484 return inv;
3485 }
3486 }
3487 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3488 }
3489 panic!("invocation {function}/{id} never settled");
3490 }
3491
3492 #[cfg(feature = "handlers")]
3497 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3498 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
3499 use crate::scheduler::{run_scheduler_tick, CronNow};
3500 use boatramp_core::deploy::DeployStore;
3501 use boatramp_core::function::{
3502 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3503 };
3504 use boatramp_handlers::{HandlerEngine, Limits};
3505 use futures::StreamExt;
3506
3507 const HTTP_200: &[u8] =
3508 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3509
3510 let storage = Arc::new(MemStorage::default());
3511 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3512 let deploy = DeployStore::new(storage.clone(), kv.clone());
3513 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3514 let stream: ByteStream =
3515 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3516 deploy.put_blob(&hash, stream).await.unwrap();
3517
3518 let function = Function {
3519 name: "worker".into(),
3520 owner: Owner::Project("default".into()),
3521 versions: vec![FunctionVersion {
3522 id: "v1".into(),
3523 component: hash.clone(),
3524 created: 0,
3525 lifecycle: Lifecycle::Independent,
3526 }],
3527 active: "v1".into(),
3528 aliases: Default::default(),
3529 config: Default::default(),
3530 };
3531 deploy
3532 .put_function(ProjectRef::DEFAULT, &function)
3533 .await
3534 .unwrap();
3535
3536 let base = Invocation {
3539 id: String::new(),
3540 function: "worker".into(),
3541 version: "v1".into(),
3542 mode: InvokeMode::Async,
3543 status: InvocationStatus::Running,
3544 idempotency_key: None,
3545 attempts: 1,
3546 lease_expires: None,
3547 request_b64: None,
3548 request_content_type: None,
3549 result: None,
3550 created: 0,
3551 updated: 0,
3552 };
3553 let orphan = Invocation {
3554 id: "orphan".into(),
3555 lease_expires: Some(1),
3556 ..base.clone()
3557 };
3558 deploy
3559 .put_invocation(ProjectRef::DEFAULT, &orphan)
3560 .await
3561 .unwrap();
3562 let live = Invocation {
3563 id: "live".into(),
3564 lease_expires: Some(u64::MAX),
3565 ..base.clone()
3566 };
3567 deploy
3568 .put_invocation(ProjectRef::DEFAULT, &live)
3569 .await
3570 .unwrap();
3571
3572 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3573 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3574 let inner = rt.inner.as_ref().unwrap();
3575
3576 let now = CronNow {
3577 minute: 0,
3578 hour: 0,
3579 dom: 1,
3580 month: 1,
3581 dow: 0,
3582 minute_stamp: 0,
3583 };
3584 let mut wasm_cache = std::collections::HashMap::new();
3585 let mut cron_state = std::collections::HashMap::new();
3586 let mut sweep = std::collections::HashMap::new();
3587 run_scheduler_tick(
3588 inner,
3589 &deploy,
3590 &mut wasm_cache,
3591 &mut cron_state,
3592 &mut sweep,
3593 now,
3594 )
3595 .await
3596 .unwrap();
3597
3598 let settled =
3600 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
3601 assert_eq!(settled.status, InvocationStatus::Succeeded);
3602 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
3603 assert_eq!(
3604 settled.lease_expires, None,
3605 "a settled invocation drops its lease"
3606 );
3607 let live_after = deploy
3609 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
3610 .await
3611 .unwrap()
3612 .unwrap();
3613 assert_eq!(live_after.status, InvocationStatus::Running);
3614 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
3615 assert_eq!(live_after.lease_expires, Some(u64::MAX));
3616 }
3617
3618 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3633 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
3634 use boatramp_core::deploy::DeployStore;
3635 use boatramp_core::function::{
3636 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3637 };
3638 use boatramp_handlers::{HandlerEngine, Limits};
3639 use futures::StreamExt;
3640
3641 let storage = Arc::new(MemStorage::default());
3642 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3643 let deploy = DeployStore::new(storage.clone(), kv.clone());
3644
3645 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3648 let stream: ByteStream =
3649 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3650 deploy.put_blob(&hash, stream).await.unwrap();
3651
3652 let store = Function {
3657 name: "store".into(),
3658 owner: Owner::Project("default".into()),
3659 versions: vec![FunctionVersion {
3660 id: "v1".into(),
3661 component: hash.clone(),
3662 created: 0,
3663 lifecycle: Lifecycle::Independent,
3664 }],
3665 active: "v1".into(),
3666 aliases: Default::default(),
3667 config: FunctionConfig {
3668 imports: vec!["wasi:keyvalue".into()],
3669 ..Default::default()
3670 },
3671 };
3672 let acme = ProjectRef::new("acme");
3673 let globex = ProjectRef::new("globex");
3674
3675 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3676 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3677 let inner = rt.inner.as_ref().unwrap();
3678
3679 let request = || {
3680 axum::http::Request::builder()
3681 .method("GET")
3682 .uri("/")
3683 .body(axum::body::Body::empty())
3684 .unwrap()
3685 };
3686
3687 let component = store.resolve(&store.active).unwrap().to_owned();
3690 for project in [acme, globex, ProjectRef::DEFAULT] {
3691 let (response, _) = execute_function(
3692 inner,
3693 &deploy,
3694 project,
3695 &store,
3696 &component,
3697 request(),
3698 0,
3699 boatramp_handlers::Lane::Sync,
3700 crate::function_runtime::FnTenant::Request,
3701 )
3702 .await;
3703 assert!(response.status().is_success(), "invocation should succeed");
3704 }
3705
3706 assert_eq!(
3709 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3710 Some(b"1".to_vec()),
3711 "acme's write must be tenant-qualified"
3712 );
3713 assert_eq!(
3714 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3715 Some(b"1".to_vec()),
3716 "globex's write must be tenant-qualified"
3717 );
3718 assert_eq!(
3719 kv.get("hkv/fn/store/hits").await.unwrap(),
3720 Some(b"1".to_vec()),
3721 "the default project must keep the byte-identical pre-project key"
3722 );
3723 }
3726
3727 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3731 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3732 use boatramp_core::config::{
3733 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3734 };
3735 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3736 use boatramp_handlers::{HandlerEngine, Limits};
3737 use futures::StreamExt;
3738 use std::sync::atomic::Ordering;
3739
3740 let storage = Arc::new(MemStorage::default());
3741 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3742 let deploy = DeployStore::new(storage.clone(), kv.clone());
3743
3744 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3745 let stream: ByteStream =
3746 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3747 deploy.put_blob(&hash, stream).await.unwrap();
3748 let mut files = std::collections::BTreeMap::new();
3749 files.insert(
3750 "counter.wasm".to_string(),
3751 FileEntry {
3752 hash: hash.clone(),
3753 size: KV_COUNTER.len() as u64,
3754 content_type: None,
3755 variants: std::collections::BTreeMap::new(),
3756 },
3757 );
3758 let manifest = Manifest {
3759 files,
3760 config: DeployConfig {
3761 handlers: vec![HandlerConfig {
3762 route: "/".into(),
3763 methods: Vec::new(),
3764 component: "counter.wasm".into(),
3765 imports: vec!["wasi:keyvalue".into()],
3766 streaming: false,
3767 limits: None,
3768 env: std::collections::BTreeMap::new(),
3769 invoke_targets: Vec::new(),
3770 }],
3771 crons: vec![CronConfig {
3772 schedule: "* * * * *".into(),
3773 route: "/".into(),
3774 overlap: Overlap::Skip,
3775 }],
3776 ..Default::default()
3777 },
3778 ..Default::default()
3779 };
3780 let id = deploy.put_manifest(&manifest).await.unwrap();
3781 deploy
3782 .activate(ProjectRef::DEFAULT, "blog", &id)
3783 .await
3784 .unwrap();
3785 deploy
3786 .set_site_config(
3787 ProjectRef::DEFAULT,
3788 "blog",
3789 &SiteConfig {
3790 handlers: Some(HandlersSiteConfig {
3791 enabled: true,
3792 allow_imports: vec!["wasi:keyvalue".into()],
3793 ..Default::default()
3794 }),
3795 ..Default::default()
3796 },
3797 )
3798 .await
3799 .unwrap();
3800
3801 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3802 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3803 let inner = rt.inner.clone().unwrap();
3804 let mut wasm = std::collections::HashMap::new();
3805 let mut crons = std::collections::HashMap::new();
3806 let mut sweep = std::collections::HashMap::new();
3807 let at = |stamp| CronNow {
3808 minute: 0,
3809 hour: 0,
3810 dom: 1,
3811 month: 1,
3812 dow: 0,
3813 minute_stamp: stamp,
3814 };
3815
3816 let (_, handles) =
3818 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3819 .await
3820 .unwrap();
3821 for h in handles {
3822 h.await.unwrap();
3823 }
3824 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3825
3826 let (_, handles) =
3828 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3829 .await
3830 .unwrap();
3831 assert!(handles.is_empty());
3832 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3833
3834 let (_, handles) =
3836 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3837 .await
3838 .unwrap();
3839 for h in handles {
3840 h.await.unwrap();
3841 }
3842 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3843
3844 crons
3848 .get("default|blog|cron|0")
3849 .unwrap()
3850 .running
3851 .store(true, Ordering::Release);
3852 let (_, handles) =
3853 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3854 .await
3855 .unwrap();
3856 assert!(handles.is_empty());
3857 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3858 }
3859
3860 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3864 async fn cron_leader_gate_suppresses_crons_off_leader() {
3865 use boatramp_core::config::{
3866 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3867 };
3868 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3869 use boatramp_handlers::{HandlerEngine, Limits};
3870 use futures::StreamExt;
3871
3872 let storage = Arc::new(MemStorage::default());
3873 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3874 let deploy = DeployStore::new(storage.clone(), kv.clone());
3875
3876 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3877 let stream: ByteStream =
3878 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3879 deploy.put_blob(&hash, stream).await.unwrap();
3880 let mut files = std::collections::BTreeMap::new();
3881 files.insert(
3882 "counter.wasm".to_string(),
3883 FileEntry {
3884 hash: hash.clone(),
3885 size: KV_COUNTER.len() as u64,
3886 content_type: None,
3887 variants: std::collections::BTreeMap::new(),
3888 },
3889 );
3890 let manifest = Manifest {
3891 files,
3892 config: DeployConfig {
3893 handlers: vec![HandlerConfig {
3894 route: "/".into(),
3895 methods: Vec::new(),
3896 component: "counter.wasm".into(),
3897 imports: vec!["wasi:keyvalue".into()],
3898 streaming: false,
3899 limits: None,
3900 env: std::collections::BTreeMap::new(),
3901 invoke_targets: Vec::new(),
3902 }],
3903 crons: vec![CronConfig {
3904 schedule: "* * * * *".into(),
3905 route: "/".into(),
3906 overlap: Overlap::Skip,
3907 }],
3908 ..Default::default()
3909 },
3910 ..Default::default()
3911 };
3912 let id = deploy.put_manifest(&manifest).await.unwrap();
3913 deploy
3914 .activate(ProjectRef::DEFAULT, "blog", &id)
3915 .await
3916 .unwrap();
3917 deploy
3918 .set_site_config(
3919 ProjectRef::DEFAULT,
3920 "blog",
3921 &SiteConfig {
3922 handlers: Some(HandlersSiteConfig {
3923 enabled: true,
3924 allow_imports: vec!["wasi:keyvalue".into()],
3925 ..Default::default()
3926 }),
3927 ..Default::default()
3928 },
3929 )
3930 .await
3931 .unwrap();
3932
3933 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3934 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3935 rt.set_cron_leader_gate(Arc::new(|| false));
3937 let inner = rt.inner.clone().unwrap();
3938 let mut wasm = std::collections::HashMap::new();
3939 let mut crons = std::collections::HashMap::new();
3940 let mut sweep = std::collections::HashMap::new();
3941 let now = CronNow {
3942 minute: 0,
3943 hour: 0,
3944 dom: 1,
3945 month: 1,
3946 dow: 0,
3947 minute_stamp: 100,
3948 };
3949
3950 let (_, handles) =
3951 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3952 .await
3953 .unwrap();
3954 assert!(handles.is_empty(), "a non-leader must not fire crons");
3956 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3957 }
3958
3959 #[tokio::test]
3966 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3967 use boatramp_core::config::HandlersSiteConfig;
3968 use boatramp_core::project::ProjectRef;
3969 use boatramp_handlers::{HandlerEngine, Limits};
3970
3971 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3972 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3973 let sql_dir =
3975 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3976 let _ = std::fs::remove_dir_all(&sql_dir);
3977 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3978 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3979
3980 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3981 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3982 let inner = rt.inner.as_ref().unwrap();
3983
3984 let site = HandlersSiteConfig {
3988 enabled: true,
3989 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3990 tenancy: Some(boatramp_core::tenancy::Tenancy::Disabled),
3991 ..Default::default()
3992 };
3993 let env = std::collections::BTreeMap::new();
3994 let build = |imports: &[&str]| {
3995 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3996 let site = &site;
3997 let env = &env;
3998 async move {
3999 crate::handler_dispatch::build_bindings(
4000 inner,
4001 ProjectRef::new("default"),
4002 "shop",
4003 "shop",
4004 None,
4005 &imports,
4006 site,
4007 env,
4008 &[],
4009 0,
4010 None,
4011 None,
4012 None,
4013 )
4014 .await
4015 .expect("no secrets → resolves")
4016 .sql_database_names()
4017 }
4018 };
4019
4020 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
4023 assert_eq!(
4025 build(&["sql", "sql:*"]).await,
4026 vec!["", "privileged", "product"]
4027 );
4028 assert!(build(&["sql:secret"]).await.is_empty());
4030 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
4032
4033 let _ = std::fs::remove_dir_all(&sql_dir);
4034 }
4035}