1use std::future::Future;
15use std::net::{IpAddr, SocketAddr};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::body::Body;
20use axum::extract::{ConnectInfo, Path, Query, Request, State};
21use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{any, get, post, put};
24use axum::{Extension, Json, Router};
25use boatramp_core::access::{AccessConfig, BasicAuth};
26use boatramp_core::authz::{GrantedRole, TokenMeta};
27use boatramp_core::config::{DeployConfig, SiteConfig};
28use boatramp_core::cose::{self, Claims, Signer};
29use boatramp_core::deploy::{
30 DeployMetaInput, DeployStore, FileEntry, GcOptions, GcReport, Manifest,
31};
32use boatramp_core::matcher::Pattern;
33use boatramp_core::route::{self, Outcome};
34use boatramp_core::{DeployError, StorageError};
35use futures::StreamExt;
36use serde::{Deserialize, Serialize};
37
38mod admin_api;
39pub mod sql_shim;
40#[cfg(feature = "oidc")]
41pub(crate) use admin_api::auth_exchange;
42pub(crate) use admin_api::{
43 activate_deployment, cert_status, compute_dns, compute_dns_resolve, compute_exec, compute_ipam,
44 compute_netdiag, compute_reconcile, compute_restart, compute_set_health, compute_status,
45 create_deployment, current_deployment, delete_compute, delete_compute_volume, delete_site,
46 get_compute, get_daemon_config, get_deployment, get_site_config, invalidate_cache,
47 list_aliases, list_compute, list_compute_volumes, list_deployments, list_sites, prune_delete,
48 prune_report, put_blob, put_compute, put_daemon_config, put_site_config, remove_alias,
49 rollback_daemon_config, scrub_blobs, set_alias, sql_exec, sql_ping, sql_query,
50};
51#[cfg(feature = "handlers")]
52pub(crate) use admin_api::{
53 delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
54 list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
55 put_graphql_subgraph, register_graphql_safelist,
56};
57#[cfg(feature = "admin")]
59mod admin_controller;
60mod auth;
61#[cfg(feature = "console")]
62pub mod console;
63mod content;
64mod control_api;
65#[cfg(feature = "email")]
68mod email_spool;
69#[cfg(feature = "admin")]
70pub use admin_controller::ServerAdminController;
71#[cfg(feature = "compression")]
72pub(crate) use content::maybe_compress;
73pub(crate) use content::multipart_byteranges;
74pub(crate) use content::{
75 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
76};
77pub(crate) use control_api::{
78 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
79 cluster_revoke, cluster_rotate_key, create_join_token, create_token, delete_email_profile,
80 delete_secret, get_authz_policy, list_email_profiles, list_root_anchors, list_secrets,
81 list_tokens, put_authz_policy, remove_root_anchor, revoke_token, set_email_profile, set_secret,
82 show_email_profile,
83};
84#[cfg(all(test, feature = "handlers"))]
85use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
86#[cfg(feature = "email")]
87pub use email_spool::NodeEmailSpool;
88mod domain_verify;
89pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
90pub mod envelope;
91#[cfg(feature = "handlers")]
92mod graphql_apq;
93#[cfg(feature = "handlers")]
94mod graphql_cache;
95#[cfg(feature = "handlers")]
96mod graphql_data;
97#[cfg(feature = "handlers")]
98mod graphql_federation;
99#[cfg(feature = "handlers")]
100mod graphql_gateway;
101#[cfg(feature = "handlers")]
102mod graphql_graphiql;
103#[cfg(feature = "handlers")]
104mod graphql_guard;
105#[cfg(feature = "handlers")]
106mod graphql_plan;
107#[cfg(feature = "handlers")]
108mod graphql_registry;
109#[cfg(feature = "handlers")]
110mod graphql_subscription;
111#[cfg(feature = "handlers")]
112mod handler_cache;
113#[cfg(feature = "handlers")]
114mod handler_dispatch;
115#[cfg(feature = "handlers")]
116pub(crate) use handler_dispatch::{
117 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
118 read_blob_fully, resolve_secret_env,
119};
120#[cfg(all(feature = "handlers", test))]
121use handler_dispatch::{resolve_env, set_forwarded_headers};
122mod function_api;
123pub(crate) use function_api::{
124 alias_function, deploy_function, list_functions, remove_function, rollback_function,
125};
126#[cfg(feature = "handlers")]
131pub use function_api::{
132 component_requires, host_capability_features, host_capability_features_detailed, unmet_requires,
133};
134#[cfg(all(test, feature = "handlers"))]
135use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
136pub use function_api::{CapabilityFeature, Lifecycle};
139mod gateway;
140mod host;
141pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
142#[cfg(feature = "http3")]
143mod http3;
144mod limits;
145#[cfg(feature = "handlers")]
146mod logs;
147#[cfg(feature = "handlers")]
148mod metrics;
149#[cfg(feature = "oidc")]
150mod oidc;
151mod operator;
152pub(crate) use operator::prometheus_metrics;
153#[cfg(feature = "handlers")]
154pub(crate) use operator::{
155 operator_dlq, operator_function_logs, operator_function_logs_stream, operator_handler_stats,
156 operator_logs, operator_logs_stream,
157};
158mod proxy;
159pub use proxy::spawn_compute_reconcile;
160pub(crate) use proxy::{
161 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
162 proxy, COMPUTE_WAKE_TIMEOUT,
163};
164mod splice;
165mod http_serve;
168pub use http_serve::{
169 alpn_h1_h2, serve_plaintext, serve_plaintext_listener, serve_router_conn, serve_tls,
170 serve_tls_listener, ReloadableTls, ServeInput,
171};
172#[cfg(feature = "handlers")]
174pub(crate) use proxy::is_upgrade_request;
175#[cfg(all(test, feature = "handlers"))]
176use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
177mod project_api;
178pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
179mod project_scope;
180pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
181mod ratelimit;
182mod routes;
183pub use routes::{router, router_with, router_with_fast};
184#[cfg(feature = "mcp")]
185mod mcp_http;
186#[cfg(feature = "handlers")]
187mod scheduler;
188mod serve_pipeline;
189pub use serve_pipeline::{http_redirect_router, FastServe};
190#[cfg(test)]
191mod hotpath_test;
192#[cfg(all(test, feature = "handlers"))]
193use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
194pub(crate) use serve_pipeline::{
195 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
196 BootstrapAttestation,
197};
198pub mod signer;
201mod srvmetrics;
202#[cfg(all(feature = "handlers", test))]
203use scheduler::run_scheduler_tick;
204#[cfg(feature = "handlers")]
205pub(crate) use scheduler::{
206 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
207};
208#[cfg(feature = "handlers")]
209use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
210#[cfg(feature = "handlers")]
211mod function_runtime;
212#[cfg(feature = "handlers")]
213pub(crate) use function_runtime::{
214 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
215 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
216 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
217 put_trigger_handler, webhook_ingress,
218};
219#[cfg(feature = "handlers")]
220mod stream;
221#[cfg(feature = "handlers")]
222mod workflow;
223pub use auth::{require_auth, Auth};
224#[cfg(feature = "http3")]
225pub use http3::{
226 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
227 Http3Error,
228};
229pub use limits::{ServerLimits, UploadGuard};
230#[cfg(feature = "oidc")]
231pub use oidc::{OidcConfig, OidcError, OidcVerifier};
232use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
233#[cfg(feature = "handlers")]
234pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
235#[cfg(feature = "handlers")]
236pub(crate) use workflow::{
237 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
238 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
239};
240pub use srvmetrics::{server_metrics, ServerMetrics};
243
244#[derive(Clone, Default)]
249pub struct HandlerRuntime {
250 #[cfg(feature = "handlers")]
251 inner: Option<Arc<HandlerRuntimeInner>>,
252}
253
254#[cfg(feature = "handlers")]
255struct HandlerRuntimeInner {
256 engine: boatramp_handlers::HandlerEngine,
257 async_drain_gate: Arc<tokio::sync::Semaphore>,
263 kv: Arc<dyn boatramp_core::kv::KvStore>,
264 storage: Arc<dyn boatramp_core::Storage>,
265 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
269 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
272 site_semaphores:
275 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
276 stream_semaphores:
280 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
281 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
284 metrics: metrics::Metrics,
287 logs: Arc<logs::LogStore>,
289 #[cfg(feature = "handlers")]
293 graphql_cache: graphql_cache::GraphqlCache,
294 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
300 max_blob_bytes: std::sync::OnceLock<u64>,
304 max_component_bytes: std::sync::OnceLock<u64>,
309 allow_env_secret_refs: std::sync::OnceLock<bool>,
318 secret_store: std::sync::OnceLock<Arc<boatramp_core::secret_store::SecretStore>>,
324 function_meter_locks:
328 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
329 function_semaphores:
332 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
333 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
338 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
342 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
350 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
355 #[cfg(feature = "email")]
360 email_profile_store: std::sync::OnceLock<Arc<boatramp_core::email_config::EmailProfileStore>>,
361 #[cfg(feature = "email")]
366 email_spool: std::sync::OnceLock<Arc<dyn boatramp_handlers::EmailSpool>>,
367 #[cfg(feature = "admin")]
371 admin_controller: std::sync::OnceLock<Arc<admin_controller::ServerAdminController>>,
372 #[cfg(feature = "admin")]
376 admin_surfaces:
377 std::sync::OnceLock<std::collections::BTreeSet<boatramp_handlers::AdminSurface>>,
378}
379
380pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
383
384impl HandlerRuntime {
385 pub fn disabled() -> Self {
387 Self::default()
388 }
389
390 #[cfg(feature = "handlers")]
396 pub fn new(
397 engine: boatramp_handlers::HandlerEngine,
398 kv: Arc<dyn boatramp_core::kv::KvStore>,
399 storage: Arc<dyn boatramp_core::Storage>,
400 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
401 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
402 ) -> Self {
403 let async_drain_slots = engine.async_max_concurrency().max(1);
406 Self {
407 inner: Some(Arc::new(HandlerRuntimeInner {
408 engine,
409 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
410 kv,
411 storage,
412 sql,
413 messaging,
414 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
415 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
416 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
417 metrics: metrics::Metrics::default(),
418 logs: Arc::new(logs::LogStore::default()),
419 #[cfg(feature = "handlers")]
420 graphql_cache: graphql_cache::GraphqlCache::default(),
421 cron_leader_gate: std::sync::OnceLock::new(),
422 max_blob_bytes: std::sync::OnceLock::new(),
423 max_component_bytes: std::sync::OnceLock::new(),
424 allow_env_secret_refs: std::sync::OnceLock::new(),
425 secret_store: std::sync::OnceLock::new(),
426 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
427 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
428 watch_provider: std::sync::OnceLock::new(),
429 provision_tier: std::sync::OnceLock::new(),
430 invoker: std::sync::OnceLock::new(),
431 federation_runner: std::sync::OnceLock::new(),
432 #[cfg(feature = "email")]
433 email_profile_store: std::sync::OnceLock::new(),
434 #[cfg(feature = "email")]
435 email_spool: std::sync::OnceLock::new(),
436 #[cfg(feature = "admin")]
437 admin_controller: std::sync::OnceLock::new(),
438 #[cfg(feature = "admin")]
439 admin_surfaces: std::sync::OnceLock::new(),
440 })),
441 }
442 }
443
444 #[cfg(feature = "handlers")]
447 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
448 self.inner.as_ref().and_then(|inner| inner.sql.clone())
449 }
450
451 #[cfg(feature = "handlers")]
455 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
456 self.inner
457 .as_ref()
458 .and_then(|inner| inner.invoker.get().cloned())
459 }
460
461 #[cfg(feature = "handlers")]
465 pub(crate) async fn introspect_subgraph_sdl(
466 &self,
467 deploy: &DeployStore,
468 project: boatramp_core::project::ProjectRef<'_>,
469 function: &boatramp_core::function::Function,
470 component: &str,
471 ) -> Result<String, function_runtime::SubgraphSdlError> {
472 match self.inner.as_ref() {
473 Some(inner) => {
474 function_runtime::introspect_service_sdl(
475 inner, deploy, project, function, component,
476 )
477 .await
478 }
479 None => Err(function_runtime::SubgraphSdlError::Unavailable),
480 }
481 }
482
483 #[cfg(feature = "handlers")]
489 pub fn set_invoker(&self, deploy: DeployStore) {
490 if let Some(inner) = self.inner.as_ref() {
491 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
492 deploy,
493 Arc::downgrade(inner),
494 ));
495 let _ = inner.invoker.set(invoker);
496 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
499 inner,
500 )));
501 let _ = inner.federation_runner.set(runner);
502 }
503 }
504
505 #[cfg(feature = "handlers")]
509 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
510 self.inner.as_ref().and_then(|inner| inner.sql.clone())
511 }
512
513 #[cfg(feature = "handlers")]
517 pub fn set_watch_provider(
518 &self,
519 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
520 ) {
521 if let Some(inner) = self.inner.as_ref() {
522 let _ = inner.watch_provider.set(provider);
523 }
524 }
525
526 #[cfg(feature = "handlers")]
530 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
531 if let Some(inner) = self.inner.as_ref() {
532 let _ = inner.provision_tier.set(tier);
533 }
534 }
535
536 #[cfg(feature = "handlers")]
540 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
541 if let Some(inner) = self.inner.as_ref() {
542 let _ = inner.max_blob_bytes.set(max_bytes);
543 }
544 }
545
546 #[cfg(feature = "handlers")]
549 pub fn set_max_component_bytes(&self, max_bytes: u64) {
550 if let Some(inner) = self.inner.as_ref() {
551 let _ = inner.max_component_bytes.set(max_bytes);
552 }
553 }
554
555 #[cfg(feature = "handlers")]
563 pub fn set_allow_env_secret_refs(&self, allow: bool) {
564 if let Some(inner) = self.inner.as_ref() {
565 let _ = inner.allow_env_secret_refs.set(allow);
566 }
567 }
568
569 #[cfg(feature = "handlers")]
574 pub fn set_secret_store(&self, store: Arc<boatramp_core::secret_store::SecretStore>) {
575 if let Some(inner) = self.inner.as_ref() {
576 let _ = inner.secret_store.set(store);
577 }
578 }
579
580 #[cfg(feature = "email")]
584 pub fn set_email_profile_store(
585 &self,
586 store: Arc<boatramp_core::email_config::EmailProfileStore>,
587 ) {
588 if let Some(inner) = self.inner.as_ref() {
589 let _ = inner.email_profile_store.set(store);
590 }
591 }
592
593 #[cfg(feature = "email")]
598 pub fn set_email_spool(&self, spool: Arc<dyn boatramp_handlers::EmailSpool>) {
599 if let Some(inner) = self.inner.as_ref() {
600 let _ = inner.email_spool.set(spool);
601 }
602 }
603
604 #[cfg(feature = "admin")]
609 pub fn set_admin(
610 &self,
611 controller: Arc<admin_controller::ServerAdminController>,
612 surfaces: std::collections::BTreeSet<boatramp_handlers::AdminSurface>,
613 ) {
614 if let Some(inner) = self.inner.as_ref() {
615 let _ = inner.admin_controller.set(controller);
616 let _ = inner.admin_surfaces.set(surfaces);
617 }
618 }
619
620 #[cfg(feature = "handlers")]
625 pub(crate) fn allow_env_secret_refs(&self) -> bool {
626 self.inner
627 .as_ref()
628 .and_then(|inner| inner.allow_env_secret_refs.get().copied())
629 .unwrap_or(false)
630 }
631
632 #[cfg(feature = "handlers")]
637 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
638 if let Some(inner) = self.inner.as_ref() {
639 let _ = inner.cron_leader_gate.set(gate);
640 }
641 }
642
643 #[cfg(feature = "handlers")]
650 async fn precheck_activation(
651 &self,
652 deploy: &DeployStore,
653 manifest: &Manifest,
654 site_config: Option<&SiteConfig>,
655 ) -> Result<(), String> {
656 let Some(inner) = self.inner.as_ref() else {
657 return Ok(());
658 };
659 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
662 return Ok(());
663 }
664 let site_handlers = site_config
666 .and_then(|c| c.handlers.as_ref())
667 .filter(|h| h.enabled)
668 .ok_or_else(|| {
669 "deployment ships handlers/consumers but the site has them disabled".to_string()
670 })?;
671 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
672
673 let allow_env_secret_refs = inner.allow_env_secret_refs.get().copied().unwrap_or(false);
680 crate::handler_dispatch::admit_secret_refs(&site_handlers.secrets, allow_env_secret_refs)
681 .map_err(|err| format!("handler secrets: {err}"))?;
682
683 let sync_ceiling = inner.engine.sync_timeout_ms();
689 let async_ceiling = inner.engine.async_timeout_ms();
690 if let Some(ms) = site_handlers.max_timeout_ms {
691 if u64::from(ms) > sync_ceiling {
692 tracing::warn!(
693 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
694 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
695 applies only to async calls (?mode=async / triggers), capped at \
696 async_max_timeout_ms={async_ceiling}"
697 );
698 }
699 }
700
701 for handler in &manifest.config.handlers {
703 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
704 if u64::from(ms) > sync_ceiling {
705 let route = &handler.route;
706 tracing::warn!(
707 "route {route:?} declares limits.timeout_ms={ms}, above \
708 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
709 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
710 (?mode=async / a queue trigger / a #[consumer]), capped at \
711 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
712 that isn't possible — move the work to the async lane"
713 );
714 }
715 }
716 if !handler.streaming {
721 if let Some(entry) = manifest.files.get(&handler.component) {
722 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
723 if crate::function_api::component_declares_streaming_route(
724 &bytes,
725 &handler.route,
726 ) {
727 let route = &handler.route;
728 tracing::warn!(
729 "route {route:?} is a streaming handler (#[handler(stream)]) but \
730 its config lacks streaming = true: it will run on the sync request \
731 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
732 streaming = true so it serves on the dedicated streaming lane (its \
733 own concurrency budget + a much larger wall-clock)."
734 );
735 }
736 }
737 }
738 }
739 if let Some(entry) = manifest.files.get(&handler.component) {
743 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
744 let unmet = crate::function_api::unmet_requires(&bytes);
745 if !unmet.is_empty() {
746 return Err(format!(
747 "route {:?} [{}] requires capabilities this host does not implement: \
748 {}. Upgrade boatramp or enable those features — see `boatramp \
749 capabilities`.",
750 handler.route,
751 handler.methods.join(","),
752 unmet.join(", ")
753 ));
754 }
755 }
756 }
757 precheck_component(
758 deploy,
759 manifest,
760 site_handlers,
761 inner,
762 max_component,
763 &handler.imports,
764 &handler.component,
765 &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
768 false,
769 )
770 .await?;
771 }
772 for consumer in &manifest.config.consumers {
773 precheck_component(
774 deploy,
775 manifest,
776 site_handlers,
777 inner,
778 max_component,
779 &consumer.imports,
780 &consumer.component,
781 &format!("consumer {:?}", consumer.topic),
782 true,
783 )
784 .await?;
785 }
786 Ok(())
787 }
788
789 #[cfg(not(feature = "handlers"))]
790 async fn precheck_activation(
791 &self,
792 _deploy: &DeployStore,
793 _manifest: &Manifest,
794 _site_config: Option<&SiteConfig>,
795 ) -> Result<(), String> {
796 Ok(())
797 }
798}
799
800#[derive(Default, Clone)]
806pub struct ServerOptions {
807 pub limits: ServerLimits,
809 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
812 pub default_site: Option<String>,
815 pub implicit_routing: bool,
821 pub protect_previews: bool,
826 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
830 pub issuer: Option<Arc<dyn Signer>>,
834 pub bootstrap_secret: Option<String>,
839 pub bootstrap_attestation: Option<String>,
845 pub mesh_control: Option<Arc<dyn MeshControl>>,
849 pub cors_allowed_origins: Vec<String>,
859 #[cfg(feature = "oidc")]
862 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
863 pub posture: boatramp_core::security::SecurityPosture,
867 pub served_over_tls: bool,
872 pub pop_origin: Option<String>,
879 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
883 pub operator_sql: Option<Arc<dyn boatramp_core::sql::OperatorSql>>,
887 pub tenant_deprovisioner: Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>,
892 pub compute_exec: Option<Arc<dyn boatramp_core::compute::ComputeExec>>,
896 pub compute_volumes: Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>,
901 pub compute_control: Option<Arc<dyn boatramp_core::compute::ComputeControl>>,
905 pub secret_store: Option<Arc<boatramp_core::secret_store::SecretStore>>,
913 pub email_profile_store: Option<Arc<boatramp_core::email_config::EmailProfileStore>>,
921 #[cfg(feature = "console")]
925 pub console: Option<console::ConsoleMount>,
926}
927
928#[derive(Clone, Copy)]
932struct ServedOverTls(bool);
933
934#[derive(Clone, Copy, Default)]
939struct ImplicitRouting(bool);
940
941const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
953
954pub struct DaemonRuntime {
955 baseline: boatramp_core::daemon_config::ConfigBaseline,
956 state: std::sync::RwLock<DaemonState>,
957 reload: tokio::sync::Notify,
960}
961
962struct DaemonState {
963 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
964 generation: Option<String>,
965}
966
967pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
972 #[cfg(feature = "console")]
976 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
977 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
978 None => (false, None, None),
979 };
980 #[cfg(not(feature = "console"))]
981 let (console_enabled, console_host, console_path) = (false, None, None);
982 boatramp_core::daemon_config::ConfigBaseline {
983 default_site: options.default_site.clone(),
984 protect_previews: options.protect_previews,
985 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
986 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
987 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
988 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
989 compute_vcpus: 0,
990 compute_mem_mib: 0,
991 console_enabled,
992 console_host,
993 console_path,
994 max_upload_ceiling: options.posture.max_upload_bytes,
995 max_concurrent_uploads_ceiling: None,
996 posture: options.posture,
997 }
998}
999
1000impl DaemonRuntime {
1001 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
1005 let effective =
1006 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
1007 Self {
1008 baseline,
1009 state: std::sync::RwLock::new(DaemonState {
1010 effective,
1011 generation: None,
1012 }),
1013 reload: tokio::sync::Notify::new(),
1014 }
1015 }
1016
1017 pub fn notify_reload(&self) {
1021 self.reload.notify_one();
1022 }
1023
1024 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
1026 self.state
1027 .read()
1028 .expect("daemon config lock")
1029 .effective
1030 .clone()
1031 }
1032
1033 pub fn generation(&self) -> Option<String> {
1036 self.state
1037 .read()
1038 .expect("daemon config lock")
1039 .generation
1040 .clone()
1041 }
1042
1043 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
1045 &self.baseline
1046 }
1047
1048 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
1051 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
1052 let generation = deploy.daemon_config_generation().await?;
1053 let effective = Arc::new(cfg.resolve(&self.baseline));
1054 *self.state.write().expect("daemon config lock") = DaemonState {
1055 effective,
1056 generation,
1057 };
1058 Ok(())
1059 }
1060}
1061
1062#[derive(Clone, Copy, Default)]
1065struct PreviewPolicy {
1066 protect: bool,
1067}
1068
1069#[derive(Clone, Default)]
1074struct Issuer(Option<Arc<dyn Signer>>);
1075
1076#[derive(Clone, Default)]
1081struct BootstrapGate(Option<Arc<BootstrapInner>>);
1082
1083struct BootstrapInner {
1084 secret_hash: String,
1087 lock: tokio::sync::Mutex<()>,
1090}
1091
1092impl BootstrapGate {
1093 fn new(secret: Option<&str>) -> Self {
1094 Self(secret.filter(|s| !s.is_empty()).map(|s| {
1095 Arc::new(BootstrapInner {
1096 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
1097 lock: tokio::sync::Mutex::new(()),
1098 })
1099 }))
1100 }
1101}
1102
1103#[async_trait::async_trait]
1107pub trait MeshControl: Send + Sync {
1108 async fn admit(
1116 &self,
1117 mesh_pubkey_hex: &str,
1118 jti: &str,
1119 possession_proof: &[u8],
1120 proof_iat: u64,
1121 now: u64,
1122 advertise_addr: Option<&str>,
1123 ) -> Result<JoinOutcome, String>;
1124
1125 async fn rotate_key(&self) -> Result<String, String>;
1129
1130 async fn revoke(&self, node: u64) -> Result<(), String>;
1134
1135 async fn members(&self) -> Result<Vec<MeshMember>, String>;
1139
1140 async fn promote(&self, node: u64) -> Result<(), String>;
1143}
1144
1145pub enum JoinOutcome {
1147 Admitted {
1150 members: Vec<String>,
1152 addrs: std::collections::BTreeMap<u64, String>,
1154 },
1155 TokenSpent,
1157 ProofInvalid,
1159 Revoked,
1162}
1163
1164#[derive(Debug, Clone, Serialize)]
1166pub struct MeshMember {
1167 pub node: u64,
1169 pub voter: bool,
1171 pub caught_up: bool,
1173 pub leader: bool,
1175 #[serde(default, skip_serializing_if = "Option::is_none")]
1179 pub addr: Option<String>,
1180}
1181
1182#[derive(Clone, Default)]
1185struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
1186
1187#[cfg(feature = "oidc")]
1189#[derive(Clone, Default)]
1190struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1191
1192#[cfg(feature = "oidc")]
1195const EXCHANGE_TTL_SECS: u64 = 3600;
1196
1197use boatramp_core::time::now_unix;
1198
1199#[derive(Clone)]
1201struct CorsState(Arc<Vec<String>>);
1202
1203const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1205const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1208const CORS_MAX_AGE: &str = "600";
1210
1211fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1215 allowed.iter().any(|a| a == "*" || a == origin)
1216}
1217
1218async fn cors(
1225 State(allowed): State<CorsState>,
1226 request: Request,
1227 next: axum::middleware::Next,
1228) -> Response {
1229 let origin = request
1230 .headers()
1231 .get(header::ORIGIN)
1232 .and_then(|v| v.to_str().ok())
1233 .filter(|o| cors_origin_allowed(&allowed.0, o))
1234 .map(str::to_string);
1235 let is_preflight = request.method() == Method::OPTIONS
1237 && request
1238 .headers()
1239 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1240 if is_preflight {
1241 let allow_headers = request
1243 .headers()
1244 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1245 .and_then(|v| v.to_str().ok())
1246 .map(str::to_string)
1247 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1248 let mut response = Response::new(Body::empty());
1249 *response.status_mut() = StatusCode::NO_CONTENT;
1250 if let Some(origin) = origin {
1251 let headers = response.headers_mut();
1252 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1253 set_header(headers, header::VARY, "Origin");
1254 set_header(
1255 headers,
1256 header::ACCESS_CONTROL_ALLOW_METHODS,
1257 CORS_ALLOW_METHODS,
1258 );
1259 set_header(
1260 headers,
1261 header::ACCESS_CONTROL_ALLOW_HEADERS,
1262 &allow_headers,
1263 );
1264 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1265 }
1266 return response;
1267 }
1268 let mut response = next.run(request).await;
1269 if let Some(origin) = origin {
1270 let headers = response.headers_mut();
1271 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1272 if let Ok(value) = HeaderValue::from_str("Origin") {
1275 headers.append(header::VARY, value);
1276 }
1277 }
1278 response
1279}
1280
1281const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1286
1287#[derive(Debug, thiserror::Error)]
1289pub enum ServeError {
1290 #[error("server I/O: {0}")]
1292 Io(#[from] std::io::Error),
1293}
1294
1295pub async fn serve(
1298 addr: SocketAddr,
1299 deploy: DeployStore,
1300 auth: Auth,
1301 handlers: HandlerRuntime,
1302) -> Result<(), ServeError> {
1303 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1304}
1305
1306pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1316 if let Err(err) = stream.set_nodelay(true) {
1317 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1318 }
1319}
1320
1321pub async fn serve_with(
1323 addr: SocketAddr,
1324 deploy: DeployStore,
1325 auth: Auth,
1326 handlers: HandlerRuntime,
1327 options: ServerOptions,
1328) -> Result<(), ServeError> {
1329 let tcp = tokio::net::TcpListener::bind(addr).await?;
1330 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1331 let splice_ctx = splice::SpliceCtx {
1337 deploy: deploy.clone(),
1338 posture: options.posture,
1339 daemon: options.daemon_runtime.clone(),
1340 };
1341 #[cfg(feature = "handlers")]
1344 let scheduler = handlers.spawn_scheduler(deploy.clone());
1345 let gateway_prober = gateway::spawn_active_health_prober();
1349 let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1352
1353 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1357 let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1361 shutdown_signal().await;
1362 let _ = signalled_tx.send(true);
1363 });
1364 let signalled = {
1365 let mut rx = signalled_rx;
1366 async move {
1367 let _ = rx.wait_for(|fired| *fired).await;
1368 }
1369 };
1370 let result = serve_with_drain_deadline(
1371 async move { server.await.map_err(ServeError::from) },
1372 signalled,
1373 DRAIN_DEADLINE,
1374 )
1375 .await;
1376 #[cfg(feature = "handlers")]
1378 if let Some(handle) = scheduler {
1379 handle.abort();
1380 }
1381 gateway_prober.abort();
1382 result
1383}
1384
1385async fn serve_with_drain_deadline<Srv, Sig>(
1390 server: Srv,
1391 signalled: Sig,
1392 deadline: Duration,
1393) -> Result<(), ServeError>
1394where
1395 Srv: Future<Output = Result<(), ServeError>>,
1396 Sig: Future<Output = ()>,
1397{
1398 tokio::pin!(server);
1399 let drain_cap = async move {
1400 signalled.await;
1401 tokio::time::sleep(deadline).await;
1402 };
1403 tokio::select! {
1404 result = &mut server => result,
1405 _ = drain_cap => {
1406 tracing::warn!(
1407 deadline_s = deadline.as_secs(),
1408 "drain deadline exceeded; forcing shutdown with requests still in flight"
1409 );
1410 Ok(())
1411 }
1412 }
1413}
1414
1415pub async fn shutdown_signal() {
1418 let ctrl_c = async {
1419 let _ = tokio::signal::ctrl_c().await;
1420 };
1421 #[cfg(unix)]
1422 let terminate = async {
1423 if let Ok(mut sig) =
1424 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1425 {
1426 sig.recv().await;
1427 }
1428 };
1429 #[cfg(not(unix))]
1430 let terminate = std::future::pending::<()>();
1431
1432 tokio::select! {
1433 _ = ctrl_c => {}
1434 _ = terminate => {}
1435 }
1436 tracing::info!("shutdown signal received; draining");
1437}
1438
1439async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1443 match daemon.generation() {
1444 Some(gen) => format!("ok gen={gen}"),
1445 None => "ok".to_string(),
1446 }
1447}
1448
1449async fn readyz(State(deploy): State<DeployStore>) -> Response {
1451 match deploy.ready().await {
1452 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1453 Err(err) => {
1454 tracing::warn!(error = %err, "readiness probe failed");
1455 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1456 }
1457 }
1458}
1459
1460#[derive(Clone)]
1465pub struct RequestId(pub String);
1466
1467fn request_id_for(headers: &HeaderMap) -> String {
1470 if let Some(id) = headers
1471 .get("x-request-id")
1472 .and_then(|v| v.to_str().ok())
1473 .map(str::trim)
1474 .filter(|s| !s.is_empty())
1475 {
1476 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1477 }
1478 use std::sync::atomic::{AtomicU64, Ordering};
1479 static SEQ: AtomicU64 = AtomicU64::new(0);
1480 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1481 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1482}
1483
1484struct AccessLog {
1488 request_id: String,
1489 method: Method,
1490 path: String,
1491 host: String,
1492 client: String,
1493 status: u16,
1494 encoding: String,
1496 start: std::time::Instant,
1497 bytes: std::sync::atomic::AtomicU64,
1498}
1499
1500impl Drop for AccessLog {
1501 fn drop(&mut self) {
1502 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1503 srvmetrics::server_metrics().record_request(self.status, bytes);
1506 tracing::info!(
1507 target: "boatramp::access",
1508 request_id = %self.request_id,
1509 method = %self.method,
1510 path = %self.path,
1511 host = %self.host,
1512 client = %self.client,
1513 status = self.status,
1514 bytes = bytes,
1515 encoding = %self.encoding,
1516 cache_result = srvmetrics::cache_result(self.status),
1517 elapsed_ms = self.start.elapsed().as_millis() as u64,
1518 "request"
1519 );
1520 }
1521}
1522
1523pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1529 let request_id = request_id_for(request.headers());
1530 request
1531 .extensions_mut()
1532 .insert(RequestId(request_id.clone()));
1533 request_id
1534}
1535
1536pub(crate) struct AccessLogCtx {
1541 request_id: String,
1542 method: Method,
1543 path: String,
1544 host: String,
1545 client: String,
1546 start: std::time::Instant,
1547}
1548
1549impl AccessLogCtx {
1550 pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1556 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1557 return None;
1558 }
1559 Some(Self {
1560 request_id,
1561 method: request.method().clone(),
1562 path: request.uri().path().to_string(),
1563 host: request
1564 .headers()
1565 .get(header::HOST)
1566 .and_then(|value| value.to_str().ok())
1567 .or_else(|| request.uri().host()) .unwrap_or("-")
1569 .to_string(),
1570 client: request
1571 .extensions()
1572 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1573 .map(|info| info.0.ip().to_string())
1574 .unwrap_or_else(|| "-".to_string()),
1575 start: std::time::Instant::now(),
1576 })
1577 }
1578
1579 pub(crate) fn finish(self, response: Response) -> Response {
1583 let encoding = response
1584 .headers()
1585 .get(header::CONTENT_ENCODING)
1586 .and_then(|v| v.to_str().ok())
1587 .unwrap_or("identity")
1588 .to_string();
1589 let log = AccessLog {
1590 request_id: self.request_id,
1591 method: self.method,
1592 path: self.path,
1593 host: self.host,
1594 client: self.client,
1595 status: response.status().as_u16(),
1596 encoding,
1597 start: self.start,
1598 bytes: std::sync::atomic::AtomicU64::new(0),
1599 };
1600 let (parts, body) = response.into_parts();
1601 let counted = body.into_data_stream().map(move |chunk| {
1602 if let Ok(bytes) = &chunk {
1603 log.bytes
1604 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1605 }
1606 chunk
1607 });
1608 Response::from_parts(parts, Body::from_stream(counted))
1609 }
1610}
1611
1612async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1617 let request_id = assign_request_id(&mut request);
1618 match AccessLogCtx::capture(&request, request_id) {
1619 None => next.run(request).await,
1620 Some(ctx) => ctx.finish(next.run(request).await),
1621 }
1622}
1623
1624fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1626 req_headers
1627 .get(header::IF_NONE_MATCH)
1628 .and_then(|value| value.to_str().ok())
1629 .is_some_and(|value| {
1630 value
1631 .split(',')
1632 .map(str::trim)
1633 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1634 })
1635}
1636
1637fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1638 if let Ok(value) = HeaderValue::from_str(value) {
1639 headers.insert(name, value);
1640 }
1641}
1642
1643fn not_found() -> Response {
1644 (StatusCode::NOT_FOUND, "not found\n").into_response()
1645}
1646
1647fn redirect(status: u16, location: &str) -> Response {
1648 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1649 match HeaderValue::from_str(location) {
1650 Ok(location) => {
1651 let mut headers = HeaderMap::new();
1652 headers.insert(header::LOCATION, location);
1653 (status, headers).into_response()
1654 }
1655 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1656 }
1657}
1658
1659fn deploy_error_response(err: DeployError) -> Response {
1661 let status = match &err {
1662 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1663 StatusCode::NOT_FOUND
1664 }
1665 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1666 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1667 DeployError::Conflict(_) => StatusCode::CONFLICT,
1669 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1671 _ => StatusCode::INTERNAL_SERVER_ERROR,
1672 };
1673 tracing::warn!(error = %err, "request failed");
1674 (status, format!("{err}\n")).into_response()
1675}
1676
1677fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1682 boatramp_core::project::validate_resource_name(kind, value)
1683 .err()
1684 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1685}
1686
1687#[cfg(test)]
1688mod drain_tests {
1689 use super::*;
1690
1691 #[tokio::test]
1692 async fn deadline_forces_shutdown_after_signal() {
1693 let server = std::future::pending::<Result<(), ServeError>>();
1696 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1698 assert!(result.is_ok());
1699 }
1700
1701 #[tokio::test]
1702 async fn server_finishing_first_wins() {
1703 let server = async { Ok(()) };
1706 let signalled = std::future::pending::<()>();
1707 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1708 assert!(result.is_ok());
1709 }
1710
1711 #[tokio::test]
1712 async fn deadline_does_not_trip_before_signal() {
1713 let server = async {
1717 tokio::time::sleep(Duration::from_millis(40)).await;
1718 Err(ServeError::Io(std::io::Error::other("server error")))
1719 };
1720 let signalled = std::future::pending::<()>();
1721 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1722 assert!(result.is_err());
1723 }
1724}
1725
1726#[cfg(all(test, feature = "handlers"))]
1727mod tests {
1728 use super::*;
1729 use boatramp_core::cose::{LocalSigner, TokenAlg};
1730 use boatramp_core::project::ProjectRef;
1731
1732 #[test]
1733 fn query_string_parses_and_url_decodes() {
1734 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1735 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1736 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")); }
1740
1741 #[test]
1742 fn cookie_header_parses_pairs() {
1743 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1744 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1745 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1746 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1747 }
1748
1749 #[test]
1750 fn apply_vary_merges_without_duplicates() {
1751 let base = (StatusCode::OK, "x").into_response();
1752 let r = apply_vary(base, &["accept-language".into()]);
1753 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1754 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1756 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1757 assert!(v.contains("accept-language") && v.contains("cookie"));
1758 assert_eq!(v.matches("accept-language").count(), 1);
1759 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1761 assert!(plain.headers().get(header::VARY).is_none());
1762 }
1763
1764 #[tokio::test]
1768 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1769 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1770 let public = keys.public_key();
1771
1772 let resp = create_join_token(
1774 Extension(Issuer(Some(keys.clone()))),
1775 Json(CreateJoinTokenRequest {
1776 ttl_secs: Some(600),
1777 }),
1778 )
1779 .await;
1780 assert_eq!(resp.status(), StatusCode::CREATED);
1781 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1782 .await
1783 .unwrap();
1784 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1785 let token = parsed["token"].as_str().unwrap();
1786 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1787 assert!(!jti.is_empty());
1788
1789 let no_issuer = create_join_token(
1791 Extension(Issuer(None)),
1792 Json(CreateJoinTokenRequest { ttl_secs: None }),
1793 )
1794 .await;
1795 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1796 }
1797
1798 #[tokio::test]
1803 async fn function_write_path_deploy_rollback_alias_remove() {
1804 use boatramp_core::function::Lifecycle;
1805 use boatramp_core::kv::MemoryKv;
1806 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1807
1808 struct FakeStorage {
1811 present: bool,
1812 }
1813 #[async_trait::async_trait]
1814 impl Storage for FakeStorage {
1815 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1816 Err(StorageError::NotFound(String::new()))
1817 }
1818 async fn get_range(
1819 &self,
1820 _: &str,
1821 _: u64,
1822 _: Option<u64>,
1823 ) -> Result<GetObject, StorageError> {
1824 Err(StorageError::NotFound(String::new()))
1825 }
1826 async fn put(
1827 &self,
1828 _: &str,
1829 _: ByteStream,
1830 _: PutMeta,
1831 ) -> Result<ObjectMeta, StorageError> {
1832 Err(StorageError::unsupported("fake"))
1833 }
1834 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1835 if self.present {
1836 Ok(ObjectMeta {
1837 key: key.to_string(),
1838 ..Default::default()
1839 })
1840 } else {
1841 Err(StorageError::NotFound(key.to_string()))
1842 }
1843 }
1844 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1845 Ok(())
1846 }
1847 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1848 Ok(Vec::new())
1849 }
1850 }
1851
1852 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1853 let status = resp.status();
1854 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1855 .await
1856 .unwrap();
1857 let value = if bytes.is_empty() {
1858 serde_json::Value::Null
1859 } else {
1860 serde_json::from_slice(&bytes).unwrap()
1861 };
1862 (status, value)
1863 }
1864
1865 let deploy = DeployStore::new(
1866 Arc::new(FakeStorage { present: true }),
1867 Arc::new(MemoryKv::new()),
1868 );
1869 let v1 = "a".repeat(64);
1870 let v2 = "b".repeat(64);
1871
1872 let (st, body) = body_json(
1874 deploy_function(
1875 State(deploy.clone()),
1876 axum::extract::Extension(crate::ProjectContext::default()),
1877 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1878 axum::extract::Query(DeployFunctionQuery::default()),
1879 Path("greeter".to_string()),
1880 Json(FunctionUpsert {
1881 component: v1.clone(),
1882 config: Default::default(),
1883 lifecycle: Lifecycle::Independent,
1884 }),
1885 )
1886 .await,
1887 )
1888 .await;
1889 assert_eq!(st, StatusCode::OK);
1890 assert_eq!(body["active"], v1);
1891
1892 let (_, body) = body_json(
1894 deploy_function(
1895 State(deploy.clone()),
1896 axum::extract::Extension(crate::ProjectContext::default()),
1897 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1898 axum::extract::Query(DeployFunctionQuery::default()),
1899 Path("greeter".to_string()),
1900 Json(FunctionUpsert {
1901 component: v2.clone(),
1902 config: Default::default(),
1903 lifecycle: Lifecycle::Independent,
1904 }),
1905 )
1906 .await,
1907 )
1908 .await;
1909 assert_eq!(body["active"], v2);
1910 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1911
1912 let (st, body) = body_json(
1914 rollback_function(
1915 State(deploy.clone()),
1916 axum::extract::Extension(crate::ProjectContext::default()),
1917 Path("greeter".to_string()),
1918 Json(RollbackBody { to: v1.clone() }),
1919 )
1920 .await,
1921 )
1922 .await;
1923 assert_eq!(st, StatusCode::OK);
1924 assert_eq!(body["active"], v1);
1925
1926 let resp = rollback_function(
1928 State(deploy.clone()),
1929 axum::extract::Extension(crate::ProjectContext::default()),
1930 Path("greeter".to_string()),
1931 Json(RollbackBody { to: "c".repeat(64) }),
1932 )
1933 .await;
1934 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1935
1936 let (st, body) = body_json(
1938 alias_function(
1939 State(deploy.clone()),
1940 axum::extract::Extension(crate::ProjectContext::default()),
1941 Path(("greeter".to_string(), "prod".to_string())),
1942 Json(AliasBody {
1943 version: v2.clone(),
1944 }),
1945 )
1946 .await,
1947 )
1948 .await;
1949 assert_eq!(st, StatusCode::OK);
1950 assert_eq!(body["aliases"]["prod"], v2);
1951
1952 let (st, _) = body_json(
1954 remove_function(
1955 State(deploy.clone()),
1956 axum::extract::Extension(crate::ProjectContext::default()),
1957 Path("greeter".to_string()),
1958 )
1959 .await,
1960 )
1961 .await;
1962 assert_eq!(st, StatusCode::NO_CONTENT);
1963 assert!(deploy
1964 .get_function(ProjectRef::DEFAULT, "greeter")
1965 .await
1966 .unwrap()
1967 .is_none());
1968
1969 let empty = DeployStore::new(
1971 Arc::new(FakeStorage { present: false }),
1972 Arc::new(MemoryKv::new()),
1973 );
1974 let resp = deploy_function(
1975 State(empty),
1976 axum::extract::Extension(crate::ProjectContext::default()),
1977 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1978 axum::extract::Query(DeployFunctionQuery::default()),
1979 Path("orphan".to_string()),
1980 Json(FunctionUpsert {
1981 component: v1.clone(),
1982 config: Default::default(),
1983 lifecycle: Lifecycle::default(),
1984 }),
1985 )
1986 .await;
1987 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1988 }
1989
1990 struct StubControl {
1994 admits: std::sync::Mutex<Vec<(String, String)>>,
1995 respond: StubJoin,
1996 }
1997 #[derive(Clone, Copy)]
1998 enum StubJoin {
1999 Admit,
2000 Spent,
2001 Invalid,
2002 Revoked,
2003 }
2004
2005 #[async_trait::async_trait]
2006 impl MeshControl for StubControl {
2007 async fn admit(
2008 &self,
2009 mesh_pubkey_hex: &str,
2010 jti: &str,
2011 _proof: &[u8],
2012 _proof_iat: u64,
2013 _now: u64,
2014 _advertise_addr: Option<&str>,
2015 ) -> Result<JoinOutcome, String> {
2016 self.admits
2017 .lock()
2018 .unwrap()
2019 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
2020 Ok(match self.respond {
2021 StubJoin::Admit => JoinOutcome::Admitted {
2022 members: vec!["signed-member".to_string()],
2023 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
2024 },
2025 StubJoin::Spent => JoinOutcome::TokenSpent,
2026 StubJoin::Invalid => JoinOutcome::ProofInvalid,
2027 StubJoin::Revoked => JoinOutcome::Revoked,
2028 })
2029 }
2030 async fn rotate_key(&self) -> Result<String, String> {
2031 Ok("cafe".to_string())
2032 }
2033 async fn revoke(&self, _node: u64) -> Result<(), String> {
2034 Ok(())
2035 }
2036 async fn members(&self) -> Result<Vec<MeshMember>, String> {
2037 Ok(Vec::new())
2038 }
2039 async fn promote(&self, _node: u64) -> Result<(), String> {
2040 Ok(())
2041 }
2042 }
2043
2044 #[tokio::test]
2048 async fn cluster_join_dispatches_and_maps_outcomes() {
2049 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2050 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2051 let auth = Auth::with_key(keys.public_key(), kv);
2052 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
2053 let req = |proof: &str| JoinRequest {
2054 token: token.clone(),
2055 mesh_pubkey: "302a300506032b6570032100feed".into(),
2056 possession_proof: proof.to_string(),
2057 proof_iat: now_unix(),
2058 advertise_addr: Some("https://joiner:7000".into()),
2059 };
2060
2061 let admitter = Arc::new(StubControl {
2063 admits: std::sync::Mutex::new(Vec::new()),
2064 respond: StubJoin::Admit,
2065 });
2066 let resp = cluster_join(
2067 Extension(auth.clone()),
2068 Extension(MeshControlHandle(Some(admitter.clone()))),
2069 Json(req("aa01")),
2070 )
2071 .await;
2072 assert_eq!(resp.status(), StatusCode::OK);
2073 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
2074
2075 let spent = Arc::new(StubControl {
2077 admits: std::sync::Mutex::new(Vec::new()),
2078 respond: StubJoin::Spent,
2079 });
2080 assert_eq!(
2081 cluster_join(
2082 Extension(auth.clone()),
2083 Extension(MeshControlHandle(Some(spent))),
2084 Json(req("aa01")),
2085 )
2086 .await
2087 .status(),
2088 StatusCode::CONFLICT
2089 );
2090 let invalid = Arc::new(StubControl {
2091 admits: std::sync::Mutex::new(Vec::new()),
2092 respond: StubJoin::Invalid,
2093 });
2094 assert_eq!(
2095 cluster_join(
2096 Extension(auth.clone()),
2097 Extension(MeshControlHandle(Some(invalid))),
2098 Json(req("aa01")),
2099 )
2100 .await
2101 .status(),
2102 StatusCode::FORBIDDEN
2103 );
2104 let revoked = Arc::new(StubControl {
2106 admits: std::sync::Mutex::new(Vec::new()),
2107 respond: StubJoin::Revoked,
2108 });
2109 assert_eq!(
2110 cluster_join(
2111 Extension(auth.clone()),
2112 Extension(MeshControlHandle(Some(revoked))),
2113 Json(req("aa01")),
2114 )
2115 .await
2116 .status(),
2117 StatusCode::FORBIDDEN
2118 );
2119
2120 let ok = Arc::new(StubControl {
2122 admits: std::sync::Mutex::new(Vec::new()),
2123 respond: StubJoin::Admit,
2124 });
2125 assert_eq!(
2126 cluster_join(
2127 Extension(auth.clone()),
2128 Extension(MeshControlHandle(Some(ok))),
2129 Json(req("not-hex")),
2130 )
2131 .await
2132 .status(),
2133 StatusCode::BAD_REQUEST
2134 );
2135
2136 let none = cluster_join(
2138 Extension(auth),
2139 Extension(MeshControlHandle(None)),
2140 Json(req("aa01")),
2141 )
2142 .await;
2143 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2144 }
2145
2146 #[tokio::test]
2150 async fn bootstrap_mints_the_first_token_once() {
2151 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
2152 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2153 let public = keys.public_key();
2154 let deploy = DeployStore::new(
2155 Arc::new(MemStorage::default()),
2156 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
2157 );
2158 let secret = "s3cr3t-bootstrap-value";
2159 let gate = BootstrapGate::new(Some(secret));
2160 let issuer = Issuer(Some(keys.clone()));
2161 let bearer = |s: &str| {
2162 let mut h = HeaderMap::new();
2163 h.insert(
2164 AUTHORIZATION,
2165 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
2166 );
2167 h
2168 };
2169 let req = || BootstrapRequest {
2170 roles: vec!["admin".to_string()],
2171 ttl_secs: None,
2172 };
2173
2174 let bad = bootstrap_token(
2176 State(deploy.clone()),
2177 Extension(issuer.clone()),
2178 Extension(gate.clone()),
2179 bearer("wrong"),
2180 Json(req()),
2181 )
2182 .await;
2183 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
2184
2185 let ok = bootstrap_token(
2187 State(deploy.clone()),
2188 Extension(issuer.clone()),
2189 Extension(gate.clone()),
2190 bearer(secret),
2191 Json(req()),
2192 )
2193 .await;
2194 assert_eq!(ok.status(), StatusCode::CREATED);
2195 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2196 .await
2197 .unwrap();
2198 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2199 let token = json["token"].as_str().unwrap();
2200 let id = json["id"].as_str().unwrap();
2201 let verified = cose::verify(token, &public, now_unix()).unwrap();
2202 assert!(verified.roles.iter().any(|r| r.name == "admin"));
2203 assert!(deploy
2204 .list_token_meta()
2205 .await
2206 .unwrap()
2207 .iter()
2208 .any(|m| m.revocation_id == id));
2209
2210 let reuse = bootstrap_token(
2212 State(deploy.clone()),
2213 Extension(issuer.clone()),
2214 Extension(gate),
2215 bearer(secret),
2216 Json(req()),
2217 )
2218 .await;
2219 assert_eq!(reuse.status(), StatusCode::CONFLICT);
2220
2221 let disabled = bootstrap_token(
2223 State(deploy),
2224 Extension(issuer),
2225 Extension(BootstrapGate(None)),
2226 bearer(secret),
2227 Json(req()),
2228 )
2229 .await;
2230 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2231 }
2232
2233 #[tokio::test]
2236 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2237 let control = Arc::new(StubControl {
2238 admits: std::sync::Mutex::new(Vec::new()),
2239 respond: StubJoin::Admit,
2240 });
2241 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2242 assert_eq!(resp.status(), StatusCode::OK);
2243 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2244 .await
2245 .unwrap();
2246 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2247 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2248
2249 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2250 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2251 }
2252
2253 #[test]
2254 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2255 use boatramp_core::security::SecurityProfile;
2256 let strict = SecurityProfile::MultiTenant.preset();
2257 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
2261 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2262 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2263
2264 assert!(gateway_addr_allowed(public, &strict));
2266 assert!(!gateway_addr_allowed(private, &strict));
2267 assert!(!gateway_addr_allowed(loopback, &strict));
2268 assert!(!gateway_addr_allowed(metadata, &strict));
2269
2270 assert!(gateway_addr_allowed(public, &loose));
2273 assert!(gateway_addr_allowed(private, &loose));
2274 assert!(gateway_addr_allowed(loopback, &loose));
2275 assert!(!gateway_addr_allowed(metadata, &loose));
2276 }
2277
2278 #[tokio::test]
2279 async fn resolve_env_merges_static_and_host_secrets() {
2280 use boatramp_core::config::HandlersSiteConfig;
2281
2282 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2284
2285 let deploy_env = std::collections::BTreeMap::from([
2286 ("GREETING".to_string(), "hi".to_string()),
2287 ("OVERRIDE_ME".to_string(), "static".to_string()),
2288 ]);
2289 let site_handlers = HandlersSiteConfig {
2290 enabled: true,
2291 secrets: std::collections::BTreeMap::from([
2292 (
2294 "SECRET_TOKEN".to_string(),
2295 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2296 ),
2297 (
2298 "OVERRIDE_ME".to_string(),
2299 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2300 ),
2301 (
2302 "MISSING".to_string(),
2303 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2304 ),
2305 ]),
2306 ..Default::default()
2307 };
2308 let env = resolve_env(
2311 "blog",
2312 boatramp_core::project::ProjectRef::DEFAULT,
2313 &deploy_env,
2314 &site_handlers,
2315 true,
2316 None,
2317 )
2318 .await
2319 .expect("resolves");
2320
2321 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2325 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2326 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2327 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2328
2329 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2330 }
2331
2332 #[tokio::test]
2333 async fn multi_tenant_posture_refuses_a_host_env_handler_secret() {
2334 use boatramp_core::config::HandlersSiteConfig;
2335
2336 std::env::set_var("BOATRAMP_TEST_OTHER_TENANT_SECRET", "leak-me");
2341 let deploy_env = std::collections::BTreeMap::new();
2342 let bare = HandlersSiteConfig {
2343 enabled: true,
2344 secrets: std::collections::BTreeMap::from([(
2345 "STOLEN".to_string(),
2346 "BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2347 )]),
2348 ..Default::default()
2349 };
2350 let err = resolve_env(
2351 "evil",
2352 boatramp_core::project::ProjectRef::DEFAULT,
2353 &deploy_env,
2354 &bare,
2355 false,
2356 None,
2357 )
2358 .await
2359 .expect_err("multi-tenant must refuse a bare host-env ref");
2360 assert!(
2361 err.contains("STOLEN"),
2362 "error names the offending guest var: {err}"
2363 );
2364 assert!(
2365 err.contains("multi-tenant"),
2366 "error steers the tenant: {err}"
2367 );
2368 assert!(
2369 !err.contains("leak-me"),
2370 "the host value must never appear (never read): {err}"
2371 );
2372
2373 let explicit = HandlersSiteConfig {
2375 enabled: true,
2376 secrets: std::collections::BTreeMap::from([(
2377 "STOLEN".to_string(),
2378 "env:BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2379 )]),
2380 ..Default::default()
2381 };
2382 assert!(resolve_env(
2383 "evil",
2384 boatramp_core::project::ProjectRef::DEFAULT,
2385 &deploy_env,
2386 &explicit,
2387 false,
2388 None,
2389 )
2390 .await
2391 .is_err());
2392
2393 let reserved = HandlersSiteConfig {
2395 enabled: true,
2396 secrets: std::collections::BTreeMap::from([(
2397 "TOKEN".to_string(),
2398 "vault:kv/data/app#token".to_string(),
2399 )]),
2400 ..Default::default()
2401 };
2402 let err = resolve_env(
2403 "evil",
2404 boatramp_core::project::ProjectRef::DEFAULT,
2405 &deploy_env,
2406 &reserved,
2407 true,
2408 None,
2409 )
2410 .await
2411 .expect_err("a reserved scheme is not yet supported, even under single-tenant");
2412 assert!(err.contains("not yet supported"), "{err}");
2413
2414 let arbitrary = HandlersSiteConfig {
2418 enabled: true,
2419 secrets: std::collections::BTreeMap::from([(
2420 "KEY".to_string(),
2421 "aws:sm/prod/apikey".to_string(),
2422 )]),
2423 ..Default::default()
2424 };
2425 let err = resolve_env(
2426 "evil",
2427 boatramp_core::project::ProjectRef::DEFAULT,
2428 &deploy_env,
2429 &arbitrary,
2430 true,
2431 None,
2432 )
2433 .await
2434 .expect_err("any unknown scheme is reserved, even under single-tenant");
2435 assert!(
2436 err.contains("not yet supported") && err.contains("aws"),
2437 "provider-neutral reservation names the scheme: {err}"
2438 );
2439
2440 std::env::remove_var("BOATRAMP_TEST_OTHER_TENANT_SECRET");
2441 }
2442
2443 #[tokio::test]
2444 async fn function_resolve_secret_env_reads_host_and_matches_handler_semantics() {
2445 std::env::set_var("BOATRAMP_TEST_FN_SECRET", "fnsecret");
2450
2451 let static_env = std::collections::BTreeMap::from([
2452 ("STAGE".to_string(), "prod".to_string()),
2453 ("OVERRIDE_ME".to_string(), "static".to_string()),
2454 ]);
2455 let secrets = std::collections::BTreeMap::from([
2456 ("DB_URL".to_string(), "BOATRAMP_TEST_FN_SECRET".to_string()),
2458 (
2460 "OVERRIDE_ME".to_string(),
2461 "BOATRAMP_TEST_FN_SECRET".to_string(),
2462 ),
2463 (
2465 "MISSING".to_string(),
2466 "BOATRAMP_TEST_FN_NOT_SET".to_string(),
2467 ),
2468 ]);
2469 let env = resolve_secret_env(
2471 "fn/api",
2472 boatramp_core::project::ProjectRef::DEFAULT,
2473 &static_env,
2474 &secrets,
2475 true,
2476 None,
2477 )
2478 .await
2479 .expect("resolves");
2480
2481 assert!(env.contains(&("STAGE".to_string(), "prod".to_string())));
2482 assert!(env.contains(&("DB_URL".to_string(), "fnsecret".to_string())));
2484 assert!(env.contains(&("OVERRIDE_ME".to_string(), "fnsecret".to_string())));
2486 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2488
2489 std::env::remove_var("BOATRAMP_TEST_FN_SECRET");
2490 }
2491
2492 #[tokio::test]
2493 async fn multi_tenant_posture_refuses_a_host_env_function_secret() {
2494 std::env::set_var("BOATRAMP_TEST_FN_LEAK", "leak-me");
2499 let static_env = std::collections::BTreeMap::new();
2500 let secrets = std::collections::BTreeMap::from([(
2501 "DB_URL".to_string(),
2502 "BOATRAMP_TEST_FN_LEAK".to_string(),
2503 )]);
2504
2505 let err = resolve_secret_env(
2507 "fn/api",
2508 boatramp_core::project::ProjectRef::DEFAULT,
2509 &static_env,
2510 &secrets,
2511 false,
2512 None,
2513 )
2514 .await
2515 .expect_err("multi-tenant must refuse a function host-env ref");
2516 assert!(
2517 err.contains("DB_URL"),
2518 "error names the offending guest var: {err}"
2519 );
2520 assert!(
2521 !err.contains("leak-me"),
2522 "host value must never appear: {err}"
2523 );
2524
2525 let env = resolve_secret_env(
2527 "fn/api",
2528 boatramp_core::project::ProjectRef::DEFAULT,
2529 &static_env,
2530 &secrets,
2531 true,
2532 None,
2533 )
2534 .await
2535 .expect("resolves");
2536 assert!(env.contains(&("DB_URL".to_string(), "leak-me".to_string())));
2537
2538 std::env::remove_var("BOATRAMP_TEST_FN_LEAK");
2539 }
2540
2541 #[tokio::test]
2542 async fn boatramp_scheme_resolves_from_the_project_scoped_store() {
2543 use boatramp_core::project::ProjectRef;
2544 use boatramp_core::secret_store::SecretStore;
2545 use std::sync::Arc;
2546
2547 struct XorEnvelope;
2549 #[async_trait::async_trait]
2550 impl boatramp_core::envelope::KeyEnvelope for XorEnvelope {
2551 async fn wrap(
2552 &self,
2553 p: &[u8],
2554 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2555 Ok(p.iter().map(|b| b ^ 0x5a).collect())
2556 }
2557 async fn unwrap(
2558 &self,
2559 c: &[u8],
2560 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2561 Ok(c.iter().map(|b| b ^ 0x5a).collect())
2562 }
2563 }
2564
2565 let store = SecretStore::new(
2566 Arc::new(boatramp_core::kv::MemoryKv::new()),
2567 Arc::new(XorEnvelope),
2568 );
2569 store
2570 .set(ProjectRef::new("acme"), "api-key", b"s3cr3t")
2571 .await
2572 .unwrap();
2573
2574 let static_env = std::collections::BTreeMap::new();
2575 let secrets = std::collections::BTreeMap::from([
2576 ("API_KEY".to_string(), "boatramp:api-key".to_string()),
2577 ("MISSING".to_string(), "boatramp:not-set".to_string()),
2578 ]);
2579
2580 let env = resolve_secret_env(
2583 "site",
2584 ProjectRef::new("acme"),
2585 &static_env,
2586 &secrets,
2587 false,
2588 Some(&store),
2589 )
2590 .await
2591 .expect("boatramp refs resolve without the host-env gate");
2592 assert!(env.contains(&("API_KEY".to_string(), "s3cr3t".to_string())));
2593 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2595
2596 let other_secrets = std::collections::BTreeMap::from([(
2598 "API_KEY".to_string(),
2599 "boatramp:api-key".to_string(),
2600 )]);
2601 let other = resolve_secret_env(
2602 "site",
2603 ProjectRef::new("globex"),
2604 &static_env,
2605 &other_secrets,
2606 false,
2607 Some(&store),
2608 )
2609 .await
2610 .expect("resolves (a foreign project's secret is simply absent → skipped)");
2611 assert!(
2612 !other.iter().any(|(k, _)| k == "API_KEY"),
2613 "a tenant must not read another project's secret"
2614 );
2615
2616 let err = resolve_secret_env(
2618 "site",
2619 ProjectRef::new("acme"),
2620 &static_env,
2621 &other_secrets,
2622 false,
2623 None,
2624 )
2625 .await
2626 .expect_err("no store configured must fail closed");
2627 assert!(err.contains("no internal secret store"), "{err}");
2628 }
2629
2630 fn req() -> Request {
2631 Request::builder()
2632 .uri("/")
2633 .header(header::HOST, "example.com")
2634 .body(Body::empty())
2635 .unwrap()
2636 }
2637
2638 #[test]
2639 fn forwarded_headers_set_standard_triple() {
2640 let mut request = req();
2641 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2642 let h = request.headers();
2643 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2644 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2645 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2646 }
2647
2648 #[test]
2649 fn forwarded_for_overwrites_spoofed_value() {
2650 let mut request = Request::builder()
2653 .uri("/")
2654 .header(header::HOST, "example.com")
2655 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2656 .body(Body::empty())
2657 .unwrap();
2658 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2659 let values: Vec<_> = request
2660 .headers()
2661 .get_all("x-forwarded-for")
2662 .iter()
2663 .collect();
2664 assert_eq!(values.len(), 1);
2665 assert_eq!(values[0], "203.0.113.7");
2666 }
2667
2668 #[test]
2669 fn forwarded_proto_preserves_upstream_tls() {
2670 let mut request = Request::builder()
2672 .uri("/")
2673 .header(header::HOST, "example.com")
2674 .header("x-forwarded-proto", "https")
2675 .body(Body::empty())
2676 .unwrap();
2677 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2678 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2679 }
2680
2681 #[test]
2682 fn forwarded_host_absent_when_no_host_header() {
2683 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2684 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2685 assert!(request.headers().get("x-forwarded-host").is_none());
2686 assert_eq!(
2687 request.headers().get("x-forwarded-for").unwrap(),
2688 "203.0.113.7"
2689 );
2690 }
2691
2692 use boatramp_core::kv::{KvStore, MemoryKv};
2695 use boatramp_core::messaging::{LogMessaging, Messaging};
2696 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2697
2698 const EVENT_CONSUMER: &[u8] =
2699 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2700
2701 #[derive(Default)]
2702 struct MemStorage {
2703 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2704 }
2705
2706 #[async_trait::async_trait]
2707 impl boatramp_core::Storage for MemStorage {
2708 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2709 let bytes = self
2710 .objects
2711 .lock()
2712 .unwrap()
2713 .get(key)
2714 .cloned()
2715 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2716 let body: ByteStream =
2717 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2718 Ok(GetObject {
2719 meta: ObjectMeta {
2720 key: key.to_string(),
2721 ..Default::default()
2722 },
2723 body,
2724 })
2725 }
2726 async fn get_range(
2727 &self,
2728 key: &str,
2729 _: u64,
2730 _: Option<u64>,
2731 ) -> Result<GetObject, StorageError> {
2732 self.get(key).await
2733 }
2734 async fn put(
2735 &self,
2736 key: &str,
2737 mut body: ByteStream,
2738 _: PutMeta,
2739 ) -> Result<ObjectMeta, StorageError> {
2740 use futures::StreamExt;
2741 let mut buf = Vec::new();
2742 while let Some(chunk) = body.next().await {
2743 buf.extend_from_slice(&chunk?);
2744 }
2745 self.objects.lock().unwrap().insert(key.to_string(), buf);
2746 Ok(ObjectMeta {
2747 key: key.to_string(),
2748 ..Default::default()
2749 })
2750 }
2751 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2752 self.objects
2753 .lock()
2754 .unwrap()
2755 .get(key)
2756 .map(|_| ObjectMeta {
2757 key: key.to_string(),
2758 ..Default::default()
2759 })
2760 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2761 }
2762 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2763 self.objects.lock().unwrap().remove(key);
2764 Ok(())
2765 }
2766 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2767 Ok(Vec::new())
2768 }
2769 }
2770
2771 fn observed_state_in(
2774 project: &str,
2775 workload: &str,
2776 host: &str,
2777 healthy: bool,
2778 phase: boatramp_core::compute::ReplicaPhase,
2779 ) -> boatramp_core::compute::ObservedInstance {
2780 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2781 boatramp_core::compute::ObservedInstance {
2782 handle: InstanceHandle {
2783 project: project.into(),
2784 workload: workload.into(),
2785 replica: 0,
2786 backend_ref: "ref-0".into(),
2787 },
2788 node: 1,
2789 backend: "vmm".into(),
2790 endpoint: Endpoint {
2791 scheme: Scheme::Http,
2792 host: host.into(),
2793 port: 80,
2794 },
2795 region: None,
2796 healthy,
2797 started_at: None,
2798 phase,
2799 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2800 project: project.into(),
2801 workload: workload.into(),
2802 replica: 0,
2803 data_ref: "snap-0".into(),
2804 }),
2805 }
2806 }
2807
2808 fn observed_state(
2810 workload: &str,
2811 healthy: bool,
2812 phase: boatramp_core::compute::ReplicaPhase,
2813 ) -> boatramp_core::compute::ObservedInstance {
2814 observed_state_in("default", workload, "10.0.0.2", healthy, phase)
2815 }
2816
2817 #[tokio::test]
2818 async fn has_parked_replica_detects_a_zeroed_replica() {
2819 use boatramp_core::compute::ReplicaPhase;
2820 let storage = Arc::new(MemStorage::default());
2821 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2822 let deploy = DeployStore::new(storage, kv);
2823
2824 assert!(!has_parked_replica(&deploy, "default", "w").await);
2826 deploy
2828 .set_replica_state(
2829 ProjectRef::DEFAULT,
2830 &observed_state("w", true, ReplicaPhase::Running),
2831 )
2832 .await
2833 .unwrap();
2834 assert!(!has_parked_replica(&deploy, "default", "w").await);
2835 deploy
2837 .set_replica_state(
2838 ProjectRef::DEFAULT,
2839 &observed_state("w", false, ReplicaPhase::Zero),
2840 )
2841 .await
2842 .unwrap();
2843 assert!(has_parked_replica(&deploy, "default", "w").await);
2844 }
2845
2846 #[tokio::test]
2847 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2848 use boatramp_core::compute::ReplicaPhase;
2849 let storage = Arc::new(MemStorage::default());
2850 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2851 let deploy = DeployStore::new(storage, kv);
2852
2853 let empty = await_warm(
2855 &deploy,
2856 "default",
2857 "w",
2858 std::time::Duration::from_millis(150),
2859 )
2860 .await;
2861 assert!(empty.is_empty());
2862
2863 deploy
2865 .set_replica_state(
2866 ProjectRef::DEFAULT,
2867 &observed_state("w", true, ReplicaPhase::Running),
2868 )
2869 .await
2870 .unwrap();
2871 let warm = await_warm(&deploy, "default", "w", std::time::Duration::from_secs(5)).await;
2872 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2873 }
2874
2875 #[tokio::test]
2881 async fn compute_endpoints_are_project_scoped() {
2882 use boatramp_core::compute::ReplicaPhase;
2883 let storage = Arc::new(MemStorage::default());
2884 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2885 let deploy = DeployStore::new(storage, kv);
2886
2887 deploy
2889 .set_replica_state(
2890 ProjectRef::new("acme"),
2891 &observed_state_in("acme", "web", "10.0.0.5", true, ReplicaPhase::Running),
2892 )
2893 .await
2894 .unwrap();
2895 deploy
2896 .set_replica_state(
2897 ProjectRef::DEFAULT,
2898 &observed_state_in("default", "web", "10.0.0.9", true, ReplicaPhase::Running),
2899 )
2900 .await
2901 .unwrap();
2902
2903 assert_eq!(
2905 compute_endpoints(&deploy, "acme", "web").await,
2906 vec!["http://10.0.0.5:80".to_string()],
2907 "acme's web resolves against acme, not default"
2908 );
2909 assert_eq!(
2911 compute_endpoints(&deploy, "default", "web").await,
2912 vec!["http://10.0.0.9:80".to_string()]
2913 );
2914 assert!(compute_endpoints(&deploy, "beta", "web").await.is_empty());
2917 }
2918
2919 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2923 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2924 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2925 let storage = Arc::new(MemStorage::default());
2926 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2927 let mq = LogMessaging::new(storage, kv.clone());
2928 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2929 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2930 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2931 let topic = "blog/orders/created";
2932
2933 for _ in 0..3 {
2935 mq.publish(topic, b"ok").await.unwrap();
2936 }
2937 loop {
2938 let acked = dispatch_consumer_batch(
2939 &engine,
2940 &mq,
2941 &metrics::Metrics::default(),
2942 "blog",
2943 topic,
2944 "blog/",
2945 "",
2946 boatramp_core::messaging::StartPosition::Latest,
2947 &hash,
2948 EVENT_CONSUMER,
2949 &bindings,
2950 Limits::default(),
2951 Duration::from_secs(30),
2952 5,
2953 10,
2954 )
2955 .await;
2956 if acked == 0 {
2957 break;
2958 }
2959 }
2960 assert_eq!(
2961 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2962 Some(b"3".to_vec())
2963 );
2964
2965 mq.publish(topic, b"fail").await.unwrap();
2968 for _ in 0..5 {
2969 dispatch_consumer_batch(
2970 &engine,
2971 &mq,
2972 &metrics::Metrics::default(),
2973 "blog",
2974 topic,
2975 "blog/",
2976 "",
2977 boatramp_core::messaging::StartPosition::Latest,
2978 &hash,
2979 EVENT_CONSUMER,
2980 &bindings,
2981 Limits::default(),
2982 Duration::ZERO,
2983 2,
2984 10,
2985 )
2986 .await;
2987 }
2988 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2989 assert_eq!(
2991 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2992 Some(b"3".to_vec())
2993 );
2994 }
2995
2996 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3001 async fn consumer_groups_fan_out_through_the_dispatcher() {
3002 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3003 let storage = Arc::new(MemStorage::default());
3004 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3005 let mq = LogMessaging::new(storage, kv.clone());
3006 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3007 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3008 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3009 let topic = "blog/orders/created";
3010 let start = boatramp_core::messaging::StartPosition::Latest;
3011
3012 for g in ["billing", "audit"] {
3015 let n = dispatch_consumer_batch(
3016 &engine,
3017 &mq,
3018 &metrics::Metrics::default(),
3019 "blog",
3020 topic,
3021 "blog/",
3022 g,
3023 start,
3024 &hash,
3025 EVENT_CONSUMER,
3026 &bindings,
3027 Limits::default(),
3028 Duration::from_secs(30),
3029 5,
3030 10,
3031 )
3032 .await;
3033 assert_eq!(n, 0, "no events yet for group {g}");
3034 }
3035 mq.publish(topic, b"ok").await.unwrap();
3036
3037 for g in ["billing", "audit"] {
3039 let n = dispatch_consumer_batch(
3040 &engine,
3041 &mq,
3042 &metrics::Metrics::default(),
3043 "blog",
3044 topic,
3045 "blog/",
3046 g,
3047 start,
3048 &hash,
3049 EVENT_CONSUMER,
3050 &bindings,
3051 Limits::default(),
3052 Duration::from_secs(30),
3053 5,
3054 10,
3055 )
3056 .await;
3057 assert_eq!(n, 1, "group {g} should receive the message");
3058 }
3059 assert_eq!(
3061 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3062 Some(b"2".to_vec())
3063 );
3064 }
3065
3066 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3070 async fn scheduler_runs_current_consumers_not_previews() {
3071 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
3072 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3073 use boatramp_handlers::{HandlerEngine, Limits};
3074 use futures::StreamExt;
3075
3076 let storage = Arc::new(MemStorage::default());
3077 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3078 let deploy = DeployStore::new(storage.clone(), kv.clone());
3079 let messaging: Arc<dyn Messaging> =
3080 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
3081
3082 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3084 let stream: ByteStream =
3085 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
3086 .boxed();
3087 deploy.put_blob(&hash, stream).await.unwrap();
3088 let mut files = std::collections::BTreeMap::new();
3089 files.insert(
3090 "consumer.wasm".to_string(),
3091 FileEntry {
3092 hash: hash.clone(),
3093 size: EVENT_CONSUMER.len() as u64,
3094 content_type: None,
3095 variants: std::collections::BTreeMap::new(),
3096 },
3097 );
3098 let manifest = Manifest {
3099 files,
3100 config: DeployConfig {
3101 consumers: vec![ConsumerConfig {
3102 topic: "orders/created".into(),
3103 component: "consumer.wasm".into(),
3104 imports: vec!["wasi:keyvalue".into()],
3105 group: String::new(),
3106 start: Default::default(),
3107 }],
3108 ..Default::default()
3109 },
3110 ..Default::default()
3111 };
3112 let id = deploy.put_manifest(&manifest).await.unwrap();
3113 deploy
3114 .activate(ProjectRef::DEFAULT, "blog", &id)
3115 .await
3116 .unwrap();
3117 deploy
3118 .set_site_config(
3119 ProjectRef::DEFAULT,
3120 "blog",
3121 &SiteConfig {
3122 handlers: Some(HandlersSiteConfig {
3123 enabled: true,
3124 allow_imports: vec!["wasi:keyvalue".into()],
3125 ..Default::default()
3126 }),
3127 ..Default::default()
3128 },
3129 )
3130 .await
3131 .unwrap();
3132
3133 messaging
3135 .publish("blog/orders/created", b"live")
3136 .await
3137 .unwrap();
3138 messaging
3139 .publish("blog/_preview/abc/orders/created", b"preview")
3140 .await
3141 .unwrap();
3142
3143 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3144 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
3145 let inner = rt.inner.clone().unwrap();
3146 let mut cache = std::collections::HashMap::new();
3147 let mut crons = std::collections::HashMap::new();
3148 let mut sweep = std::collections::HashMap::new();
3149 let now = CronNow {
3150 minute: 0,
3151 hour: 0,
3152 dom: 1,
3153 month: 1,
3154 dow: 0,
3155 minute_stamp: 0,
3156 };
3157 for _ in 0..3 {
3158 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
3159 .await
3160 .unwrap();
3161 }
3162
3163 assert_eq!(
3165 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3166 Some(b"1".to_vec())
3167 );
3168 assert_eq!(
3171 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
3172 .await
3173 .unwrap(),
3174 None
3175 );
3176 }
3177
3178 const KV_COUNTER: &[u8] =
3183 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
3184
3185 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3190 async fn function_invoker_runs_target_buffers_and_meters() {
3191 use boatramp_core::deploy::DeployStore;
3192 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
3193 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
3194 use futures::StreamExt;
3195
3196 const HTTP_200: &[u8] =
3199 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3200
3201 let storage = Arc::new(MemStorage::default());
3202 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3203 let deploy = DeployStore::new(storage.clone(), kv.clone());
3204
3205 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3206 let stream: ByteStream =
3207 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3208 deploy.put_blob(&hash, stream).await.unwrap();
3209 let function = Function {
3210 name: "target".into(),
3211 owner: Owner::Project("default".into()),
3212 versions: vec![FunctionVersion {
3213 id: "v1".into(),
3214 component: hash.clone(),
3215 created: 0,
3216 lifecycle: Lifecycle::Independent,
3217 }],
3218 active: "v1".into(),
3219 aliases: Default::default(),
3220 config: Default::default(),
3221 };
3222 deploy
3223 .put_function(ProjectRef::DEFAULT, &function)
3224 .await
3225 .unwrap();
3226
3227 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3228 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
3229 rt.set_invoker(deploy.clone());
3230 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
3231
3232 let request = || InvokeRequest {
3233 method: "GET".into(),
3234 path: "/".into(),
3235 headers: vec![],
3236 body: vec![],
3237 };
3238
3239 let response = invoker.invoke("target", request(), 1).await.unwrap();
3241 assert_eq!(response.status, 200);
3242
3243 let metering = deploy
3245 .get_metering(ProjectRef::DEFAULT, "target")
3246 .await
3247 .unwrap()
3248 .unwrap();
3249 assert_eq!(metering.invocations, 1);
3250
3251 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
3253 assert!(matches!(err, InvokeError::NotFound));
3254 }
3255
3256 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3262 async fn federation_runner_enforces_the_safelist_before_planning() {
3263 use boatramp_core::deploy::DeployStore;
3264 use boatramp_core::project::ProjectRef;
3265 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
3266
3267 let storage = Arc::new(MemStorage::default());
3268 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3269 let deploy = DeployStore::new(storage.clone(), kv.clone());
3270 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3271 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3272 rt.set_invoker(deploy.clone());
3273 let runner = rt
3274 .inner
3275 .as_ref()
3276 .unwrap()
3277 .federation_runner
3278 .get()
3279 .unwrap()
3280 .scoped(ProjectRef::new("default"));
3281
3282 let req = |query: &str| GraphqlRequest {
3283 query: Some(query.to_string()),
3284 persisted_hash: None,
3285 variables: "{}".to_string(),
3286 operation_name: None,
3287 authorization: None,
3288 };
3289
3290 assert!(matches!(
3292 runner.run(req("{ me { id } }"), 1).await,
3293 Err(SupergraphRunError::NotSafelisted)
3294 ));
3295
3296 let query = "{ me { id } }";
3299 let hash = crate::graphql_apq::sha256_hex(query);
3300 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
3301 .await
3302 .unwrap();
3303 assert!(matches!(
3304 runner.run(req(query), 1).await,
3305 Err(SupergraphRunError::PlanFailed(_))
3306 ));
3307
3308 let persisted = GraphqlRequest {
3310 query: None,
3311 persisted_hash: Some("deadbeef".to_string()),
3312 variables: "{}".to_string(),
3313 operation_name: None,
3314 authorization: None,
3315 };
3316 assert!(matches!(
3317 runner.run(persisted, 1).await,
3318 Err(SupergraphRunError::NotSafelisted)
3319 ));
3320 }
3321
3322 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3328 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
3329 use crate::scheduler::{run_scheduler_tick, CronNow};
3330 use boatramp_core::deploy::DeployStore;
3331 use boatramp_core::function::{
3332 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3333 };
3334 use boatramp_handlers::{HandlerEngine, Limits};
3335 use futures::StreamExt;
3336
3337 const HTTP_200: &[u8] =
3338 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3339
3340 let storage = Arc::new(MemStorage::default());
3341 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3342 let deploy = DeployStore::new(storage.clone(), kv.clone());
3343
3344 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3345 let stream: ByteStream =
3346 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3347 deploy.put_blob(&hash, stream).await.unwrap();
3348
3349 let acme = ProjectRef::new("acme");
3351 let function = Function {
3352 name: "worker".into(),
3353 owner: Owner::Project("acme".into()),
3354 versions: vec![FunctionVersion {
3355 id: "v1".into(),
3356 component: hash.clone(),
3357 created: 0,
3358 lifecycle: Lifecycle::Independent,
3359 }],
3360 active: "v1".into(),
3361 aliases: Default::default(),
3362 config: Default::default(),
3363 };
3364 deploy.put_function(acme, &function).await.unwrap();
3365 let inv = Invocation {
3366 id: "inv1".into(),
3367 function: "worker".into(),
3368 version: "v1".into(),
3369 mode: InvokeMode::Async,
3370 status: InvocationStatus::Queued,
3371 idempotency_key: None,
3372 attempts: 0,
3373 lease_expires: None,
3374 request_b64: None,
3375 request_content_type: None,
3376 result: None,
3377 created: 0,
3378 updated: 0,
3379 };
3380 deploy.put_invocation(acme, &inv).await.unwrap();
3381
3382 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3383 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3384 let inner = rt.inner.as_ref().unwrap();
3385
3386 let mut wasm_cache = std::collections::HashMap::new();
3389 let mut cron_state = std::collections::HashMap::new();
3390 let mut sweep = std::collections::HashMap::new();
3391 let now = CronNow {
3392 minute: 0,
3393 hour: 0,
3394 dom: 1,
3395 month: 1,
3396 dow: 0,
3397 minute_stamp: 0,
3398 };
3399 run_scheduler_tick(
3400 inner,
3401 &deploy,
3402 &mut wasm_cache,
3403 &mut cron_state,
3404 &mut sweep,
3405 now,
3406 )
3407 .await
3408 .unwrap();
3409
3410 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
3413 assert_eq!(settled.status, InvocationStatus::Succeeded);
3415 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
3417 assert_eq!(metering.invocations, 1);
3418 assert!(deploy
3420 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
3421 .await
3422 .unwrap()
3423 .is_none());
3424 assert!(deploy
3425 .get_metering(ProjectRef::DEFAULT, "worker")
3426 .await
3427 .unwrap()
3428 .is_none());
3429 }
3430
3431 #[cfg(feature = "handlers")]
3435 async fn poll_invocation_settled(
3436 deploy: &boatramp_core::deploy::DeployStore,
3437 project: ProjectRef<'_>,
3438 function: &str,
3439 id: &str,
3440 ) -> boatramp_core::function::Invocation {
3441 use boatramp_core::function::InvocationStatus;
3442 for _ in 0..200 {
3443 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
3444 if matches!(
3445 inv.status,
3446 InvocationStatus::Succeeded | InvocationStatus::Failed
3447 ) {
3448 return inv;
3449 }
3450 }
3451 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3452 }
3453 panic!("invocation {function}/{id} never settled");
3454 }
3455
3456 #[cfg(feature = "handlers")]
3461 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3462 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
3463 use crate::scheduler::{run_scheduler_tick, CronNow};
3464 use boatramp_core::deploy::DeployStore;
3465 use boatramp_core::function::{
3466 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3467 };
3468 use boatramp_handlers::{HandlerEngine, Limits};
3469 use futures::StreamExt;
3470
3471 const HTTP_200: &[u8] =
3472 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3473
3474 let storage = Arc::new(MemStorage::default());
3475 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3476 let deploy = DeployStore::new(storage.clone(), kv.clone());
3477 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3478 let stream: ByteStream =
3479 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3480 deploy.put_blob(&hash, stream).await.unwrap();
3481
3482 let function = Function {
3483 name: "worker".into(),
3484 owner: Owner::Project("default".into()),
3485 versions: vec![FunctionVersion {
3486 id: "v1".into(),
3487 component: hash.clone(),
3488 created: 0,
3489 lifecycle: Lifecycle::Independent,
3490 }],
3491 active: "v1".into(),
3492 aliases: Default::default(),
3493 config: Default::default(),
3494 };
3495 deploy
3496 .put_function(ProjectRef::DEFAULT, &function)
3497 .await
3498 .unwrap();
3499
3500 let base = Invocation {
3503 id: String::new(),
3504 function: "worker".into(),
3505 version: "v1".into(),
3506 mode: InvokeMode::Async,
3507 status: InvocationStatus::Running,
3508 idempotency_key: None,
3509 attempts: 1,
3510 lease_expires: None,
3511 request_b64: None,
3512 request_content_type: None,
3513 result: None,
3514 created: 0,
3515 updated: 0,
3516 };
3517 let orphan = Invocation {
3518 id: "orphan".into(),
3519 lease_expires: Some(1),
3520 ..base.clone()
3521 };
3522 deploy
3523 .put_invocation(ProjectRef::DEFAULT, &orphan)
3524 .await
3525 .unwrap();
3526 let live = Invocation {
3527 id: "live".into(),
3528 lease_expires: Some(u64::MAX),
3529 ..base.clone()
3530 };
3531 deploy
3532 .put_invocation(ProjectRef::DEFAULT, &live)
3533 .await
3534 .unwrap();
3535
3536 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3537 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3538 let inner = rt.inner.as_ref().unwrap();
3539
3540 let now = CronNow {
3541 minute: 0,
3542 hour: 0,
3543 dom: 1,
3544 month: 1,
3545 dow: 0,
3546 minute_stamp: 0,
3547 };
3548 let mut wasm_cache = std::collections::HashMap::new();
3549 let mut cron_state = std::collections::HashMap::new();
3550 let mut sweep = std::collections::HashMap::new();
3551 run_scheduler_tick(
3552 inner,
3553 &deploy,
3554 &mut wasm_cache,
3555 &mut cron_state,
3556 &mut sweep,
3557 now,
3558 )
3559 .await
3560 .unwrap();
3561
3562 let settled =
3564 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
3565 assert_eq!(settled.status, InvocationStatus::Succeeded);
3566 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
3567 assert_eq!(
3568 settled.lease_expires, None,
3569 "a settled invocation drops its lease"
3570 );
3571 let live_after = deploy
3573 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
3574 .await
3575 .unwrap()
3576 .unwrap();
3577 assert_eq!(live_after.status, InvocationStatus::Running);
3578 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
3579 assert_eq!(live_after.lease_expires, Some(u64::MAX));
3580 }
3581
3582 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3597 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
3598 use boatramp_core::deploy::DeployStore;
3599 use boatramp_core::function::{
3600 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3601 };
3602 use boatramp_handlers::{HandlerEngine, Limits};
3603 use futures::StreamExt;
3604
3605 let storage = Arc::new(MemStorage::default());
3606 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3607 let deploy = DeployStore::new(storage.clone(), kv.clone());
3608
3609 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3612 let stream: ByteStream =
3613 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3614 deploy.put_blob(&hash, stream).await.unwrap();
3615
3616 let store = Function {
3621 name: "store".into(),
3622 owner: Owner::Project("default".into()),
3623 versions: vec![FunctionVersion {
3624 id: "v1".into(),
3625 component: hash.clone(),
3626 created: 0,
3627 lifecycle: Lifecycle::Independent,
3628 }],
3629 active: "v1".into(),
3630 aliases: Default::default(),
3631 config: FunctionConfig {
3632 imports: vec!["wasi:keyvalue".into()],
3633 ..Default::default()
3634 },
3635 };
3636 let acme = ProjectRef::new("acme");
3637 let globex = ProjectRef::new("globex");
3638
3639 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3640 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3641 let inner = rt.inner.as_ref().unwrap();
3642
3643 let request = || {
3644 axum::http::Request::builder()
3645 .method("GET")
3646 .uri("/")
3647 .body(axum::body::Body::empty())
3648 .unwrap()
3649 };
3650
3651 let component = store.resolve(&store.active).unwrap().to_owned();
3654 for project in [acme, globex, ProjectRef::DEFAULT] {
3655 let (response, _) = execute_function(
3656 inner,
3657 &deploy,
3658 project,
3659 &store,
3660 &component,
3661 request(),
3662 0,
3663 boatramp_handlers::Lane::Sync,
3664 )
3665 .await;
3666 assert!(response.status().is_success(), "invocation should succeed");
3667 }
3668
3669 assert_eq!(
3672 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3673 Some(b"1".to_vec()),
3674 "acme's write must be tenant-qualified"
3675 );
3676 assert_eq!(
3677 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3678 Some(b"1".to_vec()),
3679 "globex's write must be tenant-qualified"
3680 );
3681 assert_eq!(
3682 kv.get("hkv/fn/store/hits").await.unwrap(),
3683 Some(b"1".to_vec()),
3684 "the default project must keep the byte-identical pre-project key"
3685 );
3686 }
3689
3690 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3694 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3695 use boatramp_core::config::{
3696 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3697 };
3698 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3699 use boatramp_handlers::{HandlerEngine, Limits};
3700 use futures::StreamExt;
3701 use std::sync::atomic::Ordering;
3702
3703 let storage = Arc::new(MemStorage::default());
3704 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3705 let deploy = DeployStore::new(storage.clone(), kv.clone());
3706
3707 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3708 let stream: ByteStream =
3709 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3710 deploy.put_blob(&hash, stream).await.unwrap();
3711 let mut files = std::collections::BTreeMap::new();
3712 files.insert(
3713 "counter.wasm".to_string(),
3714 FileEntry {
3715 hash: hash.clone(),
3716 size: KV_COUNTER.len() as u64,
3717 content_type: None,
3718 variants: std::collections::BTreeMap::new(),
3719 },
3720 );
3721 let manifest = Manifest {
3722 files,
3723 config: DeployConfig {
3724 handlers: vec![HandlerConfig {
3725 route: "/".into(),
3726 methods: Vec::new(),
3727 component: "counter.wasm".into(),
3728 imports: vec!["wasi:keyvalue".into()],
3729 streaming: false,
3730 limits: None,
3731 env: std::collections::BTreeMap::new(),
3732 invoke_targets: Vec::new(),
3733 }],
3734 crons: vec![CronConfig {
3735 schedule: "* * * * *".into(),
3736 route: "/".into(),
3737 overlap: Overlap::Skip,
3738 }],
3739 ..Default::default()
3740 },
3741 ..Default::default()
3742 };
3743 let id = deploy.put_manifest(&manifest).await.unwrap();
3744 deploy
3745 .activate(ProjectRef::DEFAULT, "blog", &id)
3746 .await
3747 .unwrap();
3748 deploy
3749 .set_site_config(
3750 ProjectRef::DEFAULT,
3751 "blog",
3752 &SiteConfig {
3753 handlers: Some(HandlersSiteConfig {
3754 enabled: true,
3755 allow_imports: vec!["wasi:keyvalue".into()],
3756 ..Default::default()
3757 }),
3758 ..Default::default()
3759 },
3760 )
3761 .await
3762 .unwrap();
3763
3764 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3765 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3766 let inner = rt.inner.clone().unwrap();
3767 let mut wasm = std::collections::HashMap::new();
3768 let mut crons = std::collections::HashMap::new();
3769 let mut sweep = std::collections::HashMap::new();
3770 let at = |stamp| CronNow {
3771 minute: 0,
3772 hour: 0,
3773 dom: 1,
3774 month: 1,
3775 dow: 0,
3776 minute_stamp: stamp,
3777 };
3778
3779 let (_, handles) =
3781 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3782 .await
3783 .unwrap();
3784 for h in handles {
3785 h.await.unwrap();
3786 }
3787 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3788
3789 let (_, handles) =
3791 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3792 .await
3793 .unwrap();
3794 assert!(handles.is_empty());
3795 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3796
3797 let (_, handles) =
3799 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3800 .await
3801 .unwrap();
3802 for h in handles {
3803 h.await.unwrap();
3804 }
3805 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3806
3807 crons
3811 .get("default|blog|cron|0")
3812 .unwrap()
3813 .running
3814 .store(true, Ordering::Release);
3815 let (_, handles) =
3816 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3817 .await
3818 .unwrap();
3819 assert!(handles.is_empty());
3820 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3821 }
3822
3823 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3827 async fn cron_leader_gate_suppresses_crons_off_leader() {
3828 use boatramp_core::config::{
3829 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3830 };
3831 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3832 use boatramp_handlers::{HandlerEngine, Limits};
3833 use futures::StreamExt;
3834
3835 let storage = Arc::new(MemStorage::default());
3836 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3837 let deploy = DeployStore::new(storage.clone(), kv.clone());
3838
3839 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3840 let stream: ByteStream =
3841 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3842 deploy.put_blob(&hash, stream).await.unwrap();
3843 let mut files = std::collections::BTreeMap::new();
3844 files.insert(
3845 "counter.wasm".to_string(),
3846 FileEntry {
3847 hash: hash.clone(),
3848 size: KV_COUNTER.len() as u64,
3849 content_type: None,
3850 variants: std::collections::BTreeMap::new(),
3851 },
3852 );
3853 let manifest = Manifest {
3854 files,
3855 config: DeployConfig {
3856 handlers: vec![HandlerConfig {
3857 route: "/".into(),
3858 methods: Vec::new(),
3859 component: "counter.wasm".into(),
3860 imports: vec!["wasi:keyvalue".into()],
3861 streaming: false,
3862 limits: None,
3863 env: std::collections::BTreeMap::new(),
3864 invoke_targets: Vec::new(),
3865 }],
3866 crons: vec![CronConfig {
3867 schedule: "* * * * *".into(),
3868 route: "/".into(),
3869 overlap: Overlap::Skip,
3870 }],
3871 ..Default::default()
3872 },
3873 ..Default::default()
3874 };
3875 let id = deploy.put_manifest(&manifest).await.unwrap();
3876 deploy
3877 .activate(ProjectRef::DEFAULT, "blog", &id)
3878 .await
3879 .unwrap();
3880 deploy
3881 .set_site_config(
3882 ProjectRef::DEFAULT,
3883 "blog",
3884 &SiteConfig {
3885 handlers: Some(HandlersSiteConfig {
3886 enabled: true,
3887 allow_imports: vec!["wasi:keyvalue".into()],
3888 ..Default::default()
3889 }),
3890 ..Default::default()
3891 },
3892 )
3893 .await
3894 .unwrap();
3895
3896 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3897 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3898 rt.set_cron_leader_gate(Arc::new(|| false));
3900 let inner = rt.inner.clone().unwrap();
3901 let mut wasm = std::collections::HashMap::new();
3902 let mut crons = std::collections::HashMap::new();
3903 let mut sweep = std::collections::HashMap::new();
3904 let now = CronNow {
3905 minute: 0,
3906 hour: 0,
3907 dom: 1,
3908 month: 1,
3909 dow: 0,
3910 minute_stamp: 100,
3911 };
3912
3913 let (_, handles) =
3914 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3915 .await
3916 .unwrap();
3917 assert!(handles.is_empty(), "a non-leader must not fire crons");
3919 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3920 }
3921
3922 #[tokio::test]
3929 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3930 use boatramp_core::config::HandlersSiteConfig;
3931 use boatramp_core::project::ProjectRef;
3932 use boatramp_handlers::{HandlerEngine, Limits};
3933
3934 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3935 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3936 let sql_dir =
3938 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3939 let _ = std::fs::remove_dir_all(&sql_dir);
3940 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3941 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3942
3943 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3944 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3945 let inner = rt.inner.as_ref().unwrap();
3946
3947 let site = HandlersSiteConfig {
3949 enabled: true,
3950 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3951 ..Default::default()
3952 };
3953 let env = std::collections::BTreeMap::new();
3954 let build = |imports: &[&str]| {
3955 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3956 let site = &site;
3957 let env = &env;
3958 async move {
3959 crate::handler_dispatch::build_bindings(
3960 inner,
3961 ProjectRef::new("default"),
3962 "shop",
3963 "shop",
3964 None,
3965 &imports,
3966 site,
3967 env,
3968 &[],
3969 0,
3970 None,
3971 )
3972 .await
3973 .expect("no secrets → resolves")
3974 .sql_database_names()
3975 }
3976 };
3977
3978 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3981 assert_eq!(
3983 build(&["sql", "sql:*"]).await,
3984 vec!["", "privileged", "product"]
3985 );
3986 assert!(build(&["sql:secret"]).await.is_empty());
3988 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3990
3991 let _ = std::fs::remove_dir_all(&sql_dir);
3992 }
3993}