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 = "session")]
71mod session_driver;
72#[cfg(feature = "session")]
75mod session_store;
76#[cfg(feature = "admin")]
77pub use admin_controller::ServerAdminController;
78#[cfg(feature = "compression")]
79pub(crate) use content::maybe_compress;
80pub(crate) use content::multipart_byteranges;
81pub(crate) use content::{
82 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
83};
84pub(crate) use control_api::{
85 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
86 cluster_revoke, cluster_rotate_key, create_join_token, create_token, delete_email_profile,
87 delete_secret, get_authz_policy, list_email_profiles, list_root_anchors, list_secrets,
88 list_tokens, put_authz_policy, remove_root_anchor, revoke_token, set_email_profile, set_secret,
89 show_email_profile,
90};
91#[cfg(all(test, feature = "handlers"))]
92use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
93#[cfg(feature = "email")]
94pub use email_spool::NodeEmailSpool;
95mod domain_verify;
96pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
97pub mod envelope;
98#[cfg(feature = "handlers")]
99mod graphql_apq;
100#[cfg(feature = "handlers")]
101mod graphql_cache;
102#[cfg(feature = "handlers")]
103mod graphql_data;
104#[cfg(feature = "handlers")]
105mod graphql_federation;
106#[cfg(feature = "handlers")]
107mod graphql_gateway;
108#[cfg(feature = "handlers")]
109mod graphql_graphiql;
110#[cfg(feature = "handlers")]
111mod graphql_guard;
112#[cfg(feature = "handlers")]
113mod graphql_plan;
114#[cfg(feature = "handlers")]
115mod graphql_registry;
116#[cfg(feature = "handlers")]
117mod graphql_subscription;
118#[cfg(feature = "handlers")]
119mod handler_cache;
120#[cfg(feature = "handlers")]
121mod handler_dispatch;
122#[cfg(feature = "handlers")]
123pub(crate) use handler_dispatch::{
124 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
125 read_blob_fully, resolve_secret_env,
126};
127#[cfg(all(feature = "handlers", test))]
128use handler_dispatch::{resolve_env, set_forwarded_headers};
129mod function_api;
130pub(crate) use function_api::{
131 alias_function, deploy_function, list_functions, remove_function, rollback_function,
132};
133#[cfg(feature = "handlers")]
138pub use function_api::{
139 component_requires, host_capability_features, host_capability_features_detailed, unmet_requires,
140};
141#[cfg(all(test, feature = "handlers"))]
142use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
143pub use function_api::{CapabilityFeature, Lifecycle};
146mod gateway;
147mod host;
148pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
149#[cfg(feature = "http3")]
150mod http3;
151mod limits;
152#[cfg(feature = "handlers")]
153mod logs;
154#[cfg(feature = "handlers")]
155mod metrics;
156#[cfg(feature = "oidc")]
157mod oidc;
158mod operator;
159pub(crate) use operator::prometheus_metrics;
160#[cfg(feature = "handlers")]
161pub(crate) use operator::{
162 operator_dlq, operator_function_logs, operator_function_logs_stream, operator_handler_stats,
163 operator_logs, operator_logs_stream,
164};
165mod proxy;
166pub use proxy::spawn_compute_reconcile;
167pub(crate) use proxy::{
168 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
169 proxy, COMPUTE_WAKE_TIMEOUT,
170};
171mod splice;
172mod http_serve;
175pub use http_serve::{
176 alpn_h1_h2, serve_plaintext, serve_plaintext_listener, serve_router_conn, serve_tls,
177 serve_tls_listener, ReloadableTls, ServeInput,
178};
179#[cfg(feature = "handlers")]
181pub(crate) use proxy::is_upgrade_request;
182#[cfg(all(test, feature = "handlers"))]
183use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
184mod project_api;
185pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
186mod project_scope;
187pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
188mod ratelimit;
189mod routes;
190pub use routes::{router, router_with, router_with_fast};
191#[cfg(feature = "mcp")]
192mod mcp_http;
193#[cfg(feature = "handlers")]
194mod scheduler;
195mod serve_pipeline;
196#[cfg(feature = "handlers")]
197mod tenant_resolve;
198pub use serve_pipeline::{http_redirect_router, FastServe};
199#[cfg(test)]
200mod hotpath_test;
201#[cfg(all(test, feature = "handlers"))]
202use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
203pub(crate) use serve_pipeline::{
204 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
205 BootstrapAttestation,
206};
207pub mod signer;
210mod srvmetrics;
211#[cfg(all(feature = "handlers", test))]
212use scheduler::run_scheduler_tick;
213#[cfg(feature = "handlers")]
214pub(crate) use scheduler::{
215 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
216};
217#[cfg(feature = "handlers")]
218use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
219#[cfg(feature = "handlers")]
220mod function_runtime;
221#[cfg(feature = "handlers")]
222pub(crate) use function_runtime::{
223 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
224 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
225 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
226 put_trigger_handler, webhook_ingress,
227};
228#[cfg(feature = "session")]
232mod session_serve;
233#[cfg(feature = "handlers")]
234mod stream;
235#[cfg(feature = "handlers")]
236mod workflow;
237pub use auth::{require_auth, Auth};
238#[cfg(feature = "http3")]
239pub use http3::{
240 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
241 Http3Error,
242};
243pub use limits::{ServerLimits, UploadGuard};
244#[cfg(feature = "oidc")]
245pub use oidc::{OidcConfig, OidcError, OidcVerifier};
246use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
247#[cfg(feature = "handlers")]
248pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
249#[cfg(feature = "handlers")]
250pub(crate) use workflow::{
251 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
252 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
253};
254pub use srvmetrics::{server_metrics, ServerMetrics};
257
258#[derive(Clone, Default)]
263pub struct HandlerRuntime {
264 #[cfg(feature = "handlers")]
265 inner: Option<Arc<HandlerRuntimeInner>>,
266}
267
268#[cfg(feature = "handlers")]
269struct HandlerRuntimeInner {
270 engine: boatramp_handlers::HandlerEngine,
271 async_drain_gate: Arc<tokio::sync::Semaphore>,
277 kv: Arc<dyn boatramp_core::kv::KvStore>,
278 storage: Arc<dyn boatramp_core::Storage>,
279 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
283 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
286 site_semaphores:
289 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
290 stream_semaphores:
294 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
295 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
298 metrics: metrics::Metrics,
301 logs: Arc<logs::LogStore>,
303 #[cfg(feature = "handlers")]
307 graphql_cache: graphql_cache::GraphqlCache,
308 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
314 max_blob_bytes: std::sync::OnceLock<u64>,
318 max_component_bytes: std::sync::OnceLock<u64>,
323 allow_env_secret_refs: std::sync::OnceLock<bool>,
332 require_tenancy_declaration: std::sync::OnceLock<bool>,
338 allow_cross_tenant_db: std::sync::OnceLock<bool>,
343 secret_store: std::sync::OnceLock<Arc<boatramp_core::secret_store::SecretStore>>,
349 function_meter_locks:
353 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
354 function_semaphores:
357 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
358 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
363 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
367 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
375 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
380 #[cfg(feature = "email")]
385 email_profile_store: std::sync::OnceLock<Arc<boatramp_core::email_config::EmailProfileStore>>,
386 #[cfg(feature = "email")]
391 email_spool: std::sync::OnceLock<Arc<dyn boatramp_handlers::EmailSpool>>,
392 #[cfg(feature = "admin")]
396 admin_controller: std::sync::OnceLock<Arc<admin_controller::ServerAdminController>>,
397 #[cfg(feature = "admin")]
401 admin_surfaces:
402 std::sync::OnceLock<std::collections::BTreeSet<boatramp_handlers::AdminSurface>>,
403 #[cfg(feature = "session")]
410 session_store: std::sync::OnceLock<session_store::SessionStore>,
411}
412
413pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
416
417impl HandlerRuntime {
418 pub fn disabled() -> Self {
420 Self::default()
421 }
422
423 #[cfg(feature = "handlers")]
429 pub fn new(
430 engine: boatramp_handlers::HandlerEngine,
431 kv: Arc<dyn boatramp_core::kv::KvStore>,
432 storage: Arc<dyn boatramp_core::Storage>,
433 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
434 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
435 ) -> Self {
436 let async_drain_slots = engine.async_max_concurrency().max(1);
439 Self {
440 inner: Some(Arc::new(HandlerRuntimeInner {
441 engine,
442 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
443 kv,
444 storage,
445 sql,
446 messaging,
447 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
448 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
449 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
450 metrics: metrics::Metrics::default(),
451 logs: Arc::new(logs::LogStore::default()),
452 #[cfg(feature = "handlers")]
453 graphql_cache: graphql_cache::GraphqlCache::default(),
454 cron_leader_gate: std::sync::OnceLock::new(),
455 max_blob_bytes: std::sync::OnceLock::new(),
456 max_component_bytes: std::sync::OnceLock::new(),
457 allow_env_secret_refs: std::sync::OnceLock::new(),
458 require_tenancy_declaration: std::sync::OnceLock::new(),
459 allow_cross_tenant_db: std::sync::OnceLock::new(),
460 secret_store: std::sync::OnceLock::new(),
461 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
462 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
463 watch_provider: std::sync::OnceLock::new(),
464 provision_tier: std::sync::OnceLock::new(),
465 invoker: std::sync::OnceLock::new(),
466 federation_runner: std::sync::OnceLock::new(),
467 #[cfg(feature = "email")]
468 email_profile_store: std::sync::OnceLock::new(),
469 #[cfg(feature = "email")]
470 email_spool: std::sync::OnceLock::new(),
471 #[cfg(feature = "admin")]
472 admin_controller: std::sync::OnceLock::new(),
473 #[cfg(feature = "admin")]
474 admin_surfaces: std::sync::OnceLock::new(),
475 #[cfg(feature = "session")]
476 session_store: std::sync::OnceLock::new(),
477 })),
478 }
479 }
480
481 #[cfg(feature = "handlers")]
484 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
485 self.inner.as_ref().and_then(|inner| inner.sql.clone())
486 }
487
488 #[cfg(feature = "handlers")]
492 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
493 self.inner
494 .as_ref()
495 .and_then(|inner| inner.invoker.get().cloned())
496 }
497
498 #[cfg(feature = "handlers")]
502 pub(crate) async fn introspect_subgraph_sdl(
503 &self,
504 deploy: &DeployStore,
505 project: boatramp_core::project::ProjectRef<'_>,
506 function: &boatramp_core::function::Function,
507 component: &str,
508 ) -> Result<String, function_runtime::SubgraphSdlError> {
509 match self.inner.as_ref() {
510 Some(inner) => {
511 function_runtime::introspect_service_sdl(
512 inner, deploy, project, function, component,
513 )
514 .await
515 }
516 None => Err(function_runtime::SubgraphSdlError::Unavailable),
517 }
518 }
519
520 #[cfg(feature = "handlers")]
526 pub fn set_invoker(&self, deploy: DeployStore) {
527 if let Some(inner) = self.inner.as_ref() {
528 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
529 deploy,
530 Arc::downgrade(inner),
531 ));
532 let _ = inner.invoker.set(invoker);
533 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
536 inner,
537 )));
538 let _ = inner.federation_runner.set(runner);
539 }
540 }
541
542 #[cfg(feature = "handlers")]
546 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
547 self.inner.as_ref().and_then(|inner| inner.sql.clone())
548 }
549
550 #[cfg(feature = "handlers")]
554 pub fn set_watch_provider(
555 &self,
556 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
557 ) {
558 if let Some(inner) = self.inner.as_ref() {
559 let _ = inner.watch_provider.set(provider);
560 }
561 }
562
563 #[cfg(feature = "handlers")]
567 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
568 if let Some(inner) = self.inner.as_ref() {
569 let _ = inner.provision_tier.set(tier);
570 }
571 }
572
573 #[cfg(feature = "handlers")]
577 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
578 if let Some(inner) = self.inner.as_ref() {
579 let _ = inner.max_blob_bytes.set(max_bytes);
580 }
581 }
582
583 #[cfg(feature = "handlers")]
586 pub fn set_max_component_bytes(&self, max_bytes: u64) {
587 if let Some(inner) = self.inner.as_ref() {
588 let _ = inner.max_component_bytes.set(max_bytes);
589 }
590 }
591
592 #[cfg(feature = "handlers")]
600 pub fn set_allow_env_secret_refs(&self, allow: bool) {
601 if let Some(inner) = self.inner.as_ref() {
602 let _ = inner.allow_env_secret_refs.set(allow);
603 }
604 }
605
606 #[cfg(feature = "handlers")]
611 pub fn set_tenancy_posture(&self, require_declaration: bool, allow_cross_tenant: bool) {
612 if let Some(inner) = self.inner.as_ref() {
613 let _ = inner.require_tenancy_declaration.set(require_declaration);
614 let _ = inner.allow_cross_tenant_db.set(allow_cross_tenant);
615 }
616 }
617
618 #[cfg(feature = "handlers")]
623 pub fn set_secret_store(&self, store: Arc<boatramp_core::secret_store::SecretStore>) {
624 if let Some(inner) = self.inner.as_ref() {
625 let _ = inner.secret_store.set(store);
626 }
627 }
628
629 #[cfg(feature = "email")]
633 pub fn set_email_profile_store(
634 &self,
635 store: Arc<boatramp_core::email_config::EmailProfileStore>,
636 ) {
637 if let Some(inner) = self.inner.as_ref() {
638 let _ = inner.email_profile_store.set(store);
639 }
640 }
641
642 #[cfg(feature = "email")]
647 pub fn set_email_spool(&self, spool: Arc<dyn boatramp_handlers::EmailSpool>) {
648 if let Some(inner) = self.inner.as_ref() {
649 let _ = inner.email_spool.set(spool);
650 }
651 }
652
653 #[cfg(feature = "admin")]
658 pub fn set_admin(
659 &self,
660 controller: Arc<admin_controller::ServerAdminController>,
661 surfaces: std::collections::BTreeSet<boatramp_handlers::AdminSurface>,
662 ) {
663 if let Some(inner) = self.inner.as_ref() {
664 let _ = inner.admin_controller.set(controller);
665 let _ = inner.admin_surfaces.set(surfaces);
666 }
667 }
668
669 #[cfg(feature = "handlers")]
674 pub(crate) fn allow_env_secret_refs(&self) -> bool {
675 self.inner
676 .as_ref()
677 .and_then(|inner| inner.allow_env_secret_refs.get().copied())
678 .unwrap_or(false)
679 }
680
681 #[cfg(feature = "handlers")]
686 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
687 if let Some(inner) = self.inner.as_ref() {
688 let _ = inner.cron_leader_gate.set(gate);
689 }
690 }
691
692 #[cfg(feature = "handlers")]
699 async fn precheck_activation(
700 &self,
701 deploy: &DeployStore,
702 manifest: &Manifest,
703 site_config: Option<&SiteConfig>,
704 ) -> Result<(), String> {
705 let Some(inner) = self.inner.as_ref() else {
706 return Ok(());
707 };
708 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
711 return Ok(());
712 }
713 let site_handlers = site_config
715 .and_then(|c| c.handlers.as_ref())
716 .filter(|h| h.enabled)
717 .ok_or_else(|| {
718 "deployment ships handlers/consumers but the site has them disabled".to_string()
719 })?;
720 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
721
722 let allow_env_secret_refs = inner.allow_env_secret_refs.get().copied().unwrap_or(false);
729 crate::handler_dispatch::admit_secret_refs(&site_handlers.secrets, allow_env_secret_refs)
730 .map_err(|err| format!("handler secrets: {err}"))?;
731
732 let sync_ceiling = inner.engine.sync_timeout_ms();
738 let async_ceiling = inner.engine.async_timeout_ms();
739 if let Some(ms) = site_handlers.max_timeout_ms {
740 if u64::from(ms) > sync_ceiling {
741 tracing::warn!(
742 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
743 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
744 applies only to async calls (?mode=async / triggers), capped at \
745 async_max_timeout_ms={async_ceiling}"
746 );
747 }
748 }
749
750 for handler in &manifest.config.handlers {
752 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
753 if u64::from(ms) > sync_ceiling {
754 let route = &handler.route;
755 tracing::warn!(
756 "route {route:?} declares limits.timeout_ms={ms}, above \
757 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
758 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
759 (?mode=async / a queue trigger / a #[consumer]), capped at \
760 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
761 that isn't possible — move the work to the async lane"
762 );
763 }
764 }
765 if !handler.streaming {
770 if let Some(entry) = manifest.files.get(&handler.component) {
771 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
772 if crate::function_api::component_declares_streaming_route(
773 &bytes,
774 &handler.route,
775 ) {
776 let route = &handler.route;
777 tracing::warn!(
778 "route {route:?} is a streaming handler (#[handler(stream)]) but \
779 its config lacks streaming = true: it will run on the sync request \
780 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
781 streaming = true so it serves on the dedicated streaming lane (its \
782 own concurrency budget + a much larger wall-clock)."
783 );
784 }
785 }
786 }
787 }
788 if let Some(entry) = manifest.files.get(&handler.component) {
792 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
793 let unmet = crate::function_api::unmet_requires(&bytes);
794 if !unmet.is_empty() {
795 return Err(format!(
796 "route {:?} [{}] requires capabilities this host does not implement: \
797 {}. Upgrade boatramp or enable those features — see `boatramp \
798 capabilities`.",
799 handler.route,
800 handler.methods.join(","),
801 unmet.join(", ")
802 ));
803 }
804 }
805 }
806 precheck_component(
807 deploy,
808 manifest,
809 site_handlers,
810 inner,
811 max_component,
812 &handler.imports,
813 &handler.component,
814 &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
817 false,
818 )
819 .await?;
820 }
821 for consumer in &manifest.config.consumers {
822 precheck_component(
823 deploy,
824 manifest,
825 site_handlers,
826 inner,
827 max_component,
828 &consumer.imports,
829 &consumer.component,
830 &format!("consumer {:?}", consumer.topic),
831 true,
832 )
833 .await?;
834 }
835 Ok(())
836 }
837
838 #[cfg(not(feature = "handlers"))]
839 async fn precheck_activation(
840 &self,
841 _deploy: &DeployStore,
842 _manifest: &Manifest,
843 _site_config: Option<&SiteConfig>,
844 ) -> Result<(), String> {
845 Ok(())
846 }
847}
848
849#[derive(Default, Clone)]
855pub struct ServerOptions {
856 pub limits: ServerLimits,
858 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
861 pub default_site: Option<String>,
864 pub implicit_routing: bool,
870 pub protect_previews: bool,
875 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
879 pub issuer: Option<Arc<dyn Signer>>,
883 pub bootstrap_secret: Option<String>,
888 pub bootstrap_attestation: Option<String>,
894 pub mesh_control: Option<Arc<dyn MeshControl>>,
898 pub cors_allowed_origins: Vec<String>,
908 #[cfg(feature = "oidc")]
911 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
912 pub posture: boatramp_core::security::SecurityPosture,
916 pub served_over_tls: bool,
921 pub pop_origin: Option<String>,
928 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
932 pub operator_sql: Option<Arc<dyn boatramp_core::sql::OperatorSql>>,
936 pub tenant_deprovisioner: Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>,
941 pub compute_exec: Option<Arc<dyn boatramp_core::compute::ComputeExec>>,
945 pub compute_volumes: Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>,
950 pub compute_control: Option<Arc<dyn boatramp_core::compute::ComputeControl>>,
954 pub secret_store: Option<Arc<boatramp_core::secret_store::SecretStore>>,
962 pub email_profile_store: Option<Arc<boatramp_core::email_config::EmailProfileStore>>,
970 #[cfg(feature = "console")]
974 pub console: Option<console::ConsoleMount>,
975}
976
977#[derive(Clone, Copy)]
981struct ServedOverTls(bool);
982
983#[derive(Clone, Copy, Default)]
988struct ImplicitRouting(bool);
989
990const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
1002
1003pub struct DaemonRuntime {
1004 baseline: boatramp_core::daemon_config::ConfigBaseline,
1005 state: std::sync::RwLock<DaemonState>,
1006 reload: tokio::sync::Notify,
1009}
1010
1011struct DaemonState {
1012 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
1013 generation: Option<String>,
1014}
1015
1016pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
1021 #[cfg(feature = "console")]
1025 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
1026 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
1027 None => (false, None, None),
1028 };
1029 #[cfg(not(feature = "console"))]
1030 let (console_enabled, console_host, console_path) = (false, None, None);
1031 boatramp_core::daemon_config::ConfigBaseline {
1032 default_site: options.default_site.clone(),
1033 protect_previews: options.protect_previews,
1034 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
1035 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
1036 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
1037 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
1038 compute_vcpus: 0,
1039 compute_mem_mib: 0,
1040 console_enabled,
1041 console_host,
1042 console_path,
1043 max_upload_ceiling: options.posture.max_upload_bytes,
1044 max_concurrent_uploads_ceiling: None,
1045 posture: options.posture,
1046 }
1047}
1048
1049impl DaemonRuntime {
1050 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
1054 let effective =
1055 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
1056 Self {
1057 baseline,
1058 state: std::sync::RwLock::new(DaemonState {
1059 effective,
1060 generation: None,
1061 }),
1062 reload: tokio::sync::Notify::new(),
1063 }
1064 }
1065
1066 pub fn notify_reload(&self) {
1070 self.reload.notify_one();
1071 }
1072
1073 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
1075 self.state
1076 .read()
1077 .expect("daemon config lock")
1078 .effective
1079 .clone()
1080 }
1081
1082 pub fn generation(&self) -> Option<String> {
1085 self.state
1086 .read()
1087 .expect("daemon config lock")
1088 .generation
1089 .clone()
1090 }
1091
1092 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
1094 &self.baseline
1095 }
1096
1097 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
1100 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
1101 let generation = deploy.daemon_config_generation().await?;
1102 let effective = Arc::new(cfg.resolve(&self.baseline));
1103 *self.state.write().expect("daemon config lock") = DaemonState {
1104 effective,
1105 generation,
1106 };
1107 Ok(())
1108 }
1109}
1110
1111#[derive(Clone, Copy, Default)]
1114struct PreviewPolicy {
1115 protect: bool,
1116}
1117
1118#[derive(Clone, Default)]
1123struct Issuer(Option<Arc<dyn Signer>>);
1124
1125#[derive(Clone, Default)]
1130struct BootstrapGate(Option<Arc<BootstrapInner>>);
1131
1132struct BootstrapInner {
1133 secret_hash: String,
1136 lock: tokio::sync::Mutex<()>,
1139}
1140
1141impl BootstrapGate {
1142 fn new(secret: Option<&str>) -> Self {
1143 Self(secret.filter(|s| !s.is_empty()).map(|s| {
1144 Arc::new(BootstrapInner {
1145 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
1146 lock: tokio::sync::Mutex::new(()),
1147 })
1148 }))
1149 }
1150}
1151
1152#[async_trait::async_trait]
1156pub trait MeshControl: Send + Sync {
1157 async fn admit(
1165 &self,
1166 mesh_pubkey_hex: &str,
1167 jti: &str,
1168 possession_proof: &[u8],
1169 proof_iat: u64,
1170 now: u64,
1171 advertise_addr: Option<&str>,
1172 ) -> Result<JoinOutcome, String>;
1173
1174 async fn rotate_key(&self) -> Result<String, String>;
1178
1179 async fn revoke(&self, node: u64) -> Result<(), String>;
1183
1184 async fn members(&self) -> Result<Vec<MeshMember>, String>;
1188
1189 async fn promote(&self, node: u64) -> Result<(), String>;
1192}
1193
1194pub enum JoinOutcome {
1196 Admitted {
1199 members: Vec<String>,
1201 addrs: std::collections::BTreeMap<u64, String>,
1203 },
1204 TokenSpent,
1206 ProofInvalid,
1208 Revoked,
1211}
1212
1213#[derive(Debug, Clone, Serialize)]
1215pub struct MeshMember {
1216 pub node: u64,
1218 pub voter: bool,
1220 pub caught_up: bool,
1222 pub leader: bool,
1224 #[serde(default, skip_serializing_if = "Option::is_none")]
1228 pub addr: Option<String>,
1229}
1230
1231#[derive(Clone, Default)]
1234struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
1235
1236#[cfg(feature = "oidc")]
1238#[derive(Clone, Default)]
1239struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1240
1241#[cfg(feature = "oidc")]
1244const EXCHANGE_TTL_SECS: u64 = 3600;
1245
1246use boatramp_core::time::now_unix;
1247
1248#[derive(Clone)]
1250struct CorsState(Arc<Vec<String>>);
1251
1252const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1254const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1257const CORS_MAX_AGE: &str = "600";
1259
1260fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1264 allowed.iter().any(|a| a == "*" || a == origin)
1265}
1266
1267async fn cors(
1274 State(allowed): State<CorsState>,
1275 request: Request,
1276 next: axum::middleware::Next,
1277) -> Response {
1278 let origin = request
1279 .headers()
1280 .get(header::ORIGIN)
1281 .and_then(|v| v.to_str().ok())
1282 .filter(|o| cors_origin_allowed(&allowed.0, o))
1283 .map(str::to_string);
1284 let is_preflight = request.method() == Method::OPTIONS
1286 && request
1287 .headers()
1288 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1289 if is_preflight {
1290 let allow_headers = request
1292 .headers()
1293 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1294 .and_then(|v| v.to_str().ok())
1295 .map(str::to_string)
1296 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1297 let mut response = Response::new(Body::empty());
1298 *response.status_mut() = StatusCode::NO_CONTENT;
1299 if let Some(origin) = origin {
1300 let headers = response.headers_mut();
1301 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1302 set_header(headers, header::VARY, "Origin");
1303 set_header(
1304 headers,
1305 header::ACCESS_CONTROL_ALLOW_METHODS,
1306 CORS_ALLOW_METHODS,
1307 );
1308 set_header(
1309 headers,
1310 header::ACCESS_CONTROL_ALLOW_HEADERS,
1311 &allow_headers,
1312 );
1313 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1314 }
1315 return response;
1316 }
1317 let mut response = next.run(request).await;
1318 if let Some(origin) = origin {
1319 let headers = response.headers_mut();
1320 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1321 if let Ok(value) = HeaderValue::from_str("Origin") {
1324 headers.append(header::VARY, value);
1325 }
1326 }
1327 response
1328}
1329
1330const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1335
1336#[derive(Debug, thiserror::Error)]
1338pub enum ServeError {
1339 #[error("server I/O: {0}")]
1341 Io(#[from] std::io::Error),
1342}
1343
1344pub async fn serve(
1347 addr: SocketAddr,
1348 deploy: DeployStore,
1349 auth: Auth,
1350 handlers: HandlerRuntime,
1351) -> Result<(), ServeError> {
1352 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1353}
1354
1355pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1365 if let Err(err) = stream.set_nodelay(true) {
1366 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1367 }
1368}
1369
1370pub async fn serve_with(
1372 addr: SocketAddr,
1373 deploy: DeployStore,
1374 auth: Auth,
1375 handlers: HandlerRuntime,
1376 options: ServerOptions,
1377) -> Result<(), ServeError> {
1378 let tcp = tokio::net::TcpListener::bind(addr).await?;
1379 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1380 let splice_ctx = splice::SpliceCtx {
1386 deploy: deploy.clone(),
1387 posture: options.posture,
1388 daemon: options.daemon_runtime.clone(),
1389 };
1390 #[cfg(feature = "handlers")]
1393 let scheduler = handlers.spawn_scheduler(deploy.clone());
1394 let gateway_prober = gateway::spawn_active_health_prober();
1398 let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1401
1402 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1406 let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1410 shutdown_signal().await;
1411 let _ = signalled_tx.send(true);
1412 });
1413 let signalled = {
1414 let mut rx = signalled_rx;
1415 async move {
1416 let _ = rx.wait_for(|fired| *fired).await;
1417 }
1418 };
1419 let result = serve_with_drain_deadline(
1420 async move { server.await.map_err(ServeError::from) },
1421 signalled,
1422 DRAIN_DEADLINE,
1423 )
1424 .await;
1425 #[cfg(feature = "handlers")]
1427 if let Some(handle) = scheduler {
1428 handle.abort();
1429 }
1430 gateway_prober.abort();
1431 result
1432}
1433
1434async fn serve_with_drain_deadline<Srv, Sig>(
1439 server: Srv,
1440 signalled: Sig,
1441 deadline: Duration,
1442) -> Result<(), ServeError>
1443where
1444 Srv: Future<Output = Result<(), ServeError>>,
1445 Sig: Future<Output = ()>,
1446{
1447 tokio::pin!(server);
1448 let drain_cap = async move {
1449 signalled.await;
1450 tokio::time::sleep(deadline).await;
1451 };
1452 tokio::select! {
1453 result = &mut server => result,
1454 _ = drain_cap => {
1455 tracing::warn!(
1456 deadline_s = deadline.as_secs(),
1457 "drain deadline exceeded; forcing shutdown with requests still in flight"
1458 );
1459 Ok(())
1460 }
1461 }
1462}
1463
1464pub async fn shutdown_signal() {
1467 let ctrl_c = async {
1468 let _ = tokio::signal::ctrl_c().await;
1469 };
1470 #[cfg(unix)]
1471 let terminate = async {
1472 if let Ok(mut sig) =
1473 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1474 {
1475 sig.recv().await;
1476 }
1477 };
1478 #[cfg(not(unix))]
1479 let terminate = std::future::pending::<()>();
1480
1481 tokio::select! {
1482 _ = ctrl_c => {}
1483 _ = terminate => {}
1484 }
1485 tracing::info!("shutdown signal received; draining");
1486}
1487
1488async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1492 match daemon.generation() {
1493 Some(gen) => format!("ok gen={gen}"),
1494 None => "ok".to_string(),
1495 }
1496}
1497
1498async fn readyz(State(deploy): State<DeployStore>) -> Response {
1500 match deploy.ready().await {
1501 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1502 Err(err) => {
1503 tracing::warn!(error = %err, "readiness probe failed");
1504 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1505 }
1506 }
1507}
1508
1509#[derive(Clone)]
1514pub struct RequestId(pub String);
1515
1516#[cfg(feature = "handlers")]
1522#[derive(Clone)]
1523pub struct DomainContext(pub String);
1524
1525fn request_id_for(headers: &HeaderMap) -> String {
1528 if let Some(id) = headers
1529 .get("x-request-id")
1530 .and_then(|v| v.to_str().ok())
1531 .map(str::trim)
1532 .filter(|s| !s.is_empty())
1533 {
1534 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1535 }
1536 use std::sync::atomic::{AtomicU64, Ordering};
1537 static SEQ: AtomicU64 = AtomicU64::new(0);
1538 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1539 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1540}
1541
1542struct AccessLog {
1546 request_id: String,
1547 method: Method,
1548 path: String,
1549 host: String,
1550 client: String,
1551 status: u16,
1552 encoding: String,
1554 start: std::time::Instant,
1555 bytes: std::sync::atomic::AtomicU64,
1556}
1557
1558impl Drop for AccessLog {
1559 fn drop(&mut self) {
1560 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1561 srvmetrics::server_metrics().record_request(self.status, bytes);
1564 tracing::info!(
1565 target: "boatramp::access",
1566 request_id = %self.request_id,
1567 method = %self.method,
1568 path = %self.path,
1569 host = %self.host,
1570 client = %self.client,
1571 status = self.status,
1572 bytes = bytes,
1573 encoding = %self.encoding,
1574 cache_result = srvmetrics::cache_result(self.status),
1575 elapsed_ms = self.start.elapsed().as_millis() as u64,
1576 "request"
1577 );
1578 }
1579}
1580
1581pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1587 let request_id = request_id_for(request.headers());
1588 request
1589 .extensions_mut()
1590 .insert(RequestId(request_id.clone()));
1591 request_id
1592}
1593
1594pub(crate) struct AccessLogCtx {
1599 request_id: String,
1600 method: Method,
1601 path: String,
1602 host: String,
1603 client: String,
1604 start: std::time::Instant,
1605}
1606
1607impl AccessLogCtx {
1608 pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1614 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1615 return None;
1616 }
1617 Some(Self {
1618 request_id,
1619 method: request.method().clone(),
1620 path: request.uri().path().to_string(),
1621 host: request
1622 .headers()
1623 .get(header::HOST)
1624 .and_then(|value| value.to_str().ok())
1625 .or_else(|| request.uri().host()) .unwrap_or("-")
1627 .to_string(),
1628 client: request
1629 .extensions()
1630 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1631 .map(|info| info.0.ip().to_string())
1632 .unwrap_or_else(|| "-".to_string()),
1633 start: std::time::Instant::now(),
1634 })
1635 }
1636
1637 pub(crate) fn finish(self, response: Response) -> Response {
1641 let encoding = response
1642 .headers()
1643 .get(header::CONTENT_ENCODING)
1644 .and_then(|v| v.to_str().ok())
1645 .unwrap_or("identity")
1646 .to_string();
1647 let log = AccessLog {
1648 request_id: self.request_id,
1649 method: self.method,
1650 path: self.path,
1651 host: self.host,
1652 client: self.client,
1653 status: response.status().as_u16(),
1654 encoding,
1655 start: self.start,
1656 bytes: std::sync::atomic::AtomicU64::new(0),
1657 };
1658 let (parts, body) = response.into_parts();
1659 let counted = body.into_data_stream().map(move |chunk| {
1660 if let Ok(bytes) = &chunk {
1661 log.bytes
1662 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1663 }
1664 chunk
1665 });
1666 Response::from_parts(parts, Body::from_stream(counted))
1667 }
1668}
1669
1670async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1675 let request_id = assign_request_id(&mut request);
1676 match AccessLogCtx::capture(&request, request_id) {
1677 None => next.run(request).await,
1678 Some(ctx) => ctx.finish(next.run(request).await),
1679 }
1680}
1681
1682fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1684 req_headers
1685 .get(header::IF_NONE_MATCH)
1686 .and_then(|value| value.to_str().ok())
1687 .is_some_and(|value| {
1688 value
1689 .split(',')
1690 .map(str::trim)
1691 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1692 })
1693}
1694
1695fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1696 if let Ok(value) = HeaderValue::from_str(value) {
1697 headers.insert(name, value);
1698 }
1699}
1700
1701fn not_found() -> Response {
1702 (StatusCode::NOT_FOUND, "not found\n").into_response()
1703}
1704
1705fn redirect(status: u16, location: &str) -> Response {
1706 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1707 match HeaderValue::from_str(location) {
1708 Ok(location) => {
1709 let mut headers = HeaderMap::new();
1710 headers.insert(header::LOCATION, location);
1711 (status, headers).into_response()
1712 }
1713 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1714 }
1715}
1716
1717fn deploy_error_response(err: DeployError) -> Response {
1719 let status = match &err {
1720 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1721 StatusCode::NOT_FOUND
1722 }
1723 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1724 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1725 DeployError::Conflict(_) => StatusCode::CONFLICT,
1727 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1729 _ => StatusCode::INTERNAL_SERVER_ERROR,
1730 };
1731 tracing::warn!(error = %err, "request failed");
1732 (status, format!("{err}\n")).into_response()
1733}
1734
1735fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1740 boatramp_core::project::validate_resource_name(kind, value)
1741 .err()
1742 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1743}
1744
1745#[cfg(test)]
1746mod drain_tests {
1747 use super::*;
1748
1749 #[tokio::test]
1750 async fn deadline_forces_shutdown_after_signal() {
1751 let server = std::future::pending::<Result<(), ServeError>>();
1754 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1756 assert!(result.is_ok());
1757 }
1758
1759 #[tokio::test]
1760 async fn server_finishing_first_wins() {
1761 let server = async { Ok(()) };
1764 let signalled = std::future::pending::<()>();
1765 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1766 assert!(result.is_ok());
1767 }
1768
1769 #[tokio::test]
1770 async fn deadline_does_not_trip_before_signal() {
1771 let server = async {
1775 tokio::time::sleep(Duration::from_millis(40)).await;
1776 Err(ServeError::Io(std::io::Error::other("server error")))
1777 };
1778 let signalled = std::future::pending::<()>();
1779 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1780 assert!(result.is_err());
1781 }
1782}
1783
1784#[cfg(all(test, feature = "handlers"))]
1785mod tests {
1786 use super::*;
1787 use boatramp_core::cose::{LocalSigner, TokenAlg};
1788 use boatramp_core::project::ProjectRef;
1789
1790 #[test]
1791 fn query_string_parses_and_url_decodes() {
1792 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1793 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1794 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")); }
1798
1799 #[test]
1800 fn cookie_header_parses_pairs() {
1801 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1802 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1803 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1804 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1805 }
1806
1807 #[test]
1808 fn apply_vary_merges_without_duplicates() {
1809 let base = (StatusCode::OK, "x").into_response();
1810 let r = apply_vary(base, &["accept-language".into()]);
1811 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1812 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1814 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1815 assert!(v.contains("accept-language") && v.contains("cookie"));
1816 assert_eq!(v.matches("accept-language").count(), 1);
1817 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1819 assert!(plain.headers().get(header::VARY).is_none());
1820 }
1821
1822 #[tokio::test]
1826 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1827 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1828 let public = keys.public_key();
1829
1830 let resp = create_join_token(
1832 Extension(Issuer(Some(keys.clone()))),
1833 Json(CreateJoinTokenRequest {
1834 ttl_secs: Some(600),
1835 }),
1836 )
1837 .await;
1838 assert_eq!(resp.status(), StatusCode::CREATED);
1839 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1840 .await
1841 .unwrap();
1842 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1843 let token = parsed["token"].as_str().unwrap();
1844 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1845 assert!(!jti.is_empty());
1846
1847 let no_issuer = create_join_token(
1849 Extension(Issuer(None)),
1850 Json(CreateJoinTokenRequest { ttl_secs: None }),
1851 )
1852 .await;
1853 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1854 }
1855
1856 #[tokio::test]
1861 async fn function_write_path_deploy_rollback_alias_remove() {
1862 use boatramp_core::function::Lifecycle;
1863 use boatramp_core::kv::MemoryKv;
1864 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1865
1866 struct FakeStorage {
1869 present: bool,
1870 }
1871 #[async_trait::async_trait]
1872 impl Storage for FakeStorage {
1873 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1874 Err(StorageError::NotFound(String::new()))
1875 }
1876 async fn get_range(
1877 &self,
1878 _: &str,
1879 _: u64,
1880 _: Option<u64>,
1881 ) -> Result<GetObject, StorageError> {
1882 Err(StorageError::NotFound(String::new()))
1883 }
1884 async fn put(
1885 &self,
1886 _: &str,
1887 _: ByteStream,
1888 _: PutMeta,
1889 ) -> Result<ObjectMeta, StorageError> {
1890 Err(StorageError::unsupported("fake"))
1891 }
1892 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1893 if self.present {
1894 Ok(ObjectMeta {
1895 key: key.to_string(),
1896 ..Default::default()
1897 })
1898 } else {
1899 Err(StorageError::NotFound(key.to_string()))
1900 }
1901 }
1902 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1903 Ok(())
1904 }
1905 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1906 Ok(Vec::new())
1907 }
1908 }
1909
1910 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1911 let status = resp.status();
1912 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1913 .await
1914 .unwrap();
1915 let value = if bytes.is_empty() {
1916 serde_json::Value::Null
1917 } else {
1918 serde_json::from_slice(&bytes).unwrap()
1919 };
1920 (status, value)
1921 }
1922
1923 let deploy = DeployStore::new(
1924 Arc::new(FakeStorage { present: true }),
1925 Arc::new(MemoryKv::new()),
1926 );
1927 let v1 = "a".repeat(64);
1928 let v2 = "b".repeat(64);
1929
1930 let (st, body) = body_json(
1932 deploy_function(
1933 State(deploy.clone()),
1934 axum::extract::Extension(crate::ProjectContext::default()),
1935 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1936 axum::extract::Query(DeployFunctionQuery::default()),
1937 Path("greeter".to_string()),
1938 Json(FunctionUpsert {
1939 component: v1.clone(),
1940 config: Default::default(),
1941 lifecycle: Lifecycle::Independent,
1942 }),
1943 )
1944 .await,
1945 )
1946 .await;
1947 assert_eq!(st, StatusCode::OK);
1948 assert_eq!(body["active"], v1);
1949
1950 let (_, body) = body_json(
1952 deploy_function(
1953 State(deploy.clone()),
1954 axum::extract::Extension(crate::ProjectContext::default()),
1955 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1956 axum::extract::Query(DeployFunctionQuery::default()),
1957 Path("greeter".to_string()),
1958 Json(FunctionUpsert {
1959 component: v2.clone(),
1960 config: Default::default(),
1961 lifecycle: Lifecycle::Independent,
1962 }),
1963 )
1964 .await,
1965 )
1966 .await;
1967 assert_eq!(body["active"], v2);
1968 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1969
1970 let (st, body) = body_json(
1972 rollback_function(
1973 State(deploy.clone()),
1974 axum::extract::Extension(crate::ProjectContext::default()),
1975 Path("greeter".to_string()),
1976 Json(RollbackBody { to: v1.clone() }),
1977 )
1978 .await,
1979 )
1980 .await;
1981 assert_eq!(st, StatusCode::OK);
1982 assert_eq!(body["active"], v1);
1983
1984 let resp = rollback_function(
1986 State(deploy.clone()),
1987 axum::extract::Extension(crate::ProjectContext::default()),
1988 Path("greeter".to_string()),
1989 Json(RollbackBody { to: "c".repeat(64) }),
1990 )
1991 .await;
1992 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1993
1994 let (st, body) = body_json(
1996 alias_function(
1997 State(deploy.clone()),
1998 axum::extract::Extension(crate::ProjectContext::default()),
1999 Path(("greeter".to_string(), "prod".to_string())),
2000 Json(AliasBody {
2001 version: v2.clone(),
2002 }),
2003 )
2004 .await,
2005 )
2006 .await;
2007 assert_eq!(st, StatusCode::OK);
2008 assert_eq!(body["aliases"]["prod"], v2);
2009
2010 let (st, _) = body_json(
2012 remove_function(
2013 State(deploy.clone()),
2014 axum::extract::Extension(crate::ProjectContext::default()),
2015 Path("greeter".to_string()),
2016 )
2017 .await,
2018 )
2019 .await;
2020 assert_eq!(st, StatusCode::NO_CONTENT);
2021 assert!(deploy
2022 .get_function(ProjectRef::DEFAULT, "greeter")
2023 .await
2024 .unwrap()
2025 .is_none());
2026
2027 let empty = DeployStore::new(
2029 Arc::new(FakeStorage { present: false }),
2030 Arc::new(MemoryKv::new()),
2031 );
2032 let resp = deploy_function(
2033 State(empty),
2034 axum::extract::Extension(crate::ProjectContext::default()),
2035 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2036 axum::extract::Query(DeployFunctionQuery::default()),
2037 Path("orphan".to_string()),
2038 Json(FunctionUpsert {
2039 component: v1.clone(),
2040 config: Default::default(),
2041 lifecycle: Lifecycle::default(),
2042 }),
2043 )
2044 .await;
2045 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2046 }
2047
2048 struct StubControl {
2052 admits: std::sync::Mutex<Vec<(String, String)>>,
2053 respond: StubJoin,
2054 }
2055 #[derive(Clone, Copy)]
2056 enum StubJoin {
2057 Admit,
2058 Spent,
2059 Invalid,
2060 Revoked,
2061 }
2062
2063 #[async_trait::async_trait]
2064 impl MeshControl for StubControl {
2065 async fn admit(
2066 &self,
2067 mesh_pubkey_hex: &str,
2068 jti: &str,
2069 _proof: &[u8],
2070 _proof_iat: u64,
2071 _now: u64,
2072 _advertise_addr: Option<&str>,
2073 ) -> Result<JoinOutcome, String> {
2074 self.admits
2075 .lock()
2076 .unwrap()
2077 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
2078 Ok(match self.respond {
2079 StubJoin::Admit => JoinOutcome::Admitted {
2080 members: vec!["signed-member".to_string()],
2081 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
2082 },
2083 StubJoin::Spent => JoinOutcome::TokenSpent,
2084 StubJoin::Invalid => JoinOutcome::ProofInvalid,
2085 StubJoin::Revoked => JoinOutcome::Revoked,
2086 })
2087 }
2088 async fn rotate_key(&self) -> Result<String, String> {
2089 Ok("cafe".to_string())
2090 }
2091 async fn revoke(&self, _node: u64) -> Result<(), String> {
2092 Ok(())
2093 }
2094 async fn members(&self) -> Result<Vec<MeshMember>, String> {
2095 Ok(Vec::new())
2096 }
2097 async fn promote(&self, _node: u64) -> Result<(), String> {
2098 Ok(())
2099 }
2100 }
2101
2102 #[tokio::test]
2106 async fn cluster_join_dispatches_and_maps_outcomes() {
2107 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2108 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2109 let auth = Auth::with_key(keys.public_key(), kv);
2110 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
2111 let req = |proof: &str| JoinRequest {
2112 token: token.clone(),
2113 mesh_pubkey: "302a300506032b6570032100feed".into(),
2114 possession_proof: proof.to_string(),
2115 proof_iat: now_unix(),
2116 advertise_addr: Some("https://joiner:7000".into()),
2117 };
2118
2119 let admitter = Arc::new(StubControl {
2121 admits: std::sync::Mutex::new(Vec::new()),
2122 respond: StubJoin::Admit,
2123 });
2124 let resp = cluster_join(
2125 Extension(auth.clone()),
2126 Extension(MeshControlHandle(Some(admitter.clone()))),
2127 Json(req("aa01")),
2128 )
2129 .await;
2130 assert_eq!(resp.status(), StatusCode::OK);
2131 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
2132
2133 let spent = Arc::new(StubControl {
2135 admits: std::sync::Mutex::new(Vec::new()),
2136 respond: StubJoin::Spent,
2137 });
2138 assert_eq!(
2139 cluster_join(
2140 Extension(auth.clone()),
2141 Extension(MeshControlHandle(Some(spent))),
2142 Json(req("aa01")),
2143 )
2144 .await
2145 .status(),
2146 StatusCode::CONFLICT
2147 );
2148 let invalid = Arc::new(StubControl {
2149 admits: std::sync::Mutex::new(Vec::new()),
2150 respond: StubJoin::Invalid,
2151 });
2152 assert_eq!(
2153 cluster_join(
2154 Extension(auth.clone()),
2155 Extension(MeshControlHandle(Some(invalid))),
2156 Json(req("aa01")),
2157 )
2158 .await
2159 .status(),
2160 StatusCode::FORBIDDEN
2161 );
2162 let revoked = Arc::new(StubControl {
2164 admits: std::sync::Mutex::new(Vec::new()),
2165 respond: StubJoin::Revoked,
2166 });
2167 assert_eq!(
2168 cluster_join(
2169 Extension(auth.clone()),
2170 Extension(MeshControlHandle(Some(revoked))),
2171 Json(req("aa01")),
2172 )
2173 .await
2174 .status(),
2175 StatusCode::FORBIDDEN
2176 );
2177
2178 let ok = Arc::new(StubControl {
2180 admits: std::sync::Mutex::new(Vec::new()),
2181 respond: StubJoin::Admit,
2182 });
2183 assert_eq!(
2184 cluster_join(
2185 Extension(auth.clone()),
2186 Extension(MeshControlHandle(Some(ok))),
2187 Json(req("not-hex")),
2188 )
2189 .await
2190 .status(),
2191 StatusCode::BAD_REQUEST
2192 );
2193
2194 let none = cluster_join(
2196 Extension(auth),
2197 Extension(MeshControlHandle(None)),
2198 Json(req("aa01")),
2199 )
2200 .await;
2201 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2202 }
2203
2204 #[tokio::test]
2208 async fn bootstrap_mints_the_first_token_once() {
2209 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
2210 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2211 let public = keys.public_key();
2212 let deploy = DeployStore::new(
2213 Arc::new(MemStorage::default()),
2214 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
2215 );
2216 let secret = "s3cr3t-bootstrap-value";
2217 let gate = BootstrapGate::new(Some(secret));
2218 let issuer = Issuer(Some(keys.clone()));
2219 let bearer = |s: &str| {
2220 let mut h = HeaderMap::new();
2221 h.insert(
2222 AUTHORIZATION,
2223 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
2224 );
2225 h
2226 };
2227 let req = || BootstrapRequest {
2228 roles: vec!["admin".to_string()],
2229 ttl_secs: None,
2230 };
2231
2232 let bad = bootstrap_token(
2234 State(deploy.clone()),
2235 Extension(issuer.clone()),
2236 Extension(gate.clone()),
2237 bearer("wrong"),
2238 Json(req()),
2239 )
2240 .await;
2241 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
2242
2243 let ok = bootstrap_token(
2245 State(deploy.clone()),
2246 Extension(issuer.clone()),
2247 Extension(gate.clone()),
2248 bearer(secret),
2249 Json(req()),
2250 )
2251 .await;
2252 assert_eq!(ok.status(), StatusCode::CREATED);
2253 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2254 .await
2255 .unwrap();
2256 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2257 let token = json["token"].as_str().unwrap();
2258 let id = json["id"].as_str().unwrap();
2259 let verified = cose::verify(token, &public, now_unix()).unwrap();
2260 assert!(verified.roles.iter().any(|r| r.name == "admin"));
2261 assert!(deploy
2262 .list_token_meta()
2263 .await
2264 .unwrap()
2265 .iter()
2266 .any(|m| m.revocation_id == id));
2267
2268 let reuse = bootstrap_token(
2270 State(deploy.clone()),
2271 Extension(issuer.clone()),
2272 Extension(gate),
2273 bearer(secret),
2274 Json(req()),
2275 )
2276 .await;
2277 assert_eq!(reuse.status(), StatusCode::CONFLICT);
2278
2279 let disabled = bootstrap_token(
2281 State(deploy),
2282 Extension(issuer),
2283 Extension(BootstrapGate(None)),
2284 bearer(secret),
2285 Json(req()),
2286 )
2287 .await;
2288 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2289 }
2290
2291 #[tokio::test]
2294 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2295 let control = Arc::new(StubControl {
2296 admits: std::sync::Mutex::new(Vec::new()),
2297 respond: StubJoin::Admit,
2298 });
2299 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2300 assert_eq!(resp.status(), StatusCode::OK);
2301 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2302 .await
2303 .unwrap();
2304 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2305 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2306
2307 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2308 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2309 }
2310
2311 #[test]
2312 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2313 use boatramp_core::security::SecurityProfile;
2314 let strict = SecurityProfile::MultiTenant.preset();
2315 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
2319 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2320 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2321
2322 assert!(gateway_addr_allowed(public, &strict));
2324 assert!(!gateway_addr_allowed(private, &strict));
2325 assert!(!gateway_addr_allowed(loopback, &strict));
2326 assert!(!gateway_addr_allowed(metadata, &strict));
2327
2328 assert!(gateway_addr_allowed(public, &loose));
2331 assert!(gateway_addr_allowed(private, &loose));
2332 assert!(gateway_addr_allowed(loopback, &loose));
2333 assert!(!gateway_addr_allowed(metadata, &loose));
2334 }
2335
2336 #[tokio::test]
2337 async fn resolve_env_merges_static_and_host_secrets() {
2338 use boatramp_core::config::HandlersSiteConfig;
2339
2340 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2342
2343 let deploy_env = std::collections::BTreeMap::from([
2344 ("GREETING".to_string(), "hi".to_string()),
2345 ("OVERRIDE_ME".to_string(), "static".to_string()),
2346 ]);
2347 let site_handlers = HandlersSiteConfig {
2348 enabled: true,
2349 secrets: std::collections::BTreeMap::from([
2350 (
2352 "SECRET_TOKEN".to_string(),
2353 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2354 ),
2355 (
2356 "OVERRIDE_ME".to_string(),
2357 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2358 ),
2359 (
2360 "MISSING".to_string(),
2361 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2362 ),
2363 ]),
2364 ..Default::default()
2365 };
2366 let env = resolve_env(
2369 "blog",
2370 boatramp_core::project::ProjectRef::DEFAULT,
2371 &deploy_env,
2372 &site_handlers,
2373 true,
2374 None,
2375 )
2376 .await
2377 .expect("resolves");
2378
2379 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2383 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2384 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2385 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2386
2387 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2388 }
2389
2390 #[tokio::test]
2391 async fn multi_tenant_posture_refuses_a_host_env_handler_secret() {
2392 use boatramp_core::config::HandlersSiteConfig;
2393
2394 std::env::set_var("BOATRAMP_TEST_OTHER_TENANT_SECRET", "leak-me");
2399 let deploy_env = std::collections::BTreeMap::new();
2400 let bare = HandlersSiteConfig {
2401 enabled: true,
2402 secrets: std::collections::BTreeMap::from([(
2403 "STOLEN".to_string(),
2404 "BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2405 )]),
2406 ..Default::default()
2407 };
2408 let err = resolve_env(
2409 "evil",
2410 boatramp_core::project::ProjectRef::DEFAULT,
2411 &deploy_env,
2412 &bare,
2413 false,
2414 None,
2415 )
2416 .await
2417 .expect_err("multi-tenant must refuse a bare host-env ref");
2418 assert!(
2419 err.contains("STOLEN"),
2420 "error names the offending guest var: {err}"
2421 );
2422 assert!(
2423 err.contains("multi-tenant"),
2424 "error steers the tenant: {err}"
2425 );
2426 assert!(
2427 !err.contains("leak-me"),
2428 "the host value must never appear (never read): {err}"
2429 );
2430
2431 let explicit = HandlersSiteConfig {
2433 enabled: true,
2434 secrets: std::collections::BTreeMap::from([(
2435 "STOLEN".to_string(),
2436 "env:BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2437 )]),
2438 ..Default::default()
2439 };
2440 assert!(resolve_env(
2441 "evil",
2442 boatramp_core::project::ProjectRef::DEFAULT,
2443 &deploy_env,
2444 &explicit,
2445 false,
2446 None,
2447 )
2448 .await
2449 .is_err());
2450
2451 let reserved = HandlersSiteConfig {
2453 enabled: true,
2454 secrets: std::collections::BTreeMap::from([(
2455 "TOKEN".to_string(),
2456 "vault:kv/data/app#token".to_string(),
2457 )]),
2458 ..Default::default()
2459 };
2460 let err = resolve_env(
2461 "evil",
2462 boatramp_core::project::ProjectRef::DEFAULT,
2463 &deploy_env,
2464 &reserved,
2465 true,
2466 None,
2467 )
2468 .await
2469 .expect_err("a reserved scheme is not yet supported, even under single-tenant");
2470 assert!(err.contains("not yet supported"), "{err}");
2471
2472 let arbitrary = HandlersSiteConfig {
2476 enabled: true,
2477 secrets: std::collections::BTreeMap::from([(
2478 "KEY".to_string(),
2479 "aws:sm/prod/apikey".to_string(),
2480 )]),
2481 ..Default::default()
2482 };
2483 let err = resolve_env(
2484 "evil",
2485 boatramp_core::project::ProjectRef::DEFAULT,
2486 &deploy_env,
2487 &arbitrary,
2488 true,
2489 None,
2490 )
2491 .await
2492 .expect_err("any unknown scheme is reserved, even under single-tenant");
2493 assert!(
2494 err.contains("not yet supported") && err.contains("aws"),
2495 "provider-neutral reservation names the scheme: {err}"
2496 );
2497
2498 std::env::remove_var("BOATRAMP_TEST_OTHER_TENANT_SECRET");
2499 }
2500
2501 #[tokio::test]
2502 async fn function_resolve_secret_env_reads_host_and_matches_handler_semantics() {
2503 std::env::set_var("BOATRAMP_TEST_FN_SECRET", "fnsecret");
2508
2509 let static_env = std::collections::BTreeMap::from([
2510 ("STAGE".to_string(), "prod".to_string()),
2511 ("OVERRIDE_ME".to_string(), "static".to_string()),
2512 ]);
2513 let secrets = std::collections::BTreeMap::from([
2514 ("DB_URL".to_string(), "BOATRAMP_TEST_FN_SECRET".to_string()),
2516 (
2518 "OVERRIDE_ME".to_string(),
2519 "BOATRAMP_TEST_FN_SECRET".to_string(),
2520 ),
2521 (
2523 "MISSING".to_string(),
2524 "BOATRAMP_TEST_FN_NOT_SET".to_string(),
2525 ),
2526 ]);
2527 let env = resolve_secret_env(
2529 "fn/api",
2530 boatramp_core::project::ProjectRef::DEFAULT,
2531 &static_env,
2532 &secrets,
2533 true,
2534 None,
2535 )
2536 .await
2537 .expect("resolves");
2538
2539 assert!(env.contains(&("STAGE".to_string(), "prod".to_string())));
2540 assert!(env.contains(&("DB_URL".to_string(), "fnsecret".to_string())));
2542 assert!(env.contains(&("OVERRIDE_ME".to_string(), "fnsecret".to_string())));
2544 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2546
2547 std::env::remove_var("BOATRAMP_TEST_FN_SECRET");
2548 }
2549
2550 #[tokio::test]
2551 async fn multi_tenant_posture_refuses_a_host_env_function_secret() {
2552 std::env::set_var("BOATRAMP_TEST_FN_LEAK", "leak-me");
2557 let static_env = std::collections::BTreeMap::new();
2558 let secrets = std::collections::BTreeMap::from([(
2559 "DB_URL".to_string(),
2560 "BOATRAMP_TEST_FN_LEAK".to_string(),
2561 )]);
2562
2563 let err = resolve_secret_env(
2565 "fn/api",
2566 boatramp_core::project::ProjectRef::DEFAULT,
2567 &static_env,
2568 &secrets,
2569 false,
2570 None,
2571 )
2572 .await
2573 .expect_err("multi-tenant must refuse a function host-env ref");
2574 assert!(
2575 err.contains("DB_URL"),
2576 "error names the offending guest var: {err}"
2577 );
2578 assert!(
2579 !err.contains("leak-me"),
2580 "host value must never appear: {err}"
2581 );
2582
2583 let env = resolve_secret_env(
2585 "fn/api",
2586 boatramp_core::project::ProjectRef::DEFAULT,
2587 &static_env,
2588 &secrets,
2589 true,
2590 None,
2591 )
2592 .await
2593 .expect("resolves");
2594 assert!(env.contains(&("DB_URL".to_string(), "leak-me".to_string())));
2595
2596 std::env::remove_var("BOATRAMP_TEST_FN_LEAK");
2597 }
2598
2599 #[tokio::test]
2600 async fn boatramp_scheme_resolves_from_the_project_scoped_store() {
2601 use boatramp_core::project::ProjectRef;
2602 use boatramp_core::secret_store::SecretStore;
2603 use std::sync::Arc;
2604
2605 struct XorEnvelope;
2607 #[async_trait::async_trait]
2608 impl boatramp_core::envelope::KeyEnvelope for XorEnvelope {
2609 async fn wrap(
2610 &self,
2611 p: &[u8],
2612 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2613 Ok(p.iter().map(|b| b ^ 0x5a).collect())
2614 }
2615 async fn unwrap(
2616 &self,
2617 c: &[u8],
2618 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2619 Ok(c.iter().map(|b| b ^ 0x5a).collect())
2620 }
2621 }
2622
2623 let store = SecretStore::new(
2624 Arc::new(boatramp_core::kv::MemoryKv::new()),
2625 Arc::new(XorEnvelope),
2626 );
2627 store
2628 .set(ProjectRef::new("acme"), "api-key", b"s3cr3t")
2629 .await
2630 .unwrap();
2631
2632 let static_env = std::collections::BTreeMap::new();
2633 let secrets = std::collections::BTreeMap::from([
2634 ("API_KEY".to_string(), "boatramp:api-key".to_string()),
2635 ("MISSING".to_string(), "boatramp:not-set".to_string()),
2636 ]);
2637
2638 let env = resolve_secret_env(
2641 "site",
2642 ProjectRef::new("acme"),
2643 &static_env,
2644 &secrets,
2645 false,
2646 Some(&store),
2647 )
2648 .await
2649 .expect("boatramp refs resolve without the host-env gate");
2650 assert!(env.contains(&("API_KEY".to_string(), "s3cr3t".to_string())));
2651 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2653
2654 let other_secrets = std::collections::BTreeMap::from([(
2656 "API_KEY".to_string(),
2657 "boatramp:api-key".to_string(),
2658 )]);
2659 let other = resolve_secret_env(
2660 "site",
2661 ProjectRef::new("globex"),
2662 &static_env,
2663 &other_secrets,
2664 false,
2665 Some(&store),
2666 )
2667 .await
2668 .expect("resolves (a foreign project's secret is simply absent → skipped)");
2669 assert!(
2670 !other.iter().any(|(k, _)| k == "API_KEY"),
2671 "a tenant must not read another project's secret"
2672 );
2673
2674 let err = resolve_secret_env(
2676 "site",
2677 ProjectRef::new("acme"),
2678 &static_env,
2679 &other_secrets,
2680 false,
2681 None,
2682 )
2683 .await
2684 .expect_err("no store configured must fail closed");
2685 assert!(err.contains("no internal secret store"), "{err}");
2686 }
2687
2688 fn req() -> Request {
2689 Request::builder()
2690 .uri("/")
2691 .header(header::HOST, "example.com")
2692 .body(Body::empty())
2693 .unwrap()
2694 }
2695
2696 #[test]
2697 fn forwarded_headers_set_standard_triple() {
2698 let mut request = req();
2699 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2700 let h = request.headers();
2701 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2702 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2703 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2704 }
2705
2706 #[test]
2707 fn forwarded_for_overwrites_spoofed_value() {
2708 let mut request = Request::builder()
2711 .uri("/")
2712 .header(header::HOST, "example.com")
2713 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2714 .body(Body::empty())
2715 .unwrap();
2716 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2717 let values: Vec<_> = request
2718 .headers()
2719 .get_all("x-forwarded-for")
2720 .iter()
2721 .collect();
2722 assert_eq!(values.len(), 1);
2723 assert_eq!(values[0], "203.0.113.7");
2724 }
2725
2726 #[test]
2727 fn forwarded_proto_preserves_upstream_tls() {
2728 let mut request = Request::builder()
2730 .uri("/")
2731 .header(header::HOST, "example.com")
2732 .header("x-forwarded-proto", "https")
2733 .body(Body::empty())
2734 .unwrap();
2735 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2736 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2737 }
2738
2739 #[test]
2740 fn forwarded_host_absent_when_no_host_header() {
2741 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2742 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2743 assert!(request.headers().get("x-forwarded-host").is_none());
2744 assert_eq!(
2745 request.headers().get("x-forwarded-for").unwrap(),
2746 "203.0.113.7"
2747 );
2748 }
2749
2750 use boatramp_core::kv::{KvStore, MemoryKv};
2753 use boatramp_core::messaging::{LogMessaging, Messaging};
2754 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2755
2756 const EVENT_CONSUMER: &[u8] =
2757 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2758
2759 #[derive(Default)]
2760 struct MemStorage {
2761 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2762 }
2763
2764 #[async_trait::async_trait]
2765 impl boatramp_core::Storage for MemStorage {
2766 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2767 let bytes = self
2768 .objects
2769 .lock()
2770 .unwrap()
2771 .get(key)
2772 .cloned()
2773 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2774 let body: ByteStream =
2775 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2776 Ok(GetObject {
2777 meta: ObjectMeta {
2778 key: key.to_string(),
2779 ..Default::default()
2780 },
2781 body,
2782 })
2783 }
2784 async fn get_range(
2785 &self,
2786 key: &str,
2787 _: u64,
2788 _: Option<u64>,
2789 ) -> Result<GetObject, StorageError> {
2790 self.get(key).await
2791 }
2792 async fn put(
2793 &self,
2794 key: &str,
2795 mut body: ByteStream,
2796 _: PutMeta,
2797 ) -> Result<ObjectMeta, StorageError> {
2798 use futures::StreamExt;
2799 let mut buf = Vec::new();
2800 while let Some(chunk) = body.next().await {
2801 buf.extend_from_slice(&chunk?);
2802 }
2803 self.objects.lock().unwrap().insert(key.to_string(), buf);
2804 Ok(ObjectMeta {
2805 key: key.to_string(),
2806 ..Default::default()
2807 })
2808 }
2809 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2810 self.objects
2811 .lock()
2812 .unwrap()
2813 .get(key)
2814 .map(|_| ObjectMeta {
2815 key: key.to_string(),
2816 ..Default::default()
2817 })
2818 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2819 }
2820 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2821 self.objects.lock().unwrap().remove(key);
2822 Ok(())
2823 }
2824 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2825 Ok(Vec::new())
2826 }
2827 }
2828
2829 fn observed_state_in(
2832 project: &str,
2833 workload: &str,
2834 host: &str,
2835 healthy: bool,
2836 phase: boatramp_core::compute::ReplicaPhase,
2837 ) -> boatramp_core::compute::ObservedInstance {
2838 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2839 boatramp_core::compute::ObservedInstance {
2840 handle: InstanceHandle {
2841 project: project.into(),
2842 workload: workload.into(),
2843 replica: 0,
2844 backend_ref: "ref-0".into(),
2845 },
2846 node: 1,
2847 backend: "vmm".into(),
2848 endpoint: Endpoint {
2849 scheme: Scheme::Http,
2850 host: host.into(),
2851 port: 80,
2852 },
2853 region: None,
2854 healthy,
2855 started_at: None,
2856 phase,
2857 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2858 project: project.into(),
2859 workload: workload.into(),
2860 replica: 0,
2861 data_ref: "snap-0".into(),
2862 }),
2863 }
2864 }
2865
2866 fn observed_state(
2868 workload: &str,
2869 healthy: bool,
2870 phase: boatramp_core::compute::ReplicaPhase,
2871 ) -> boatramp_core::compute::ObservedInstance {
2872 observed_state_in("default", workload, "10.0.0.2", healthy, phase)
2873 }
2874
2875 #[tokio::test]
2876 async fn has_parked_replica_detects_a_zeroed_replica() {
2877 use boatramp_core::compute::ReplicaPhase;
2878 let storage = Arc::new(MemStorage::default());
2879 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2880 let deploy = DeployStore::new(storage, kv);
2881
2882 assert!(!has_parked_replica(&deploy, "default", "w").await);
2884 deploy
2886 .set_replica_state(
2887 ProjectRef::DEFAULT,
2888 &observed_state("w", true, ReplicaPhase::Running),
2889 )
2890 .await
2891 .unwrap();
2892 assert!(!has_parked_replica(&deploy, "default", "w").await);
2893 deploy
2895 .set_replica_state(
2896 ProjectRef::DEFAULT,
2897 &observed_state("w", false, ReplicaPhase::Zero),
2898 )
2899 .await
2900 .unwrap();
2901 assert!(has_parked_replica(&deploy, "default", "w").await);
2902 }
2903
2904 #[tokio::test]
2905 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2906 use boatramp_core::compute::ReplicaPhase;
2907 let storage = Arc::new(MemStorage::default());
2908 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2909 let deploy = DeployStore::new(storage, kv);
2910
2911 let empty = await_warm(
2913 &deploy,
2914 "default",
2915 "w",
2916 std::time::Duration::from_millis(150),
2917 )
2918 .await;
2919 assert!(empty.is_empty());
2920
2921 deploy
2923 .set_replica_state(
2924 ProjectRef::DEFAULT,
2925 &observed_state("w", true, ReplicaPhase::Running),
2926 )
2927 .await
2928 .unwrap();
2929 let warm = await_warm(&deploy, "default", "w", std::time::Duration::from_secs(5)).await;
2930 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2931 }
2932
2933 #[tokio::test]
2939 async fn compute_endpoints_are_project_scoped() {
2940 use boatramp_core::compute::ReplicaPhase;
2941 let storage = Arc::new(MemStorage::default());
2942 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2943 let deploy = DeployStore::new(storage, kv);
2944
2945 deploy
2947 .set_replica_state(
2948 ProjectRef::new("acme"),
2949 &observed_state_in("acme", "web", "10.0.0.5", true, ReplicaPhase::Running),
2950 )
2951 .await
2952 .unwrap();
2953 deploy
2954 .set_replica_state(
2955 ProjectRef::DEFAULT,
2956 &observed_state_in("default", "web", "10.0.0.9", true, ReplicaPhase::Running),
2957 )
2958 .await
2959 .unwrap();
2960
2961 assert_eq!(
2963 compute_endpoints(&deploy, "acme", "web").await,
2964 vec!["http://10.0.0.5:80".to_string()],
2965 "acme's web resolves against acme, not default"
2966 );
2967 assert_eq!(
2969 compute_endpoints(&deploy, "default", "web").await,
2970 vec!["http://10.0.0.9:80".to_string()]
2971 );
2972 assert!(compute_endpoints(&deploy, "beta", "web").await.is_empty());
2975 }
2976
2977 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2981 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2982 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2983 let storage = Arc::new(MemStorage::default());
2984 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2985 let mq = LogMessaging::new(storage, kv.clone());
2986 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2987 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2988 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2989 let topic = "blog/orders/created";
2990
2991 for _ in 0..3 {
2993 mq.publish(topic, b"ok").await.unwrap();
2994 }
2995 loop {
2996 let acked = dispatch_consumer_batch(
2997 &engine,
2998 &mq,
2999 &metrics::Metrics::default(),
3000 "blog",
3001 topic,
3002 "blog/",
3003 "",
3004 boatramp_core::messaging::StartPosition::Latest,
3005 &hash,
3006 EVENT_CONSUMER,
3007 &bindings,
3008 Limits::default(),
3009 Duration::from_secs(30),
3010 5,
3011 10,
3012 )
3013 .await;
3014 if acked == 0 {
3015 break;
3016 }
3017 }
3018 assert_eq!(
3019 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3020 Some(b"3".to_vec())
3021 );
3022
3023 mq.publish(topic, b"fail").await.unwrap();
3026 for _ in 0..5 {
3027 dispatch_consumer_batch(
3028 &engine,
3029 &mq,
3030 &metrics::Metrics::default(),
3031 "blog",
3032 topic,
3033 "blog/",
3034 "",
3035 boatramp_core::messaging::StartPosition::Latest,
3036 &hash,
3037 EVENT_CONSUMER,
3038 &bindings,
3039 Limits::default(),
3040 Duration::ZERO,
3041 2,
3042 10,
3043 )
3044 .await;
3045 }
3046 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
3047 assert_eq!(
3049 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3050 Some(b"3".to_vec())
3051 );
3052 }
3053
3054 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3059 async fn consumer_groups_fan_out_through_the_dispatcher() {
3060 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3061 let storage = Arc::new(MemStorage::default());
3062 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3063 let mq = LogMessaging::new(storage, kv.clone());
3064 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3065 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3066 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3067 let topic = "blog/orders/created";
3068 let start = boatramp_core::messaging::StartPosition::Latest;
3069
3070 for g in ["billing", "audit"] {
3073 let n = dispatch_consumer_batch(
3074 &engine,
3075 &mq,
3076 &metrics::Metrics::default(),
3077 "blog",
3078 topic,
3079 "blog/",
3080 g,
3081 start,
3082 &hash,
3083 EVENT_CONSUMER,
3084 &bindings,
3085 Limits::default(),
3086 Duration::from_secs(30),
3087 5,
3088 10,
3089 )
3090 .await;
3091 assert_eq!(n, 0, "no events yet for group {g}");
3092 }
3093 mq.publish(topic, b"ok").await.unwrap();
3094
3095 for g in ["billing", "audit"] {
3097 let n = dispatch_consumer_batch(
3098 &engine,
3099 &mq,
3100 &metrics::Metrics::default(),
3101 "blog",
3102 topic,
3103 "blog/",
3104 g,
3105 start,
3106 &hash,
3107 EVENT_CONSUMER,
3108 &bindings,
3109 Limits::default(),
3110 Duration::from_secs(30),
3111 5,
3112 10,
3113 )
3114 .await;
3115 assert_eq!(n, 1, "group {g} should receive the message");
3116 }
3117 assert_eq!(
3119 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3120 Some(b"2".to_vec())
3121 );
3122 }
3123
3124 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3128 async fn scheduler_runs_current_consumers_not_previews() {
3129 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
3130 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3131 use boatramp_handlers::{HandlerEngine, Limits};
3132 use futures::StreamExt;
3133
3134 let storage = Arc::new(MemStorage::default());
3135 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3136 let deploy = DeployStore::new(storage.clone(), kv.clone());
3137 let messaging: Arc<dyn Messaging> =
3138 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
3139
3140 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3142 let stream: ByteStream =
3143 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
3144 .boxed();
3145 deploy.put_blob(&hash, stream).await.unwrap();
3146 let mut files = std::collections::BTreeMap::new();
3147 files.insert(
3148 "consumer.wasm".to_string(),
3149 FileEntry {
3150 hash: hash.clone(),
3151 size: EVENT_CONSUMER.len() as u64,
3152 content_type: None,
3153 variants: std::collections::BTreeMap::new(),
3154 },
3155 );
3156 let manifest = Manifest {
3157 files,
3158 config: DeployConfig {
3159 consumers: vec![ConsumerConfig {
3160 topic: "orders/created".into(),
3161 component: "consumer.wasm".into(),
3162 imports: vec!["wasi:keyvalue".into()],
3163 group: String::new(),
3164 start: Default::default(),
3165 }],
3166 ..Default::default()
3167 },
3168 ..Default::default()
3169 };
3170 let id = deploy.put_manifest(&manifest).await.unwrap();
3171 deploy
3172 .activate(ProjectRef::DEFAULT, "blog", &id)
3173 .await
3174 .unwrap();
3175 deploy
3176 .set_site_config(
3177 ProjectRef::DEFAULT,
3178 "blog",
3179 &SiteConfig {
3180 handlers: Some(HandlersSiteConfig {
3181 enabled: true,
3182 allow_imports: vec!["wasi:keyvalue".into()],
3183 ..Default::default()
3184 }),
3185 ..Default::default()
3186 },
3187 )
3188 .await
3189 .unwrap();
3190
3191 messaging
3193 .publish("blog/orders/created", b"live")
3194 .await
3195 .unwrap();
3196 messaging
3197 .publish("blog/_preview/abc/orders/created", b"preview")
3198 .await
3199 .unwrap();
3200
3201 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3202 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
3203 let inner = rt.inner.clone().unwrap();
3204 let mut cache = std::collections::HashMap::new();
3205 let mut crons = std::collections::HashMap::new();
3206 let mut sweep = std::collections::HashMap::new();
3207 let now = CronNow {
3208 minute: 0,
3209 hour: 0,
3210 dom: 1,
3211 month: 1,
3212 dow: 0,
3213 minute_stamp: 0,
3214 };
3215 for _ in 0..3 {
3216 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
3217 .await
3218 .unwrap();
3219 }
3220
3221 assert_eq!(
3223 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3224 Some(b"1".to_vec())
3225 );
3226 assert_eq!(
3229 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
3230 .await
3231 .unwrap(),
3232 None
3233 );
3234 }
3235
3236 const KV_COUNTER: &[u8] =
3241 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
3242
3243 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3248 async fn function_invoker_runs_target_buffers_and_meters() {
3249 use boatramp_core::deploy::DeployStore;
3250 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
3251 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
3252 use futures::StreamExt;
3253
3254 const HTTP_200: &[u8] =
3257 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3258
3259 let storage = Arc::new(MemStorage::default());
3260 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3261 let deploy = DeployStore::new(storage.clone(), kv.clone());
3262
3263 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3264 let stream: ByteStream =
3265 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3266 deploy.put_blob(&hash, stream).await.unwrap();
3267 let function = Function {
3268 name: "target".into(),
3269 owner: Owner::Project("default".into()),
3270 versions: vec![FunctionVersion {
3271 id: "v1".into(),
3272 component: hash.clone(),
3273 created: 0,
3274 lifecycle: Lifecycle::Independent,
3275 }],
3276 active: "v1".into(),
3277 aliases: Default::default(),
3278 config: Default::default(),
3279 };
3280 deploy
3281 .put_function(ProjectRef::DEFAULT, &function)
3282 .await
3283 .unwrap();
3284
3285 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3286 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
3287 rt.set_invoker(deploy.clone());
3288 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
3289
3290 let request = || InvokeRequest {
3291 method: "GET".into(),
3292 path: "/".into(),
3293 headers: vec![],
3294 body: vec![],
3295 };
3296
3297 let response = invoker.invoke("target", request(), 1).await.unwrap();
3299 assert_eq!(response.status, 200);
3300
3301 let metering = deploy
3303 .get_metering(ProjectRef::DEFAULT, "target")
3304 .await
3305 .unwrap()
3306 .unwrap();
3307 assert_eq!(metering.invocations, 1);
3308
3309 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
3311 assert!(matches!(err, InvokeError::NotFound));
3312 }
3313
3314 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3320 async fn federation_runner_enforces_the_safelist_before_planning() {
3321 use boatramp_core::deploy::DeployStore;
3322 use boatramp_core::project::ProjectRef;
3323 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
3324
3325 let storage = Arc::new(MemStorage::default());
3326 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3327 let deploy = DeployStore::new(storage.clone(), kv.clone());
3328 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3329 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3330 rt.set_invoker(deploy.clone());
3331 let runner = rt
3332 .inner
3333 .as_ref()
3334 .unwrap()
3335 .federation_runner
3336 .get()
3337 .unwrap()
3338 .scoped(ProjectRef::new("default"));
3339
3340 let req = |query: &str| GraphqlRequest {
3341 query: Some(query.to_string()),
3342 persisted_hash: None,
3343 variables: "{}".to_string(),
3344 operation_name: None,
3345 authorization: None,
3346 };
3347
3348 assert!(matches!(
3350 runner.run(req("{ me { id } }"), 1).await,
3351 Err(SupergraphRunError::NotSafelisted)
3352 ));
3353
3354 let query = "{ me { id } }";
3357 let hash = crate::graphql_apq::sha256_hex(query);
3358 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
3359 .await
3360 .unwrap();
3361 assert!(matches!(
3362 runner.run(req(query), 1).await,
3363 Err(SupergraphRunError::PlanFailed(_))
3364 ));
3365
3366 let persisted = GraphqlRequest {
3368 query: None,
3369 persisted_hash: Some("deadbeef".to_string()),
3370 variables: "{}".to_string(),
3371 operation_name: None,
3372 authorization: None,
3373 };
3374 assert!(matches!(
3375 runner.run(persisted, 1).await,
3376 Err(SupergraphRunError::NotSafelisted)
3377 ));
3378 }
3379
3380 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3386 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
3387 use crate::scheduler::{run_scheduler_tick, CronNow};
3388 use boatramp_core::deploy::DeployStore;
3389 use boatramp_core::function::{
3390 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3391 };
3392 use boatramp_handlers::{HandlerEngine, Limits};
3393 use futures::StreamExt;
3394
3395 const HTTP_200: &[u8] =
3396 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3397
3398 let storage = Arc::new(MemStorage::default());
3399 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3400 let deploy = DeployStore::new(storage.clone(), kv.clone());
3401
3402 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3403 let stream: ByteStream =
3404 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3405 deploy.put_blob(&hash, stream).await.unwrap();
3406
3407 let acme = ProjectRef::new("acme");
3409 let function = Function {
3410 name: "worker".into(),
3411 owner: Owner::Project("acme".into()),
3412 versions: vec![FunctionVersion {
3413 id: "v1".into(),
3414 component: hash.clone(),
3415 created: 0,
3416 lifecycle: Lifecycle::Independent,
3417 }],
3418 active: "v1".into(),
3419 aliases: Default::default(),
3420 config: Default::default(),
3421 };
3422 deploy.put_function(acme, &function).await.unwrap();
3423 let inv = Invocation {
3424 id: "inv1".into(),
3425 function: "worker".into(),
3426 version: "v1".into(),
3427 mode: InvokeMode::Async,
3428 status: InvocationStatus::Queued,
3429 idempotency_key: None,
3430 attempts: 0,
3431 lease_expires: None,
3432 request_b64: None,
3433 request_content_type: None,
3434 result: None,
3435 created: 0,
3436 updated: 0,
3437 };
3438 deploy.put_invocation(acme, &inv).await.unwrap();
3439
3440 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3441 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3442 let inner = rt.inner.as_ref().unwrap();
3443
3444 let mut wasm_cache = std::collections::HashMap::new();
3447 let mut cron_state = std::collections::HashMap::new();
3448 let mut sweep = std::collections::HashMap::new();
3449 let now = CronNow {
3450 minute: 0,
3451 hour: 0,
3452 dom: 1,
3453 month: 1,
3454 dow: 0,
3455 minute_stamp: 0,
3456 };
3457 run_scheduler_tick(
3458 inner,
3459 &deploy,
3460 &mut wasm_cache,
3461 &mut cron_state,
3462 &mut sweep,
3463 now,
3464 )
3465 .await
3466 .unwrap();
3467
3468 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
3471 assert_eq!(settled.status, InvocationStatus::Succeeded);
3473 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
3475 assert_eq!(metering.invocations, 1);
3476 assert!(deploy
3478 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
3479 .await
3480 .unwrap()
3481 .is_none());
3482 assert!(deploy
3483 .get_metering(ProjectRef::DEFAULT, "worker")
3484 .await
3485 .unwrap()
3486 .is_none());
3487 }
3488
3489 #[cfg(feature = "handlers")]
3493 async fn poll_invocation_settled(
3494 deploy: &boatramp_core::deploy::DeployStore,
3495 project: ProjectRef<'_>,
3496 function: &str,
3497 id: &str,
3498 ) -> boatramp_core::function::Invocation {
3499 use boatramp_core::function::InvocationStatus;
3500 for _ in 0..200 {
3501 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
3502 if matches!(
3503 inv.status,
3504 InvocationStatus::Succeeded | InvocationStatus::Failed
3505 ) {
3506 return inv;
3507 }
3508 }
3509 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3510 }
3511 panic!("invocation {function}/{id} never settled");
3512 }
3513
3514 #[cfg(feature = "handlers")]
3519 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3520 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
3521 use crate::scheduler::{run_scheduler_tick, CronNow};
3522 use boatramp_core::deploy::DeployStore;
3523 use boatramp_core::function::{
3524 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3525 };
3526 use boatramp_handlers::{HandlerEngine, Limits};
3527 use futures::StreamExt;
3528
3529 const HTTP_200: &[u8] =
3530 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3531
3532 let storage = Arc::new(MemStorage::default());
3533 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3534 let deploy = DeployStore::new(storage.clone(), kv.clone());
3535 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3536 let stream: ByteStream =
3537 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3538 deploy.put_blob(&hash, stream).await.unwrap();
3539
3540 let function = Function {
3541 name: "worker".into(),
3542 owner: Owner::Project("default".into()),
3543 versions: vec![FunctionVersion {
3544 id: "v1".into(),
3545 component: hash.clone(),
3546 created: 0,
3547 lifecycle: Lifecycle::Independent,
3548 }],
3549 active: "v1".into(),
3550 aliases: Default::default(),
3551 config: Default::default(),
3552 };
3553 deploy
3554 .put_function(ProjectRef::DEFAULT, &function)
3555 .await
3556 .unwrap();
3557
3558 let base = Invocation {
3561 id: String::new(),
3562 function: "worker".into(),
3563 version: "v1".into(),
3564 mode: InvokeMode::Async,
3565 status: InvocationStatus::Running,
3566 idempotency_key: None,
3567 attempts: 1,
3568 lease_expires: None,
3569 request_b64: None,
3570 request_content_type: None,
3571 result: None,
3572 created: 0,
3573 updated: 0,
3574 };
3575 let orphan = Invocation {
3576 id: "orphan".into(),
3577 lease_expires: Some(1),
3578 ..base.clone()
3579 };
3580 deploy
3581 .put_invocation(ProjectRef::DEFAULT, &orphan)
3582 .await
3583 .unwrap();
3584 let live = Invocation {
3585 id: "live".into(),
3586 lease_expires: Some(u64::MAX),
3587 ..base.clone()
3588 };
3589 deploy
3590 .put_invocation(ProjectRef::DEFAULT, &live)
3591 .await
3592 .unwrap();
3593
3594 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3595 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3596 let inner = rt.inner.as_ref().unwrap();
3597
3598 let now = CronNow {
3599 minute: 0,
3600 hour: 0,
3601 dom: 1,
3602 month: 1,
3603 dow: 0,
3604 minute_stamp: 0,
3605 };
3606 let mut wasm_cache = std::collections::HashMap::new();
3607 let mut cron_state = std::collections::HashMap::new();
3608 let mut sweep = std::collections::HashMap::new();
3609 run_scheduler_tick(
3610 inner,
3611 &deploy,
3612 &mut wasm_cache,
3613 &mut cron_state,
3614 &mut sweep,
3615 now,
3616 )
3617 .await
3618 .unwrap();
3619
3620 let settled =
3622 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
3623 assert_eq!(settled.status, InvocationStatus::Succeeded);
3624 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
3625 assert_eq!(
3626 settled.lease_expires, None,
3627 "a settled invocation drops its lease"
3628 );
3629 let live_after = deploy
3631 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
3632 .await
3633 .unwrap()
3634 .unwrap();
3635 assert_eq!(live_after.status, InvocationStatus::Running);
3636 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
3637 assert_eq!(live_after.lease_expires, Some(u64::MAX));
3638 }
3639
3640 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3655 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
3656 use boatramp_core::deploy::DeployStore;
3657 use boatramp_core::function::{
3658 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3659 };
3660 use boatramp_handlers::{HandlerEngine, Limits};
3661 use futures::StreamExt;
3662
3663 let storage = Arc::new(MemStorage::default());
3664 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3665 let deploy = DeployStore::new(storage.clone(), kv.clone());
3666
3667 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3670 let stream: ByteStream =
3671 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3672 deploy.put_blob(&hash, stream).await.unwrap();
3673
3674 let store = Function {
3679 name: "store".into(),
3680 owner: Owner::Project("default".into()),
3681 versions: vec![FunctionVersion {
3682 id: "v1".into(),
3683 component: hash.clone(),
3684 created: 0,
3685 lifecycle: Lifecycle::Independent,
3686 }],
3687 active: "v1".into(),
3688 aliases: Default::default(),
3689 config: FunctionConfig {
3690 imports: vec!["wasi:keyvalue".into()],
3691 ..Default::default()
3692 },
3693 };
3694 let acme = ProjectRef::new("acme");
3695 let globex = ProjectRef::new("globex");
3696
3697 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3698 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3699 let inner = rt.inner.as_ref().unwrap();
3700
3701 let request = || {
3702 axum::http::Request::builder()
3703 .method("GET")
3704 .uri("/")
3705 .body(axum::body::Body::empty())
3706 .unwrap()
3707 };
3708
3709 let component = store.resolve(&store.active).unwrap().to_owned();
3712 for project in [acme, globex, ProjectRef::DEFAULT] {
3713 let (response, _) = execute_function(
3714 inner,
3715 &deploy,
3716 project,
3717 &store,
3718 &component,
3719 request(),
3720 0,
3721 boatramp_handlers::Lane::Sync,
3722 crate::function_runtime::FnTenant::Request,
3723 )
3724 .await;
3725 assert!(response.status().is_success(), "invocation should succeed");
3726 }
3727
3728 assert_eq!(
3731 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3732 Some(b"1".to_vec()),
3733 "acme's write must be tenant-qualified"
3734 );
3735 assert_eq!(
3736 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3737 Some(b"1".to_vec()),
3738 "globex's write must be tenant-qualified"
3739 );
3740 assert_eq!(
3741 kv.get("hkv/fn/store/hits").await.unwrap(),
3742 Some(b"1".to_vec()),
3743 "the default project must keep the byte-identical pre-project key"
3744 );
3745 }
3748
3749 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3753 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3754 use boatramp_core::config::{
3755 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3756 };
3757 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3758 use boatramp_handlers::{HandlerEngine, Limits};
3759 use futures::StreamExt;
3760 use std::sync::atomic::Ordering;
3761
3762 let storage = Arc::new(MemStorage::default());
3763 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3764 let deploy = DeployStore::new(storage.clone(), kv.clone());
3765
3766 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3767 let stream: ByteStream =
3768 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3769 deploy.put_blob(&hash, stream).await.unwrap();
3770 let mut files = std::collections::BTreeMap::new();
3771 files.insert(
3772 "counter.wasm".to_string(),
3773 FileEntry {
3774 hash: hash.clone(),
3775 size: KV_COUNTER.len() as u64,
3776 content_type: None,
3777 variants: std::collections::BTreeMap::new(),
3778 },
3779 );
3780 let manifest = Manifest {
3781 files,
3782 config: DeployConfig {
3783 handlers: vec![HandlerConfig {
3784 route: "/".into(),
3785 methods: Vec::new(),
3786 component: "counter.wasm".into(),
3787 imports: vec!["wasi:keyvalue".into()],
3788 streaming: false,
3789 limits: None,
3790 env: std::collections::BTreeMap::new(),
3791 invoke_targets: Vec::new(),
3792 }],
3793 crons: vec![CronConfig {
3794 schedule: "* * * * *".into(),
3795 route: "/".into(),
3796 overlap: Overlap::Skip,
3797 }],
3798 ..Default::default()
3799 },
3800 ..Default::default()
3801 };
3802 let id = deploy.put_manifest(&manifest).await.unwrap();
3803 deploy
3804 .activate(ProjectRef::DEFAULT, "blog", &id)
3805 .await
3806 .unwrap();
3807 deploy
3808 .set_site_config(
3809 ProjectRef::DEFAULT,
3810 "blog",
3811 &SiteConfig {
3812 handlers: Some(HandlersSiteConfig {
3813 enabled: true,
3814 allow_imports: vec!["wasi:keyvalue".into()],
3815 ..Default::default()
3816 }),
3817 ..Default::default()
3818 },
3819 )
3820 .await
3821 .unwrap();
3822
3823 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3824 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3825 let inner = rt.inner.clone().unwrap();
3826 let mut wasm = std::collections::HashMap::new();
3827 let mut crons = std::collections::HashMap::new();
3828 let mut sweep = std::collections::HashMap::new();
3829 let at = |stamp| CronNow {
3830 minute: 0,
3831 hour: 0,
3832 dom: 1,
3833 month: 1,
3834 dow: 0,
3835 minute_stamp: stamp,
3836 };
3837
3838 let (_, handles) =
3840 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3841 .await
3842 .unwrap();
3843 for h in handles {
3844 h.await.unwrap();
3845 }
3846 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3847
3848 let (_, handles) =
3850 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3851 .await
3852 .unwrap();
3853 assert!(handles.is_empty());
3854 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3855
3856 let (_, handles) =
3858 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3859 .await
3860 .unwrap();
3861 for h in handles {
3862 h.await.unwrap();
3863 }
3864 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3865
3866 crons
3870 .get("default|blog|cron|0")
3871 .unwrap()
3872 .running
3873 .store(true, Ordering::Release);
3874 let (_, handles) =
3875 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3876 .await
3877 .unwrap();
3878 assert!(handles.is_empty());
3879 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3880 }
3881
3882 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3886 async fn cron_leader_gate_suppresses_crons_off_leader() {
3887 use boatramp_core::config::{
3888 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3889 };
3890 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3891 use boatramp_handlers::{HandlerEngine, Limits};
3892 use futures::StreamExt;
3893
3894 let storage = Arc::new(MemStorage::default());
3895 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3896 let deploy = DeployStore::new(storage.clone(), kv.clone());
3897
3898 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3899 let stream: ByteStream =
3900 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3901 deploy.put_blob(&hash, stream).await.unwrap();
3902 let mut files = std::collections::BTreeMap::new();
3903 files.insert(
3904 "counter.wasm".to_string(),
3905 FileEntry {
3906 hash: hash.clone(),
3907 size: KV_COUNTER.len() as u64,
3908 content_type: None,
3909 variants: std::collections::BTreeMap::new(),
3910 },
3911 );
3912 let manifest = Manifest {
3913 files,
3914 config: DeployConfig {
3915 handlers: vec![HandlerConfig {
3916 route: "/".into(),
3917 methods: Vec::new(),
3918 component: "counter.wasm".into(),
3919 imports: vec!["wasi:keyvalue".into()],
3920 streaming: false,
3921 limits: None,
3922 env: std::collections::BTreeMap::new(),
3923 invoke_targets: Vec::new(),
3924 }],
3925 crons: vec![CronConfig {
3926 schedule: "* * * * *".into(),
3927 route: "/".into(),
3928 overlap: Overlap::Skip,
3929 }],
3930 ..Default::default()
3931 },
3932 ..Default::default()
3933 };
3934 let id = deploy.put_manifest(&manifest).await.unwrap();
3935 deploy
3936 .activate(ProjectRef::DEFAULT, "blog", &id)
3937 .await
3938 .unwrap();
3939 deploy
3940 .set_site_config(
3941 ProjectRef::DEFAULT,
3942 "blog",
3943 &SiteConfig {
3944 handlers: Some(HandlersSiteConfig {
3945 enabled: true,
3946 allow_imports: vec!["wasi:keyvalue".into()],
3947 ..Default::default()
3948 }),
3949 ..Default::default()
3950 },
3951 )
3952 .await
3953 .unwrap();
3954
3955 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3956 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3957 rt.set_cron_leader_gate(Arc::new(|| false));
3959 let inner = rt.inner.clone().unwrap();
3960 let mut wasm = std::collections::HashMap::new();
3961 let mut crons = std::collections::HashMap::new();
3962 let mut sweep = std::collections::HashMap::new();
3963 let now = CronNow {
3964 minute: 0,
3965 hour: 0,
3966 dom: 1,
3967 month: 1,
3968 dow: 0,
3969 minute_stamp: 100,
3970 };
3971
3972 let (_, handles) =
3973 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3974 .await
3975 .unwrap();
3976 assert!(handles.is_empty(), "a non-leader must not fire crons");
3978 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3979 }
3980
3981 #[tokio::test]
3988 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3989 use boatramp_core::config::HandlersSiteConfig;
3990 use boatramp_core::project::ProjectRef;
3991 use boatramp_handlers::{HandlerEngine, Limits};
3992
3993 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3994 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3995 let sql_dir =
3997 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3998 let _ = std::fs::remove_dir_all(&sql_dir);
3999 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
4000 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
4001
4002 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4003 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
4004 let inner = rt.inner.as_ref().unwrap();
4005
4006 let site = HandlersSiteConfig {
4010 enabled: true,
4011 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
4012 tenancy: Some(boatramp_core::tenancy::Tenancy::Disabled),
4013 ..Default::default()
4014 };
4015 let env = std::collections::BTreeMap::new();
4016 let build = |imports: &[&str]| {
4017 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
4018 let site = &site;
4019 let env = &env;
4020 async move {
4021 crate::handler_dispatch::build_bindings(
4022 inner,
4023 ProjectRef::new("default"),
4024 "shop",
4025 "shop",
4026 None,
4027 &imports,
4028 site,
4029 env,
4030 &[],
4031 0,
4032 None,
4033 None,
4034 None,
4035 )
4036 .await
4037 .expect("no secrets → resolves")
4038 .sql_database_names()
4039 }
4040 };
4041
4042 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
4045 assert_eq!(
4047 build(&["sql", "sql:*"]).await,
4048 vec!["", "privileged", "product"]
4049 );
4050 assert!(build(&["sql:secret"]).await.is_empty());
4052 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
4054
4055 let _ = std::fs::remove_dir_all(&sql_dir);
4056 }
4057}