1use std::future::Future;
15use std::net::{IpAddr, SocketAddr};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::body::Body;
20use axum::extract::{ConnectInfo, Path, Query, Request, State};
21use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{any, get, post, put};
24use axum::{Extension, Json, Router};
25use boatramp_core::access::{AccessConfig, BasicAuth};
26use boatramp_core::authz::{GrantedRole, TokenMeta};
27use boatramp_core::config::{DeployConfig, SiteConfig};
28use boatramp_core::cose::{self, Claims, Signer};
29use boatramp_core::deploy::{
30 DeployMetaInput, DeployStore, FileEntry, GcOptions, GcReport, Manifest,
31};
32use boatramp_core::matcher::Pattern;
33use boatramp_core::route::{self, Outcome};
34use boatramp_core::{DeployError, StorageError};
35use futures::StreamExt;
36use serde::{Deserialize, Serialize};
37
38mod admin_api;
39pub mod sql_shim;
40#[cfg(feature = "oidc")]
41pub(crate) use admin_api::auth_exchange;
42pub(crate) use admin_api::{
43 activate_deployment, cert_status, create_deployment, current_deployment, delete_compute,
44 delete_site, get_compute, get_daemon_config, get_deployment, get_site_config, invalidate_cache,
45 list_aliases, list_compute, list_deployments, list_sites, prune_delete, prune_report, put_blob,
46 put_compute, put_daemon_config, put_site_config, remove_alias, rollback_daemon_config,
47 scrub_blobs, set_alias,
48};
49#[cfg(feature = "handlers")]
50pub(crate) use admin_api::{
51 delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
52 list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
53 put_graphql_subgraph, register_graphql_safelist,
54};
55mod auth;
56#[cfg(feature = "console")]
57pub mod console;
58mod content;
59mod control_api;
60#[cfg(feature = "compression")]
61pub(crate) use content::maybe_compress;
62pub(crate) use content::multipart_byteranges;
63pub(crate) use content::{
64 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
65};
66pub(crate) use control_api::{
67 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
68 cluster_revoke, cluster_rotate_key, create_join_token, create_token, get_authz_policy,
69 list_root_anchors, list_tokens, put_authz_policy, remove_root_anchor, revoke_token,
70};
71#[cfg(all(test, feature = "handlers"))]
72use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
73mod domain_verify;
74pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
75pub mod envelope;
76#[cfg(feature = "handlers")]
77mod graphql_apq;
78#[cfg(feature = "handlers")]
79mod graphql_cache;
80#[cfg(feature = "handlers")]
81mod graphql_data;
82#[cfg(feature = "handlers")]
83mod graphql_federation;
84#[cfg(feature = "handlers")]
85mod graphql_gateway;
86#[cfg(feature = "handlers")]
87mod graphql_graphiql;
88#[cfg(feature = "handlers")]
89mod graphql_guard;
90#[cfg(feature = "handlers")]
91mod graphql_plan;
92#[cfg(feature = "handlers")]
93mod graphql_registry;
94#[cfg(feature = "handlers")]
95mod graphql_subscription;
96#[cfg(feature = "handlers")]
97mod handler_cache;
98#[cfg(feature = "handlers")]
99mod handler_dispatch;
100#[cfg(feature = "handlers")]
101pub(crate) use handler_dispatch::{
102 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
103 read_blob_fully,
104};
105#[cfg(all(feature = "handlers", test))]
106use handler_dispatch::{resolve_env, set_forwarded_headers};
107mod function_api;
108pub(crate) use function_api::{
109 alias_function, deploy_function, list_functions, remove_function, rollback_function,
110};
111#[cfg(all(test, feature = "handlers"))]
112use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
113mod gateway;
114mod host;
115pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
116#[cfg(feature = "http3")]
117mod http3;
118mod limits;
119#[cfg(feature = "handlers")]
120mod logs;
121#[cfg(feature = "handlers")]
122mod metrics;
123#[cfg(feature = "oidc")]
124mod oidc;
125mod operator;
126pub(crate) use operator::prometheus_metrics;
127#[cfg(feature = "handlers")]
128pub(crate) use operator::{
129 operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
130};
131mod proxy;
132pub use proxy::spawn_compute_reconcile;
133pub(crate) use proxy::{
134 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
135 proxy, COMPUTE_WAKE_TIMEOUT,
136};
137#[cfg(feature = "handlers")]
139pub(crate) use proxy::is_upgrade_request;
140#[cfg(all(test, feature = "handlers"))]
141use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
142mod project_api;
143pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
144mod project_scope;
145pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
146mod ratelimit;
147mod routes;
148pub use routes::{router, router_with};
149#[cfg(feature = "mcp")]
150mod mcp_http;
151#[cfg(feature = "handlers")]
152mod scheduler;
153mod serve_pipeline;
154pub use serve_pipeline::http_redirect_router;
155#[cfg(all(test, feature = "handlers"))]
156use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
157pub(crate) use serve_pipeline::{
158 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
159 BootstrapAttestation,
160};
161pub mod signer;
164mod srvmetrics;
165#[cfg(all(feature = "handlers", test))]
166use scheduler::run_scheduler_tick;
167#[cfg(feature = "handlers")]
168pub(crate) use scheduler::{
169 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
170};
171#[cfg(feature = "handlers")]
172use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
173#[cfg(feature = "handlers")]
174mod function_runtime;
175#[cfg(feature = "handlers")]
176pub(crate) use function_runtime::{
177 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
178 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
179 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
180 put_trigger_handler, webhook_ingress,
181};
182#[cfg(feature = "handlers")]
183mod stream;
184#[cfg(feature = "handlers")]
185mod workflow;
186pub use auth::{require_auth, Auth};
187#[cfg(feature = "http3")]
188pub use http3::{
189 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
190 Http3Error,
191};
192pub use limits::{ServerLimits, UploadGuard};
193#[cfg(feature = "oidc")]
194pub use oidc::{OidcConfig, OidcError, OidcVerifier};
195use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
196#[cfg(feature = "handlers")]
197pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
198#[cfg(feature = "handlers")]
199pub(crate) use workflow::{
200 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
201 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
202};
203pub use srvmetrics::{server_metrics, ServerMetrics};
206
207#[derive(Clone, Default)]
212pub struct HandlerRuntime {
213 #[cfg(feature = "handlers")]
214 inner: Option<Arc<HandlerRuntimeInner>>,
215}
216
217#[cfg(feature = "handlers")]
218struct HandlerRuntimeInner {
219 engine: boatramp_handlers::HandlerEngine,
220 async_drain_gate: Arc<tokio::sync::Semaphore>,
226 kv: Arc<dyn boatramp_core::kv::KvStore>,
227 storage: Arc<dyn boatramp_core::Storage>,
228 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
232 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
235 site_semaphores:
238 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
239 stream_semaphores:
243 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
244 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
247 metrics: metrics::Metrics,
250 logs: Arc<logs::LogStore>,
252 #[cfg(feature = "handlers")]
256 graphql_cache: graphql_cache::GraphqlCache,
257 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
263 max_blob_bytes: std::sync::OnceLock<u64>,
267 max_component_bytes: std::sync::OnceLock<u64>,
272 function_meter_locks:
276 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
277 function_semaphores:
280 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
281 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
286 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
290 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
298 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
303}
304
305pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
308
309impl HandlerRuntime {
310 pub fn disabled() -> Self {
312 Self::default()
313 }
314
315 #[cfg(feature = "handlers")]
321 pub fn new(
322 engine: boatramp_handlers::HandlerEngine,
323 kv: Arc<dyn boatramp_core::kv::KvStore>,
324 storage: Arc<dyn boatramp_core::Storage>,
325 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
326 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
327 ) -> Self {
328 let async_drain_slots = engine.async_max_concurrency().max(1);
331 Self {
332 inner: Some(Arc::new(HandlerRuntimeInner {
333 engine,
334 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
335 kv,
336 storage,
337 sql,
338 messaging,
339 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
340 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
341 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
342 metrics: metrics::Metrics::default(),
343 logs: Arc::new(logs::LogStore::default()),
344 #[cfg(feature = "handlers")]
345 graphql_cache: graphql_cache::GraphqlCache::default(),
346 cron_leader_gate: std::sync::OnceLock::new(),
347 max_blob_bytes: std::sync::OnceLock::new(),
348 max_component_bytes: std::sync::OnceLock::new(),
349 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
350 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
351 watch_provider: std::sync::OnceLock::new(),
352 provision_tier: std::sync::OnceLock::new(),
353 invoker: std::sync::OnceLock::new(),
354 federation_runner: std::sync::OnceLock::new(),
355 })),
356 }
357 }
358
359 #[cfg(feature = "handlers")]
362 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
363 self.inner.as_ref().and_then(|inner| inner.sql.clone())
364 }
365
366 #[cfg(feature = "handlers")]
370 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
371 self.inner
372 .as_ref()
373 .and_then(|inner| inner.invoker.get().cloned())
374 }
375
376 #[cfg(feature = "handlers")]
380 pub(crate) async fn introspect_subgraph_sdl(
381 &self,
382 deploy: &DeployStore,
383 project: boatramp_core::project::ProjectRef<'_>,
384 function: &boatramp_core::function::Function,
385 component: &str,
386 ) -> Result<String, function_runtime::SubgraphSdlError> {
387 match self.inner.as_ref() {
388 Some(inner) => {
389 function_runtime::introspect_service_sdl(
390 inner, deploy, project, function, component,
391 )
392 .await
393 }
394 None => Err(function_runtime::SubgraphSdlError::Unavailable),
395 }
396 }
397
398 #[cfg(feature = "handlers")]
404 pub fn set_invoker(&self, deploy: DeployStore) {
405 if let Some(inner) = self.inner.as_ref() {
406 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
407 deploy,
408 Arc::downgrade(inner),
409 ));
410 let _ = inner.invoker.set(invoker);
411 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
414 inner,
415 )));
416 let _ = inner.federation_runner.set(runner);
417 }
418 }
419
420 #[cfg(feature = "handlers")]
424 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
425 self.inner.as_ref().and_then(|inner| inner.sql.clone())
426 }
427
428 #[cfg(feature = "handlers")]
432 pub fn set_watch_provider(
433 &self,
434 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
435 ) {
436 if let Some(inner) = self.inner.as_ref() {
437 let _ = inner.watch_provider.set(provider);
438 }
439 }
440
441 #[cfg(feature = "handlers")]
445 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
446 if let Some(inner) = self.inner.as_ref() {
447 let _ = inner.provision_tier.set(tier);
448 }
449 }
450
451 #[cfg(feature = "handlers")]
455 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
456 if let Some(inner) = self.inner.as_ref() {
457 let _ = inner.max_blob_bytes.set(max_bytes);
458 }
459 }
460
461 #[cfg(feature = "handlers")]
464 pub fn set_max_component_bytes(&self, max_bytes: u64) {
465 if let Some(inner) = self.inner.as_ref() {
466 let _ = inner.max_component_bytes.set(max_bytes);
467 }
468 }
469
470 #[cfg(feature = "handlers")]
475 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
476 if let Some(inner) = self.inner.as_ref() {
477 let _ = inner.cron_leader_gate.set(gate);
478 }
479 }
480
481 #[cfg(feature = "handlers")]
488 async fn precheck_activation(
489 &self,
490 deploy: &DeployStore,
491 manifest: &Manifest,
492 site_config: Option<&SiteConfig>,
493 ) -> Result<(), String> {
494 let Some(inner) = self.inner.as_ref() else {
495 return Ok(());
496 };
497 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
500 return Ok(());
501 }
502 let site_handlers = site_config
504 .and_then(|c| c.handlers.as_ref())
505 .filter(|h| h.enabled)
506 .ok_or_else(|| {
507 "deployment ships handlers/consumers but the site has them disabled".to_string()
508 })?;
509 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
510
511 for handler in &manifest.config.handlers {
513 precheck_component(
514 deploy,
515 manifest,
516 site_handlers,
517 inner,
518 max_component,
519 &handler.imports,
520 &handler.component,
521 &format!("handler {:?}", handler.route),
522 false,
523 )
524 .await?;
525 }
526 for consumer in &manifest.config.consumers {
527 precheck_component(
528 deploy,
529 manifest,
530 site_handlers,
531 inner,
532 max_component,
533 &consumer.imports,
534 &consumer.component,
535 &format!("consumer {:?}", consumer.topic),
536 true,
537 )
538 .await?;
539 }
540 Ok(())
541 }
542
543 #[cfg(not(feature = "handlers"))]
544 async fn precheck_activation(
545 &self,
546 _deploy: &DeployStore,
547 _manifest: &Manifest,
548 _site_config: Option<&SiteConfig>,
549 ) -> Result<(), String> {
550 Ok(())
551 }
552}
553
554#[derive(Default, Clone)]
560pub struct ServerOptions {
561 pub limits: ServerLimits,
563 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
566 pub default_site: Option<String>,
569 pub implicit_routing: bool,
575 pub protect_previews: bool,
580 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
584 pub issuer: Option<Arc<dyn Signer>>,
588 pub bootstrap_secret: Option<String>,
593 pub bootstrap_attestation: Option<String>,
599 pub mesh_control: Option<Arc<dyn MeshControl>>,
603 pub cors_allowed_origins: Vec<String>,
613 #[cfg(feature = "oidc")]
616 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
617 pub posture: boatramp_core::security::SecurityPosture,
621 pub served_over_tls: bool,
626 pub pop_origin: Option<String>,
633 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
637 #[cfg(feature = "console")]
641 pub console: Option<console::ConsoleMount>,
642}
643
644#[derive(Clone, Copy)]
648struct ServedOverTls(bool);
649
650#[derive(Clone, Copy, Default)]
655struct ImplicitRouting(bool);
656
657const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
669
670pub struct DaemonRuntime {
671 baseline: boatramp_core::daemon_config::ConfigBaseline,
672 state: std::sync::RwLock<DaemonState>,
673 reload: tokio::sync::Notify,
676}
677
678struct DaemonState {
679 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
680 generation: Option<String>,
681}
682
683pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
688 #[cfg(feature = "console")]
692 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
693 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
694 None => (false, None, None),
695 };
696 #[cfg(not(feature = "console"))]
697 let (console_enabled, console_host, console_path) = (false, None, None);
698 boatramp_core::daemon_config::ConfigBaseline {
699 default_site: options.default_site.clone(),
700 protect_previews: options.protect_previews,
701 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
702 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
703 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
704 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
705 compute_vcpus: 0,
706 compute_mem_mib: 0,
707 console_enabled,
708 console_host,
709 console_path,
710 max_upload_ceiling: options.posture.max_upload_bytes,
711 max_concurrent_uploads_ceiling: None,
712 posture: options.posture,
713 }
714}
715
716impl DaemonRuntime {
717 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
721 let effective =
722 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
723 Self {
724 baseline,
725 state: std::sync::RwLock::new(DaemonState {
726 effective,
727 generation: None,
728 }),
729 reload: tokio::sync::Notify::new(),
730 }
731 }
732
733 pub fn notify_reload(&self) {
737 self.reload.notify_one();
738 }
739
740 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
742 self.state
743 .read()
744 .expect("daemon config lock")
745 .effective
746 .clone()
747 }
748
749 pub fn generation(&self) -> Option<String> {
752 self.state
753 .read()
754 .expect("daemon config lock")
755 .generation
756 .clone()
757 }
758
759 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
761 &self.baseline
762 }
763
764 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
767 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
768 let generation = deploy.daemon_config_generation().await?;
769 let effective = Arc::new(cfg.resolve(&self.baseline));
770 *self.state.write().expect("daemon config lock") = DaemonState {
771 effective,
772 generation,
773 };
774 Ok(())
775 }
776}
777
778#[derive(Clone, Copy, Default)]
781struct PreviewPolicy {
782 protect: bool,
783}
784
785#[derive(Clone, Default)]
790struct Issuer(Option<Arc<dyn Signer>>);
791
792#[derive(Clone, Default)]
797struct BootstrapGate(Option<Arc<BootstrapInner>>);
798
799struct BootstrapInner {
800 secret_hash: String,
803 lock: tokio::sync::Mutex<()>,
806}
807
808impl BootstrapGate {
809 fn new(secret: Option<&str>) -> Self {
810 Self(secret.filter(|s| !s.is_empty()).map(|s| {
811 Arc::new(BootstrapInner {
812 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
813 lock: tokio::sync::Mutex::new(()),
814 })
815 }))
816 }
817}
818
819#[async_trait::async_trait]
823pub trait MeshControl: Send + Sync {
824 async fn admit(
832 &self,
833 mesh_pubkey_hex: &str,
834 jti: &str,
835 possession_proof: &[u8],
836 proof_iat: u64,
837 now: u64,
838 advertise_addr: Option<&str>,
839 ) -> Result<JoinOutcome, String>;
840
841 async fn rotate_key(&self) -> Result<String, String>;
845
846 async fn revoke(&self, node: u64) -> Result<(), String>;
850
851 async fn members(&self) -> Result<Vec<MeshMember>, String>;
855
856 async fn promote(&self, node: u64) -> Result<(), String>;
859}
860
861pub enum JoinOutcome {
863 Admitted {
866 members: Vec<String>,
868 addrs: std::collections::BTreeMap<u64, String>,
870 },
871 TokenSpent,
873 ProofInvalid,
875 Revoked,
878}
879
880#[derive(Debug, Clone, Serialize)]
882pub struct MeshMember {
883 pub node: u64,
885 pub voter: bool,
887 pub caught_up: bool,
889 pub leader: bool,
891 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub addr: Option<String>,
896}
897
898#[derive(Clone, Default)]
901struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
902
903#[cfg(feature = "oidc")]
905#[derive(Clone, Default)]
906struct OidcState(Option<Arc<oidc::OidcVerifier>>);
907
908#[cfg(feature = "oidc")]
911const EXCHANGE_TTL_SECS: u64 = 3600;
912
913use boatramp_core::time::now_unix;
914
915#[derive(Clone)]
917struct CorsState(Arc<Vec<String>>);
918
919const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
921const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
924const CORS_MAX_AGE: &str = "600";
926
927fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
931 allowed.iter().any(|a| a == "*" || a == origin)
932}
933
934async fn cors(
941 State(allowed): State<CorsState>,
942 request: Request,
943 next: axum::middleware::Next,
944) -> Response {
945 let origin = request
946 .headers()
947 .get(header::ORIGIN)
948 .and_then(|v| v.to_str().ok())
949 .filter(|o| cors_origin_allowed(&allowed.0, o))
950 .map(str::to_string);
951 let is_preflight = request.method() == Method::OPTIONS
953 && request
954 .headers()
955 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
956 if is_preflight {
957 let allow_headers = request
959 .headers()
960 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
961 .and_then(|v| v.to_str().ok())
962 .map(str::to_string)
963 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
964 let mut response = Response::new(Body::empty());
965 *response.status_mut() = StatusCode::NO_CONTENT;
966 if let Some(origin) = origin {
967 let headers = response.headers_mut();
968 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
969 set_header(headers, header::VARY, "Origin");
970 set_header(
971 headers,
972 header::ACCESS_CONTROL_ALLOW_METHODS,
973 CORS_ALLOW_METHODS,
974 );
975 set_header(
976 headers,
977 header::ACCESS_CONTROL_ALLOW_HEADERS,
978 &allow_headers,
979 );
980 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
981 }
982 return response;
983 }
984 let mut response = next.run(request).await;
985 if let Some(origin) = origin {
986 let headers = response.headers_mut();
987 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
988 if let Ok(value) = HeaderValue::from_str("Origin") {
991 headers.append(header::VARY, value);
992 }
993 }
994 response
995}
996
997const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1002
1003#[derive(Debug, thiserror::Error)]
1005pub enum ServeError {
1006 #[error("server I/O: {0}")]
1008 Io(#[from] std::io::Error),
1009}
1010
1011pub async fn serve(
1014 addr: SocketAddr,
1015 deploy: DeployStore,
1016 auth: Auth,
1017 handlers: HandlerRuntime,
1018) -> Result<(), ServeError> {
1019 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1020}
1021
1022pub async fn serve_with(
1024 addr: SocketAddr,
1025 deploy: DeployStore,
1026 auth: Auth,
1027 handlers: HandlerRuntime,
1028 options: ServerOptions,
1029) -> Result<(), ServeError> {
1030 let listener = tokio::net::TcpListener::bind(addr).await?;
1031 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1032 #[cfg(feature = "handlers")]
1035 let scheduler = handlers.spawn_scheduler(deploy.clone());
1036 let gateway_prober = gateway::spawn_active_health_prober();
1040 let app = router_with(deploy, auth, handlers, options)
1043 .into_make_service_with_connect_info::<SocketAddr>();
1044
1045 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1049 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
1050 shutdown_signal().await;
1051 let _ = signalled_tx.send(true);
1052 });
1053 let signalled = {
1054 let mut rx = signalled_rx;
1055 async move {
1056 let _ = rx.wait_for(|fired| *fired).await;
1057 }
1058 };
1059 let result = serve_with_drain_deadline(
1060 async move { server.await.map_err(ServeError::from) },
1061 signalled,
1062 DRAIN_DEADLINE,
1063 )
1064 .await;
1065 #[cfg(feature = "handlers")]
1067 if let Some(handle) = scheduler {
1068 handle.abort();
1069 }
1070 gateway_prober.abort();
1071 result
1072}
1073
1074async fn serve_with_drain_deadline<Srv, Sig>(
1079 server: Srv,
1080 signalled: Sig,
1081 deadline: Duration,
1082) -> Result<(), ServeError>
1083where
1084 Srv: Future<Output = Result<(), ServeError>>,
1085 Sig: Future<Output = ()>,
1086{
1087 tokio::pin!(server);
1088 let drain_cap = async move {
1089 signalled.await;
1090 tokio::time::sleep(deadline).await;
1091 };
1092 tokio::select! {
1093 result = &mut server => result,
1094 _ = drain_cap => {
1095 tracing::warn!(
1096 deadline_s = deadline.as_secs(),
1097 "drain deadline exceeded; forcing shutdown with requests still in flight"
1098 );
1099 Ok(())
1100 }
1101 }
1102}
1103
1104pub async fn shutdown_signal() {
1107 let ctrl_c = async {
1108 let _ = tokio::signal::ctrl_c().await;
1109 };
1110 #[cfg(unix)]
1111 let terminate = async {
1112 if let Ok(mut sig) =
1113 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1114 {
1115 sig.recv().await;
1116 }
1117 };
1118 #[cfg(not(unix))]
1119 let terminate = std::future::pending::<()>();
1120
1121 tokio::select! {
1122 _ = ctrl_c => {}
1123 _ = terminate => {}
1124 }
1125 tracing::info!("shutdown signal received; draining");
1126}
1127
1128async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1132 match daemon.generation() {
1133 Some(gen) => format!("ok gen={gen}"),
1134 None => "ok".to_string(),
1135 }
1136}
1137
1138async fn readyz(State(deploy): State<DeployStore>) -> Response {
1140 match deploy.ready().await {
1141 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1142 Err(err) => {
1143 tracing::warn!(error = %err, "readiness probe failed");
1144 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1145 }
1146 }
1147}
1148
1149#[derive(Clone)]
1154pub struct RequestId(pub String);
1155
1156fn request_id_for(headers: &HeaderMap) -> String {
1159 if let Some(id) = headers
1160 .get("x-request-id")
1161 .and_then(|v| v.to_str().ok())
1162 .map(str::trim)
1163 .filter(|s| !s.is_empty())
1164 {
1165 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1166 }
1167 use std::sync::atomic::{AtomicU64, Ordering};
1168 static SEQ: AtomicU64 = AtomicU64::new(0);
1169 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1170 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1171}
1172
1173struct AccessLog {
1177 request_id: String,
1178 method: Method,
1179 path: String,
1180 host: String,
1181 client: String,
1182 status: u16,
1183 encoding: String,
1185 start: std::time::Instant,
1186 bytes: std::sync::atomic::AtomicU64,
1187}
1188
1189impl Drop for AccessLog {
1190 fn drop(&mut self) {
1191 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1192 srvmetrics::server_metrics().record_request(self.status, bytes);
1195 tracing::info!(
1196 target: "boatramp::access",
1197 request_id = %self.request_id,
1198 method = %self.method,
1199 path = %self.path,
1200 host = %self.host,
1201 client = %self.client,
1202 status = self.status,
1203 bytes = bytes,
1204 encoding = %self.encoding,
1205 cache_result = srvmetrics::cache_result(self.status),
1206 elapsed_ms = self.start.elapsed().as_millis() as u64,
1207 "request"
1208 );
1209 }
1210}
1211
1212async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1216 let method = request.method().clone();
1217 let path = request.uri().path().to_string();
1218 let request_id = request_id_for(request.headers());
1221 request
1222 .extensions_mut()
1223 .insert(RequestId(request_id.clone()));
1224 let host = request
1225 .headers()
1226 .get(header::HOST)
1227 .and_then(|value| value.to_str().ok())
1228 .unwrap_or("-")
1229 .to_string();
1230 let client = request
1231 .extensions()
1232 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1233 .map(|info| info.0.ip().to_string())
1234 .unwrap_or_else(|| "-".to_string());
1235
1236 let start = std::time::Instant::now();
1237 let response = next.run(request).await;
1238 let encoding = response
1239 .headers()
1240 .get(header::CONTENT_ENCODING)
1241 .and_then(|v| v.to_str().ok())
1242 .unwrap_or("identity")
1243 .to_string();
1244 let log = AccessLog {
1245 request_id,
1246 method,
1247 path,
1248 host,
1249 client,
1250 status: response.status().as_u16(),
1251 encoding,
1252 start,
1253 bytes: std::sync::atomic::AtomicU64::new(0),
1254 };
1255
1256 let (parts, body) = response.into_parts();
1260 let counted = body.into_data_stream().map(move |chunk| {
1261 if let Ok(bytes) = &chunk {
1262 log.bytes
1263 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1264 }
1265 chunk
1266 });
1267 Response::from_parts(parts, Body::from_stream(counted))
1268}
1269
1270fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1272 req_headers
1273 .get(header::IF_NONE_MATCH)
1274 .and_then(|value| value.to_str().ok())
1275 .is_some_and(|value| {
1276 value
1277 .split(',')
1278 .map(str::trim)
1279 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1280 })
1281}
1282
1283fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1284 if let Ok(value) = HeaderValue::from_str(value) {
1285 headers.insert(name, value);
1286 }
1287}
1288
1289fn not_found() -> Response {
1290 (StatusCode::NOT_FOUND, "not found\n").into_response()
1291}
1292
1293fn redirect(status: u16, location: &str) -> Response {
1294 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1295 match HeaderValue::from_str(location) {
1296 Ok(location) => {
1297 let mut headers = HeaderMap::new();
1298 headers.insert(header::LOCATION, location);
1299 (status, headers).into_response()
1300 }
1301 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1302 }
1303}
1304
1305fn deploy_error_response(err: DeployError) -> Response {
1307 let status = match &err {
1308 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1309 StatusCode::NOT_FOUND
1310 }
1311 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1312 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1313 DeployError::Conflict(_) => StatusCode::CONFLICT,
1315 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1317 _ => StatusCode::INTERNAL_SERVER_ERROR,
1318 };
1319 tracing::warn!(error = %err, "request failed");
1320 (status, format!("{err}\n")).into_response()
1321}
1322
1323fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1328 boatramp_core::project::validate_resource_name(kind, value)
1329 .err()
1330 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1331}
1332
1333#[cfg(test)]
1334mod drain_tests {
1335 use super::*;
1336
1337 #[tokio::test]
1338 async fn deadline_forces_shutdown_after_signal() {
1339 let server = std::future::pending::<Result<(), ServeError>>();
1342 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1344 assert!(result.is_ok());
1345 }
1346
1347 #[tokio::test]
1348 async fn server_finishing_first_wins() {
1349 let server = async { Ok(()) };
1352 let signalled = std::future::pending::<()>();
1353 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1354 assert!(result.is_ok());
1355 }
1356
1357 #[tokio::test]
1358 async fn deadline_does_not_trip_before_signal() {
1359 let server = async {
1363 tokio::time::sleep(Duration::from_millis(40)).await;
1364 Err(ServeError::Io(std::io::Error::other("server error")))
1365 };
1366 let signalled = std::future::pending::<()>();
1367 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1368 assert!(result.is_err());
1369 }
1370}
1371
1372#[cfg(all(test, feature = "handlers"))]
1373mod tests {
1374 use super::*;
1375 use boatramp_core::cose::{LocalSigner, TokenAlg};
1376 use boatramp_core::project::ProjectRef;
1377
1378 #[test]
1379 fn query_string_parses_and_url_decodes() {
1380 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1381 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1382 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")); }
1386
1387 #[test]
1388 fn cookie_header_parses_pairs() {
1389 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1390 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1391 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1392 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1393 }
1394
1395 #[test]
1396 fn apply_vary_merges_without_duplicates() {
1397 let base = (StatusCode::OK, "x").into_response();
1398 let r = apply_vary(base, &["accept-language".into()]);
1399 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1400 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1402 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1403 assert!(v.contains("accept-language") && v.contains("cookie"));
1404 assert_eq!(v.matches("accept-language").count(), 1);
1405 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1407 assert!(plain.headers().get(header::VARY).is_none());
1408 }
1409
1410 #[tokio::test]
1414 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1415 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1416 let public = keys.public_key();
1417
1418 let resp = create_join_token(
1420 Extension(Issuer(Some(keys.clone()))),
1421 Json(CreateJoinTokenRequest {
1422 ttl_secs: Some(600),
1423 }),
1424 )
1425 .await;
1426 assert_eq!(resp.status(), StatusCode::CREATED);
1427 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1428 .await
1429 .unwrap();
1430 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1431 let token = parsed["token"].as_str().unwrap();
1432 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1433 assert!(!jti.is_empty());
1434
1435 let no_issuer = create_join_token(
1437 Extension(Issuer(None)),
1438 Json(CreateJoinTokenRequest { ttl_secs: None }),
1439 )
1440 .await;
1441 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1442 }
1443
1444 #[tokio::test]
1449 async fn function_write_path_deploy_rollback_alias_remove() {
1450 use boatramp_core::function::Lifecycle;
1451 use boatramp_core::kv::MemoryKv;
1452 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1453
1454 struct FakeStorage {
1457 present: bool,
1458 }
1459 #[async_trait::async_trait]
1460 impl Storage for FakeStorage {
1461 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1462 Err(StorageError::NotFound(String::new()))
1463 }
1464 async fn get_range(
1465 &self,
1466 _: &str,
1467 _: u64,
1468 _: Option<u64>,
1469 ) -> Result<GetObject, StorageError> {
1470 Err(StorageError::NotFound(String::new()))
1471 }
1472 async fn put(
1473 &self,
1474 _: &str,
1475 _: ByteStream,
1476 _: PutMeta,
1477 ) -> Result<ObjectMeta, StorageError> {
1478 Err(StorageError::unsupported("fake"))
1479 }
1480 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1481 if self.present {
1482 Ok(ObjectMeta {
1483 key: key.to_string(),
1484 ..Default::default()
1485 })
1486 } else {
1487 Err(StorageError::NotFound(key.to_string()))
1488 }
1489 }
1490 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1491 Ok(())
1492 }
1493 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1494 Ok(Vec::new())
1495 }
1496 }
1497
1498 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1499 let status = resp.status();
1500 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1501 .await
1502 .unwrap();
1503 let value = if bytes.is_empty() {
1504 serde_json::Value::Null
1505 } else {
1506 serde_json::from_slice(&bytes).unwrap()
1507 };
1508 (status, value)
1509 }
1510
1511 let deploy = DeployStore::new(
1512 Arc::new(FakeStorage { present: true }),
1513 Arc::new(MemoryKv::new()),
1514 );
1515 let v1 = "a".repeat(64);
1516 let v2 = "b".repeat(64);
1517
1518 let (st, body) = body_json(
1520 deploy_function(
1521 State(deploy.clone()),
1522 axum::extract::Extension(crate::ProjectContext::default()),
1523 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1524 axum::extract::Query(DeployFunctionQuery::default()),
1525 Path("greeter".to_string()),
1526 Json(FunctionUpsert {
1527 component: v1.clone(),
1528 config: Default::default(),
1529 lifecycle: Lifecycle::Independent,
1530 }),
1531 )
1532 .await,
1533 )
1534 .await;
1535 assert_eq!(st, StatusCode::OK);
1536 assert_eq!(body["active"], v1);
1537
1538 let (_, body) = body_json(
1540 deploy_function(
1541 State(deploy.clone()),
1542 axum::extract::Extension(crate::ProjectContext::default()),
1543 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1544 axum::extract::Query(DeployFunctionQuery::default()),
1545 Path("greeter".to_string()),
1546 Json(FunctionUpsert {
1547 component: v2.clone(),
1548 config: Default::default(),
1549 lifecycle: Lifecycle::Independent,
1550 }),
1551 )
1552 .await,
1553 )
1554 .await;
1555 assert_eq!(body["active"], v2);
1556 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1557
1558 let (st, body) = body_json(
1560 rollback_function(
1561 State(deploy.clone()),
1562 axum::extract::Extension(crate::ProjectContext::default()),
1563 Path("greeter".to_string()),
1564 Json(RollbackBody { to: v1.clone() }),
1565 )
1566 .await,
1567 )
1568 .await;
1569 assert_eq!(st, StatusCode::OK);
1570 assert_eq!(body["active"], v1);
1571
1572 let resp = rollback_function(
1574 State(deploy.clone()),
1575 axum::extract::Extension(crate::ProjectContext::default()),
1576 Path("greeter".to_string()),
1577 Json(RollbackBody { to: "c".repeat(64) }),
1578 )
1579 .await;
1580 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1581
1582 let (st, body) = body_json(
1584 alias_function(
1585 State(deploy.clone()),
1586 axum::extract::Extension(crate::ProjectContext::default()),
1587 Path(("greeter".to_string(), "prod".to_string())),
1588 Json(AliasBody {
1589 version: v2.clone(),
1590 }),
1591 )
1592 .await,
1593 )
1594 .await;
1595 assert_eq!(st, StatusCode::OK);
1596 assert_eq!(body["aliases"]["prod"], v2);
1597
1598 let (st, _) = body_json(
1600 remove_function(
1601 State(deploy.clone()),
1602 axum::extract::Extension(crate::ProjectContext::default()),
1603 Path("greeter".to_string()),
1604 )
1605 .await,
1606 )
1607 .await;
1608 assert_eq!(st, StatusCode::NO_CONTENT);
1609 assert!(deploy
1610 .get_function(ProjectRef::DEFAULT, "greeter")
1611 .await
1612 .unwrap()
1613 .is_none());
1614
1615 let empty = DeployStore::new(
1617 Arc::new(FakeStorage { present: false }),
1618 Arc::new(MemoryKv::new()),
1619 );
1620 let resp = deploy_function(
1621 State(empty),
1622 axum::extract::Extension(crate::ProjectContext::default()),
1623 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1624 axum::extract::Query(DeployFunctionQuery::default()),
1625 Path("orphan".to_string()),
1626 Json(FunctionUpsert {
1627 component: v1.clone(),
1628 config: Default::default(),
1629 lifecycle: Lifecycle::default(),
1630 }),
1631 )
1632 .await;
1633 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1634 }
1635
1636 struct StubControl {
1640 admits: std::sync::Mutex<Vec<(String, String)>>,
1641 respond: StubJoin,
1642 }
1643 #[derive(Clone, Copy)]
1644 enum StubJoin {
1645 Admit,
1646 Spent,
1647 Invalid,
1648 Revoked,
1649 }
1650
1651 #[async_trait::async_trait]
1652 impl MeshControl for StubControl {
1653 async fn admit(
1654 &self,
1655 mesh_pubkey_hex: &str,
1656 jti: &str,
1657 _proof: &[u8],
1658 _proof_iat: u64,
1659 _now: u64,
1660 _advertise_addr: Option<&str>,
1661 ) -> Result<JoinOutcome, String> {
1662 self.admits
1663 .lock()
1664 .unwrap()
1665 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1666 Ok(match self.respond {
1667 StubJoin::Admit => JoinOutcome::Admitted {
1668 members: vec!["signed-member".to_string()],
1669 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1670 },
1671 StubJoin::Spent => JoinOutcome::TokenSpent,
1672 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1673 StubJoin::Revoked => JoinOutcome::Revoked,
1674 })
1675 }
1676 async fn rotate_key(&self) -> Result<String, String> {
1677 Ok("cafe".to_string())
1678 }
1679 async fn revoke(&self, _node: u64) -> Result<(), String> {
1680 Ok(())
1681 }
1682 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1683 Ok(Vec::new())
1684 }
1685 async fn promote(&self, _node: u64) -> Result<(), String> {
1686 Ok(())
1687 }
1688 }
1689
1690 #[tokio::test]
1694 async fn cluster_join_dispatches_and_maps_outcomes() {
1695 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1696 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1697 let auth = Auth::with_key(keys.public_key(), kv);
1698 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1699 let req = |proof: &str| JoinRequest {
1700 token: token.clone(),
1701 mesh_pubkey: "302a300506032b6570032100feed".into(),
1702 possession_proof: proof.to_string(),
1703 proof_iat: now_unix(),
1704 advertise_addr: Some("https://joiner:7000".into()),
1705 };
1706
1707 let admitter = Arc::new(StubControl {
1709 admits: std::sync::Mutex::new(Vec::new()),
1710 respond: StubJoin::Admit,
1711 });
1712 let resp = cluster_join(
1713 Extension(auth.clone()),
1714 Extension(MeshControlHandle(Some(admitter.clone()))),
1715 Json(req("aa01")),
1716 )
1717 .await;
1718 assert_eq!(resp.status(), StatusCode::OK);
1719 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1720
1721 let spent = Arc::new(StubControl {
1723 admits: std::sync::Mutex::new(Vec::new()),
1724 respond: StubJoin::Spent,
1725 });
1726 assert_eq!(
1727 cluster_join(
1728 Extension(auth.clone()),
1729 Extension(MeshControlHandle(Some(spent))),
1730 Json(req("aa01")),
1731 )
1732 .await
1733 .status(),
1734 StatusCode::CONFLICT
1735 );
1736 let invalid = Arc::new(StubControl {
1737 admits: std::sync::Mutex::new(Vec::new()),
1738 respond: StubJoin::Invalid,
1739 });
1740 assert_eq!(
1741 cluster_join(
1742 Extension(auth.clone()),
1743 Extension(MeshControlHandle(Some(invalid))),
1744 Json(req("aa01")),
1745 )
1746 .await
1747 .status(),
1748 StatusCode::FORBIDDEN
1749 );
1750 let revoked = Arc::new(StubControl {
1752 admits: std::sync::Mutex::new(Vec::new()),
1753 respond: StubJoin::Revoked,
1754 });
1755 assert_eq!(
1756 cluster_join(
1757 Extension(auth.clone()),
1758 Extension(MeshControlHandle(Some(revoked))),
1759 Json(req("aa01")),
1760 )
1761 .await
1762 .status(),
1763 StatusCode::FORBIDDEN
1764 );
1765
1766 let ok = Arc::new(StubControl {
1768 admits: std::sync::Mutex::new(Vec::new()),
1769 respond: StubJoin::Admit,
1770 });
1771 assert_eq!(
1772 cluster_join(
1773 Extension(auth.clone()),
1774 Extension(MeshControlHandle(Some(ok))),
1775 Json(req("not-hex")),
1776 )
1777 .await
1778 .status(),
1779 StatusCode::BAD_REQUEST
1780 );
1781
1782 let none = cluster_join(
1784 Extension(auth),
1785 Extension(MeshControlHandle(None)),
1786 Json(req("aa01")),
1787 )
1788 .await;
1789 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1790 }
1791
1792 #[tokio::test]
1796 async fn bootstrap_mints_the_first_token_once() {
1797 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1798 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1799 let public = keys.public_key();
1800 let deploy = DeployStore::new(
1801 Arc::new(MemStorage::default()),
1802 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1803 );
1804 let secret = "s3cr3t-bootstrap-value";
1805 let gate = BootstrapGate::new(Some(secret));
1806 let issuer = Issuer(Some(keys.clone()));
1807 let bearer = |s: &str| {
1808 let mut h = HeaderMap::new();
1809 h.insert(
1810 AUTHORIZATION,
1811 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1812 );
1813 h
1814 };
1815 let req = || BootstrapRequest {
1816 roles: vec!["admin".to_string()],
1817 ttl_secs: None,
1818 };
1819
1820 let bad = bootstrap_token(
1822 State(deploy.clone()),
1823 Extension(issuer.clone()),
1824 Extension(gate.clone()),
1825 bearer("wrong"),
1826 Json(req()),
1827 )
1828 .await;
1829 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1830
1831 let ok = bootstrap_token(
1833 State(deploy.clone()),
1834 Extension(issuer.clone()),
1835 Extension(gate.clone()),
1836 bearer(secret),
1837 Json(req()),
1838 )
1839 .await;
1840 assert_eq!(ok.status(), StatusCode::CREATED);
1841 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1842 .await
1843 .unwrap();
1844 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1845 let token = json["token"].as_str().unwrap();
1846 let id = json["id"].as_str().unwrap();
1847 let verified = cose::verify(token, &public, now_unix()).unwrap();
1848 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1849 assert!(deploy
1850 .list_token_meta()
1851 .await
1852 .unwrap()
1853 .iter()
1854 .any(|m| m.revocation_id == id));
1855
1856 let reuse = bootstrap_token(
1858 State(deploy.clone()),
1859 Extension(issuer.clone()),
1860 Extension(gate),
1861 bearer(secret),
1862 Json(req()),
1863 )
1864 .await;
1865 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1866
1867 let disabled = bootstrap_token(
1869 State(deploy),
1870 Extension(issuer),
1871 Extension(BootstrapGate(None)),
1872 bearer(secret),
1873 Json(req()),
1874 )
1875 .await;
1876 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1877 }
1878
1879 #[tokio::test]
1882 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1883 let control = Arc::new(StubControl {
1884 admits: std::sync::Mutex::new(Vec::new()),
1885 respond: StubJoin::Admit,
1886 });
1887 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1888 assert_eq!(resp.status(), StatusCode::OK);
1889 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1890 .await
1891 .unwrap();
1892 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1893 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1894
1895 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1896 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1897 }
1898
1899 #[test]
1900 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1901 use boatramp_core::security::SecurityProfile;
1902 let strict = SecurityProfile::MultiTenant.preset();
1903 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
1907 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1908 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1909
1910 assert!(gateway_addr_allowed(public, &strict));
1912 assert!(!gateway_addr_allowed(private, &strict));
1913 assert!(!gateway_addr_allowed(loopback, &strict));
1914 assert!(!gateway_addr_allowed(metadata, &strict));
1915
1916 assert!(gateway_addr_allowed(public, &loose));
1919 assert!(gateway_addr_allowed(private, &loose));
1920 assert!(gateway_addr_allowed(loopback, &loose));
1921 assert!(!gateway_addr_allowed(metadata, &loose));
1922 }
1923
1924 #[test]
1925 fn resolve_env_merges_static_and_host_secrets() {
1926 use boatramp_core::config::HandlersSiteConfig;
1927
1928 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
1930
1931 let deploy_env = std::collections::BTreeMap::from([
1932 ("GREETING".to_string(), "hi".to_string()),
1933 ("OVERRIDE_ME".to_string(), "static".to_string()),
1934 ]);
1935 let site_handlers = HandlersSiteConfig {
1936 enabled: true,
1937 secrets: std::collections::BTreeMap::from([
1938 (
1940 "SECRET_TOKEN".to_string(),
1941 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1942 ),
1943 (
1944 "OVERRIDE_ME".to_string(),
1945 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1946 ),
1947 (
1948 "MISSING".to_string(),
1949 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
1950 ),
1951 ]),
1952 ..Default::default()
1953 };
1954 let env = resolve_env("blog", &deploy_env, &site_handlers);
1955
1956 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
1960 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
1961 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
1962 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
1963
1964 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
1965 }
1966
1967 fn req() -> Request {
1968 Request::builder()
1969 .uri("/")
1970 .header(header::HOST, "example.com")
1971 .body(Body::empty())
1972 .unwrap()
1973 }
1974
1975 #[test]
1976 fn forwarded_headers_set_standard_triple() {
1977 let mut request = req();
1978 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1979 let h = request.headers();
1980 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
1981 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
1982 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
1983 }
1984
1985 #[test]
1986 fn forwarded_for_overwrites_spoofed_value() {
1987 let mut request = Request::builder()
1990 .uri("/")
1991 .header(header::HOST, "example.com")
1992 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
1993 .body(Body::empty())
1994 .unwrap();
1995 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1996 let values: Vec<_> = request
1997 .headers()
1998 .get_all("x-forwarded-for")
1999 .iter()
2000 .collect();
2001 assert_eq!(values.len(), 1);
2002 assert_eq!(values[0], "203.0.113.7");
2003 }
2004
2005 #[test]
2006 fn forwarded_proto_preserves_upstream_tls() {
2007 let mut request = Request::builder()
2009 .uri("/")
2010 .header(header::HOST, "example.com")
2011 .header("x-forwarded-proto", "https")
2012 .body(Body::empty())
2013 .unwrap();
2014 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2015 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2016 }
2017
2018 #[test]
2019 fn forwarded_host_absent_when_no_host_header() {
2020 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2021 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2022 assert!(request.headers().get("x-forwarded-host").is_none());
2023 assert_eq!(
2024 request.headers().get("x-forwarded-for").unwrap(),
2025 "203.0.113.7"
2026 );
2027 }
2028
2029 use boatramp_core::kv::{KvStore, MemoryKv};
2032 use boatramp_core::messaging::{LogMessaging, Messaging};
2033 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2034
2035 const EVENT_CONSUMER: &[u8] =
2036 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2037
2038 #[derive(Default)]
2039 struct MemStorage {
2040 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2041 }
2042
2043 #[async_trait::async_trait]
2044 impl boatramp_core::Storage for MemStorage {
2045 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2046 let bytes = self
2047 .objects
2048 .lock()
2049 .unwrap()
2050 .get(key)
2051 .cloned()
2052 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2053 let body: ByteStream =
2054 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2055 Ok(GetObject {
2056 meta: ObjectMeta {
2057 key: key.to_string(),
2058 ..Default::default()
2059 },
2060 body,
2061 })
2062 }
2063 async fn get_range(
2064 &self,
2065 key: &str,
2066 _: u64,
2067 _: Option<u64>,
2068 ) -> Result<GetObject, StorageError> {
2069 self.get(key).await
2070 }
2071 async fn put(
2072 &self,
2073 key: &str,
2074 mut body: ByteStream,
2075 _: PutMeta,
2076 ) -> Result<ObjectMeta, StorageError> {
2077 use futures::StreamExt;
2078 let mut buf = Vec::new();
2079 while let Some(chunk) = body.next().await {
2080 buf.extend_from_slice(&chunk?);
2081 }
2082 self.objects.lock().unwrap().insert(key.to_string(), buf);
2083 Ok(ObjectMeta {
2084 key: key.to_string(),
2085 ..Default::default()
2086 })
2087 }
2088 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2089 self.objects
2090 .lock()
2091 .unwrap()
2092 .get(key)
2093 .map(|_| ObjectMeta {
2094 key: key.to_string(),
2095 ..Default::default()
2096 })
2097 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2098 }
2099 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2100 self.objects.lock().unwrap().remove(key);
2101 Ok(())
2102 }
2103 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2104 Ok(Vec::new())
2105 }
2106 }
2107
2108 fn observed_state(
2110 workload: &str,
2111 healthy: bool,
2112 phase: boatramp_core::compute::ReplicaPhase,
2113 ) -> boatramp_core::compute::ObservedInstance {
2114 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2115 boatramp_core::compute::ObservedInstance {
2116 handle: InstanceHandle {
2117 workload: workload.into(),
2118 replica: 0,
2119 backend_ref: "ref-0".into(),
2120 },
2121 node: 1,
2122 backend: "vmm".into(),
2123 endpoint: Endpoint {
2124 scheme: Scheme::Http,
2125 host: "10.0.0.2".into(),
2126 port: 80,
2127 },
2128 region: None,
2129 healthy,
2130 phase,
2131 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2132 workload: workload.into(),
2133 replica: 0,
2134 data_ref: "snap-0".into(),
2135 }),
2136 }
2137 }
2138
2139 #[tokio::test]
2140 async fn has_parked_replica_detects_a_zeroed_replica() {
2141 use boatramp_core::compute::ReplicaPhase;
2142 let storage = Arc::new(MemStorage::default());
2143 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2144 let deploy = DeployStore::new(storage, kv);
2145
2146 assert!(!has_parked_replica(&deploy, "w").await);
2148 deploy
2150 .set_replica_state(
2151 ProjectRef::DEFAULT,
2152 &observed_state("w", true, ReplicaPhase::Running),
2153 )
2154 .await
2155 .unwrap();
2156 assert!(!has_parked_replica(&deploy, "w").await);
2157 deploy
2159 .set_replica_state(
2160 ProjectRef::DEFAULT,
2161 &observed_state("w", false, ReplicaPhase::Zero),
2162 )
2163 .await
2164 .unwrap();
2165 assert!(has_parked_replica(&deploy, "w").await);
2166 }
2167
2168 #[tokio::test]
2169 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2170 use boatramp_core::compute::ReplicaPhase;
2171 let storage = Arc::new(MemStorage::default());
2172 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2173 let deploy = DeployStore::new(storage, kv);
2174
2175 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2177 assert!(empty.is_empty());
2178
2179 deploy
2181 .set_replica_state(
2182 ProjectRef::DEFAULT,
2183 &observed_state("w", true, ReplicaPhase::Running),
2184 )
2185 .await
2186 .unwrap();
2187 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2188 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2189 }
2190
2191 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2195 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2196 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2197 let storage = Arc::new(MemStorage::default());
2198 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2199 let mq = LogMessaging::new(storage, kv.clone());
2200 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2201 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2202 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2203 let topic = "blog/orders/created";
2204
2205 for _ in 0..3 {
2207 mq.publish(topic, b"ok").await.unwrap();
2208 }
2209 loop {
2210 let acked = dispatch_consumer_batch(
2211 &engine,
2212 &mq,
2213 &metrics::Metrics::default(),
2214 "blog",
2215 topic,
2216 "blog/",
2217 "",
2218 boatramp_core::messaging::StartPosition::Latest,
2219 &hash,
2220 EVENT_CONSUMER,
2221 &bindings,
2222 Limits::default(),
2223 Duration::from_secs(30),
2224 5,
2225 10,
2226 )
2227 .await;
2228 if acked == 0 {
2229 break;
2230 }
2231 }
2232 assert_eq!(
2233 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2234 Some(b"3".to_vec())
2235 );
2236
2237 mq.publish(topic, b"fail").await.unwrap();
2240 for _ in 0..5 {
2241 dispatch_consumer_batch(
2242 &engine,
2243 &mq,
2244 &metrics::Metrics::default(),
2245 "blog",
2246 topic,
2247 "blog/",
2248 "",
2249 boatramp_core::messaging::StartPosition::Latest,
2250 &hash,
2251 EVENT_CONSUMER,
2252 &bindings,
2253 Limits::default(),
2254 Duration::ZERO,
2255 2,
2256 10,
2257 )
2258 .await;
2259 }
2260 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2261 assert_eq!(
2263 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2264 Some(b"3".to_vec())
2265 );
2266 }
2267
2268 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2273 async fn consumer_groups_fan_out_through_the_dispatcher() {
2274 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2275 let storage = Arc::new(MemStorage::default());
2276 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2277 let mq = LogMessaging::new(storage, kv.clone());
2278 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2279 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2280 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2281 let topic = "blog/orders/created";
2282 let start = boatramp_core::messaging::StartPosition::Latest;
2283
2284 for g in ["billing", "audit"] {
2287 let n = dispatch_consumer_batch(
2288 &engine,
2289 &mq,
2290 &metrics::Metrics::default(),
2291 "blog",
2292 topic,
2293 "blog/",
2294 g,
2295 start,
2296 &hash,
2297 EVENT_CONSUMER,
2298 &bindings,
2299 Limits::default(),
2300 Duration::from_secs(30),
2301 5,
2302 10,
2303 )
2304 .await;
2305 assert_eq!(n, 0, "no events yet for group {g}");
2306 }
2307 mq.publish(topic, b"ok").await.unwrap();
2308
2309 for g in ["billing", "audit"] {
2311 let n = dispatch_consumer_batch(
2312 &engine,
2313 &mq,
2314 &metrics::Metrics::default(),
2315 "blog",
2316 topic,
2317 "blog/",
2318 g,
2319 start,
2320 &hash,
2321 EVENT_CONSUMER,
2322 &bindings,
2323 Limits::default(),
2324 Duration::from_secs(30),
2325 5,
2326 10,
2327 )
2328 .await;
2329 assert_eq!(n, 1, "group {g} should receive the message");
2330 }
2331 assert_eq!(
2333 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2334 Some(b"2".to_vec())
2335 );
2336 }
2337
2338 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2342 async fn scheduler_runs_current_consumers_not_previews() {
2343 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2344 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2345 use boatramp_handlers::{HandlerEngine, Limits};
2346 use futures::StreamExt;
2347
2348 let storage = Arc::new(MemStorage::default());
2349 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2350 let deploy = DeployStore::new(storage.clone(), kv.clone());
2351 let messaging: Arc<dyn Messaging> =
2352 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2353
2354 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2356 let stream: ByteStream =
2357 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2358 .boxed();
2359 deploy.put_blob(&hash, stream).await.unwrap();
2360 let mut files = std::collections::BTreeMap::new();
2361 files.insert(
2362 "consumer.wasm".to_string(),
2363 FileEntry {
2364 hash: hash.clone(),
2365 size: EVENT_CONSUMER.len() as u64,
2366 content_type: None,
2367 variants: std::collections::BTreeMap::new(),
2368 },
2369 );
2370 let manifest = Manifest {
2371 files,
2372 config: DeployConfig {
2373 consumers: vec![ConsumerConfig {
2374 topic: "orders/created".into(),
2375 component: "consumer.wasm".into(),
2376 imports: vec!["wasi:keyvalue".into()],
2377 group: String::new(),
2378 start: Default::default(),
2379 }],
2380 ..Default::default()
2381 },
2382 ..Default::default()
2383 };
2384 let id = deploy.put_manifest(&manifest).await.unwrap();
2385 deploy
2386 .activate(ProjectRef::DEFAULT, "blog", &id)
2387 .await
2388 .unwrap();
2389 deploy
2390 .set_site_config(
2391 ProjectRef::DEFAULT,
2392 "blog",
2393 &SiteConfig {
2394 handlers: Some(HandlersSiteConfig {
2395 enabled: true,
2396 allow_imports: vec!["wasi:keyvalue".into()],
2397 ..Default::default()
2398 }),
2399 ..Default::default()
2400 },
2401 )
2402 .await
2403 .unwrap();
2404
2405 messaging
2407 .publish("blog/orders/created", b"live")
2408 .await
2409 .unwrap();
2410 messaging
2411 .publish("blog/_preview/abc/orders/created", b"preview")
2412 .await
2413 .unwrap();
2414
2415 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2416 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2417 let inner = rt.inner.clone().unwrap();
2418 let mut cache = std::collections::HashMap::new();
2419 let mut crons = std::collections::HashMap::new();
2420 let mut sweep = std::collections::HashMap::new();
2421 let now = CronNow {
2422 minute: 0,
2423 hour: 0,
2424 dom: 1,
2425 month: 1,
2426 dow: 0,
2427 minute_stamp: 0,
2428 };
2429 for _ in 0..3 {
2430 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
2431 .await
2432 .unwrap();
2433 }
2434
2435 assert_eq!(
2437 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2438 Some(b"1".to_vec())
2439 );
2440 assert_eq!(
2443 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2444 .await
2445 .unwrap(),
2446 None
2447 );
2448 }
2449
2450 const KV_COUNTER: &[u8] =
2455 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2456
2457 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2462 async fn function_invoker_runs_target_buffers_and_meters() {
2463 use boatramp_core::deploy::DeployStore;
2464 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2465 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2466 use futures::StreamExt;
2467
2468 const HTTP_200: &[u8] =
2471 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2472
2473 let storage = Arc::new(MemStorage::default());
2474 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2475 let deploy = DeployStore::new(storage.clone(), kv.clone());
2476
2477 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2478 let stream: ByteStream =
2479 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2480 deploy.put_blob(&hash, stream).await.unwrap();
2481 let function = Function {
2482 name: "target".into(),
2483 owner: Owner::Project("default".into()),
2484 versions: vec![FunctionVersion {
2485 id: "v1".into(),
2486 component: hash.clone(),
2487 created: 0,
2488 lifecycle: Lifecycle::Independent,
2489 }],
2490 active: "v1".into(),
2491 aliases: Default::default(),
2492 config: Default::default(),
2493 };
2494 deploy
2495 .put_function(ProjectRef::DEFAULT, &function)
2496 .await
2497 .unwrap();
2498
2499 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2500 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2501 rt.set_invoker(deploy.clone());
2502 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2503
2504 let request = || InvokeRequest {
2505 method: "GET".into(),
2506 path: "/".into(),
2507 headers: vec![],
2508 body: vec![],
2509 };
2510
2511 let response = invoker.invoke("target", request(), 1).await.unwrap();
2513 assert_eq!(response.status, 200);
2514
2515 let metering = deploy
2517 .get_metering(ProjectRef::DEFAULT, "target")
2518 .await
2519 .unwrap()
2520 .unwrap();
2521 assert_eq!(metering.invocations, 1);
2522
2523 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2525 assert!(matches!(err, InvokeError::NotFound));
2526 }
2527
2528 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2534 async fn federation_runner_enforces_the_safelist_before_planning() {
2535 use boatramp_core::deploy::DeployStore;
2536 use boatramp_core::project::ProjectRef;
2537 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2538
2539 let storage = Arc::new(MemStorage::default());
2540 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2541 let deploy = DeployStore::new(storage.clone(), kv.clone());
2542 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2543 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2544 rt.set_invoker(deploy.clone());
2545 let runner = rt
2546 .inner
2547 .as_ref()
2548 .unwrap()
2549 .federation_runner
2550 .get()
2551 .unwrap()
2552 .scoped(ProjectRef::new("default"));
2553
2554 let req = |query: &str| GraphqlRequest {
2555 query: Some(query.to_string()),
2556 persisted_hash: None,
2557 variables: "{}".to_string(),
2558 operation_name: None,
2559 authorization: None,
2560 };
2561
2562 assert!(matches!(
2564 runner.run(req("{ me { id } }"), 1).await,
2565 Err(SupergraphRunError::NotSafelisted)
2566 ));
2567
2568 let query = "{ me { id } }";
2571 let hash = crate::graphql_apq::sha256_hex(query);
2572 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2573 .await
2574 .unwrap();
2575 assert!(matches!(
2576 runner.run(req(query), 1).await,
2577 Err(SupergraphRunError::PlanFailed(_))
2578 ));
2579
2580 let persisted = GraphqlRequest {
2582 query: None,
2583 persisted_hash: Some("deadbeef".to_string()),
2584 variables: "{}".to_string(),
2585 operation_name: None,
2586 authorization: None,
2587 };
2588 assert!(matches!(
2589 runner.run(persisted, 1).await,
2590 Err(SupergraphRunError::NotSafelisted)
2591 ));
2592 }
2593
2594 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2600 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2601 use crate::scheduler::{run_scheduler_tick, CronNow};
2602 use boatramp_core::deploy::DeployStore;
2603 use boatramp_core::function::{
2604 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2605 };
2606 use boatramp_handlers::{HandlerEngine, Limits};
2607 use futures::StreamExt;
2608
2609 const HTTP_200: &[u8] =
2610 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2611
2612 let storage = Arc::new(MemStorage::default());
2613 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2614 let deploy = DeployStore::new(storage.clone(), kv.clone());
2615
2616 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2617 let stream: ByteStream =
2618 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2619 deploy.put_blob(&hash, stream).await.unwrap();
2620
2621 let acme = ProjectRef::new("acme");
2623 let function = Function {
2624 name: "worker".into(),
2625 owner: Owner::Project("acme".into()),
2626 versions: vec![FunctionVersion {
2627 id: "v1".into(),
2628 component: hash.clone(),
2629 created: 0,
2630 lifecycle: Lifecycle::Independent,
2631 }],
2632 active: "v1".into(),
2633 aliases: Default::default(),
2634 config: Default::default(),
2635 };
2636 deploy.put_function(acme, &function).await.unwrap();
2637 let inv = Invocation {
2638 id: "inv1".into(),
2639 function: "worker".into(),
2640 version: "v1".into(),
2641 mode: InvokeMode::Async,
2642 status: InvocationStatus::Queued,
2643 idempotency_key: None,
2644 attempts: 0,
2645 lease_expires: None,
2646 request_b64: None,
2647 request_content_type: None,
2648 result: None,
2649 created: 0,
2650 updated: 0,
2651 };
2652 deploy.put_invocation(acme, &inv).await.unwrap();
2653
2654 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2655 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2656 let inner = rt.inner.as_ref().unwrap();
2657
2658 let mut wasm_cache = std::collections::HashMap::new();
2661 let mut cron_state = std::collections::HashMap::new();
2662 let mut sweep = std::collections::HashMap::new();
2663 let now = CronNow {
2664 minute: 0,
2665 hour: 0,
2666 dom: 1,
2667 month: 1,
2668 dow: 0,
2669 minute_stamp: 0,
2670 };
2671 run_scheduler_tick(
2672 inner,
2673 &deploy,
2674 &mut wasm_cache,
2675 &mut cron_state,
2676 &mut sweep,
2677 now,
2678 )
2679 .await
2680 .unwrap();
2681
2682 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
2685 assert_eq!(settled.status, InvocationStatus::Succeeded);
2687 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2689 assert_eq!(metering.invocations, 1);
2690 assert!(deploy
2692 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2693 .await
2694 .unwrap()
2695 .is_none());
2696 assert!(deploy
2697 .get_metering(ProjectRef::DEFAULT, "worker")
2698 .await
2699 .unwrap()
2700 .is_none());
2701 }
2702
2703 #[cfg(feature = "handlers")]
2707 async fn poll_invocation_settled(
2708 deploy: &boatramp_core::deploy::DeployStore,
2709 project: ProjectRef<'_>,
2710 function: &str,
2711 id: &str,
2712 ) -> boatramp_core::function::Invocation {
2713 use boatramp_core::function::InvocationStatus;
2714 for _ in 0..200 {
2715 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
2716 if matches!(
2717 inv.status,
2718 InvocationStatus::Succeeded | InvocationStatus::Failed
2719 ) {
2720 return inv;
2721 }
2722 }
2723 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2724 }
2725 panic!("invocation {function}/{id} never settled");
2726 }
2727
2728 #[cfg(feature = "handlers")]
2733 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2734 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
2735 use crate::scheduler::{run_scheduler_tick, CronNow};
2736 use boatramp_core::deploy::DeployStore;
2737 use boatramp_core::function::{
2738 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2739 };
2740 use boatramp_handlers::{HandlerEngine, Limits};
2741 use futures::StreamExt;
2742
2743 const HTTP_200: &[u8] =
2744 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2745
2746 let storage = Arc::new(MemStorage::default());
2747 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2748 let deploy = DeployStore::new(storage.clone(), kv.clone());
2749 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2750 let stream: ByteStream =
2751 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2752 deploy.put_blob(&hash, stream).await.unwrap();
2753
2754 let function = Function {
2755 name: "worker".into(),
2756 owner: Owner::Project("default".into()),
2757 versions: vec![FunctionVersion {
2758 id: "v1".into(),
2759 component: hash.clone(),
2760 created: 0,
2761 lifecycle: Lifecycle::Independent,
2762 }],
2763 active: "v1".into(),
2764 aliases: Default::default(),
2765 config: Default::default(),
2766 };
2767 deploy
2768 .put_function(ProjectRef::DEFAULT, &function)
2769 .await
2770 .unwrap();
2771
2772 let base = Invocation {
2775 id: String::new(),
2776 function: "worker".into(),
2777 version: "v1".into(),
2778 mode: InvokeMode::Async,
2779 status: InvocationStatus::Running,
2780 idempotency_key: None,
2781 attempts: 1,
2782 lease_expires: None,
2783 request_b64: None,
2784 request_content_type: None,
2785 result: None,
2786 created: 0,
2787 updated: 0,
2788 };
2789 let orphan = Invocation {
2790 id: "orphan".into(),
2791 lease_expires: Some(1),
2792 ..base.clone()
2793 };
2794 deploy
2795 .put_invocation(ProjectRef::DEFAULT, &orphan)
2796 .await
2797 .unwrap();
2798 let live = Invocation {
2799 id: "live".into(),
2800 lease_expires: Some(u64::MAX),
2801 ..base.clone()
2802 };
2803 deploy
2804 .put_invocation(ProjectRef::DEFAULT, &live)
2805 .await
2806 .unwrap();
2807
2808 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2809 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2810 let inner = rt.inner.as_ref().unwrap();
2811
2812 let now = CronNow {
2813 minute: 0,
2814 hour: 0,
2815 dom: 1,
2816 month: 1,
2817 dow: 0,
2818 minute_stamp: 0,
2819 };
2820 let mut wasm_cache = std::collections::HashMap::new();
2821 let mut cron_state = std::collections::HashMap::new();
2822 let mut sweep = std::collections::HashMap::new();
2823 run_scheduler_tick(
2824 inner,
2825 &deploy,
2826 &mut wasm_cache,
2827 &mut cron_state,
2828 &mut sweep,
2829 now,
2830 )
2831 .await
2832 .unwrap();
2833
2834 let settled =
2836 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
2837 assert_eq!(settled.status, InvocationStatus::Succeeded);
2838 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
2839 assert_eq!(
2840 settled.lease_expires, None,
2841 "a settled invocation drops its lease"
2842 );
2843 let live_after = deploy
2845 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
2846 .await
2847 .unwrap()
2848 .unwrap();
2849 assert_eq!(live_after.status, InvocationStatus::Running);
2850 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
2851 assert_eq!(live_after.lease_expires, Some(u64::MAX));
2852 }
2853
2854 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2869 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2870 use boatramp_core::deploy::DeployStore;
2871 use boatramp_core::function::{
2872 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2873 };
2874 use boatramp_handlers::{HandlerEngine, Limits};
2875 use futures::StreamExt;
2876
2877 let storage = Arc::new(MemStorage::default());
2878 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2879 let deploy = DeployStore::new(storage.clone(), kv.clone());
2880
2881 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2884 let stream: ByteStream =
2885 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2886 deploy.put_blob(&hash, stream).await.unwrap();
2887
2888 let store = Function {
2893 name: "store".into(),
2894 owner: Owner::Project("default".into()),
2895 versions: vec![FunctionVersion {
2896 id: "v1".into(),
2897 component: hash.clone(),
2898 created: 0,
2899 lifecycle: Lifecycle::Independent,
2900 }],
2901 active: "v1".into(),
2902 aliases: Default::default(),
2903 config: FunctionConfig {
2904 imports: vec!["wasi:keyvalue".into()],
2905 ..Default::default()
2906 },
2907 };
2908 let acme = ProjectRef::new("acme");
2909 let globex = ProjectRef::new("globex");
2910
2911 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2912 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2913 let inner = rt.inner.as_ref().unwrap();
2914
2915 let request = || {
2916 axum::http::Request::builder()
2917 .method("GET")
2918 .uri("/")
2919 .body(axum::body::Body::empty())
2920 .unwrap()
2921 };
2922
2923 let component = store.resolve(&store.active).unwrap().to_owned();
2926 for project in [acme, globex, ProjectRef::DEFAULT] {
2927 let (response, _) = execute_function(
2928 inner,
2929 &deploy,
2930 project,
2931 &store,
2932 &component,
2933 request(),
2934 0,
2935 boatramp_handlers::Lane::Sync,
2936 )
2937 .await;
2938 assert!(response.status().is_success(), "invocation should succeed");
2939 }
2940
2941 assert_eq!(
2944 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
2945 Some(b"1".to_vec()),
2946 "acme's write must be tenant-qualified"
2947 );
2948 assert_eq!(
2949 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
2950 Some(b"1".to_vec()),
2951 "globex's write must be tenant-qualified"
2952 );
2953 assert_eq!(
2954 kv.get("hkv/fn/store/hits").await.unwrap(),
2955 Some(b"1".to_vec()),
2956 "the default project must keep the byte-identical pre-project key"
2957 );
2958 }
2961
2962 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2966 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
2967 use boatramp_core::config::{
2968 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2969 };
2970 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2971 use boatramp_handlers::{HandlerEngine, Limits};
2972 use futures::StreamExt;
2973 use std::sync::atomic::Ordering;
2974
2975 let storage = Arc::new(MemStorage::default());
2976 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2977 let deploy = DeployStore::new(storage.clone(), kv.clone());
2978
2979 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2980 let stream: ByteStream =
2981 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2982 deploy.put_blob(&hash, stream).await.unwrap();
2983 let mut files = std::collections::BTreeMap::new();
2984 files.insert(
2985 "counter.wasm".to_string(),
2986 FileEntry {
2987 hash: hash.clone(),
2988 size: KV_COUNTER.len() as u64,
2989 content_type: None,
2990 variants: std::collections::BTreeMap::new(),
2991 },
2992 );
2993 let manifest = Manifest {
2994 files,
2995 config: DeployConfig {
2996 handlers: vec![HandlerConfig {
2997 route: "/".into(),
2998 methods: Vec::new(),
2999 component: "counter.wasm".into(),
3000 imports: vec!["wasi:keyvalue".into()],
3001 limits: None,
3002 env: std::collections::BTreeMap::new(),
3003 invoke_targets: Vec::new(),
3004 }],
3005 crons: vec![CronConfig {
3006 schedule: "* * * * *".into(),
3007 route: "/".into(),
3008 overlap: Overlap::Skip,
3009 }],
3010 ..Default::default()
3011 },
3012 ..Default::default()
3013 };
3014 let id = deploy.put_manifest(&manifest).await.unwrap();
3015 deploy
3016 .activate(ProjectRef::DEFAULT, "blog", &id)
3017 .await
3018 .unwrap();
3019 deploy
3020 .set_site_config(
3021 ProjectRef::DEFAULT,
3022 "blog",
3023 &SiteConfig {
3024 handlers: Some(HandlersSiteConfig {
3025 enabled: true,
3026 allow_imports: vec!["wasi:keyvalue".into()],
3027 ..Default::default()
3028 }),
3029 ..Default::default()
3030 },
3031 )
3032 .await
3033 .unwrap();
3034
3035 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3036 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3037 let inner = rt.inner.clone().unwrap();
3038 let mut wasm = std::collections::HashMap::new();
3039 let mut crons = std::collections::HashMap::new();
3040 let mut sweep = std::collections::HashMap::new();
3041 let at = |stamp| CronNow {
3042 minute: 0,
3043 hour: 0,
3044 dom: 1,
3045 month: 1,
3046 dow: 0,
3047 minute_stamp: stamp,
3048 };
3049
3050 let (_, handles) =
3052 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3053 .await
3054 .unwrap();
3055 for h in handles {
3056 h.await.unwrap();
3057 }
3058 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3059
3060 let (_, handles) =
3062 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3063 .await
3064 .unwrap();
3065 assert!(handles.is_empty());
3066 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3067
3068 let (_, handles) =
3070 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3071 .await
3072 .unwrap();
3073 for h in handles {
3074 h.await.unwrap();
3075 }
3076 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3077
3078 crons
3082 .get("default|blog|cron|0")
3083 .unwrap()
3084 .running
3085 .store(true, Ordering::Release);
3086 let (_, handles) =
3087 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3088 .await
3089 .unwrap();
3090 assert!(handles.is_empty());
3091 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3092 }
3093
3094 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3098 async fn cron_leader_gate_suppresses_crons_off_leader() {
3099 use boatramp_core::config::{
3100 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3101 };
3102 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3103 use boatramp_handlers::{HandlerEngine, Limits};
3104 use futures::StreamExt;
3105
3106 let storage = Arc::new(MemStorage::default());
3107 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3108 let deploy = DeployStore::new(storage.clone(), kv.clone());
3109
3110 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3111 let stream: ByteStream =
3112 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3113 deploy.put_blob(&hash, stream).await.unwrap();
3114 let mut files = std::collections::BTreeMap::new();
3115 files.insert(
3116 "counter.wasm".to_string(),
3117 FileEntry {
3118 hash: hash.clone(),
3119 size: KV_COUNTER.len() as u64,
3120 content_type: None,
3121 variants: std::collections::BTreeMap::new(),
3122 },
3123 );
3124 let manifest = Manifest {
3125 files,
3126 config: DeployConfig {
3127 handlers: vec![HandlerConfig {
3128 route: "/".into(),
3129 methods: Vec::new(),
3130 component: "counter.wasm".into(),
3131 imports: vec!["wasi:keyvalue".into()],
3132 limits: None,
3133 env: std::collections::BTreeMap::new(),
3134 invoke_targets: Vec::new(),
3135 }],
3136 crons: vec![CronConfig {
3137 schedule: "* * * * *".into(),
3138 route: "/".into(),
3139 overlap: Overlap::Skip,
3140 }],
3141 ..Default::default()
3142 },
3143 ..Default::default()
3144 };
3145 let id = deploy.put_manifest(&manifest).await.unwrap();
3146 deploy
3147 .activate(ProjectRef::DEFAULT, "blog", &id)
3148 .await
3149 .unwrap();
3150 deploy
3151 .set_site_config(
3152 ProjectRef::DEFAULT,
3153 "blog",
3154 &SiteConfig {
3155 handlers: Some(HandlersSiteConfig {
3156 enabled: true,
3157 allow_imports: vec!["wasi:keyvalue".into()],
3158 ..Default::default()
3159 }),
3160 ..Default::default()
3161 },
3162 )
3163 .await
3164 .unwrap();
3165
3166 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3167 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3168 rt.set_cron_leader_gate(Arc::new(|| false));
3170 let inner = rt.inner.clone().unwrap();
3171 let mut wasm = std::collections::HashMap::new();
3172 let mut crons = std::collections::HashMap::new();
3173 let mut sweep = std::collections::HashMap::new();
3174 let now = CronNow {
3175 minute: 0,
3176 hour: 0,
3177 dom: 1,
3178 month: 1,
3179 dow: 0,
3180 minute_stamp: 100,
3181 };
3182
3183 let (_, handles) =
3184 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3185 .await
3186 .unwrap();
3187 assert!(handles.is_empty(), "a non-leader must not fire crons");
3189 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3190 }
3191
3192 #[tokio::test]
3199 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3200 use boatramp_core::config::HandlersSiteConfig;
3201 use boatramp_core::project::ProjectRef;
3202 use boatramp_handlers::{HandlerEngine, Limits};
3203
3204 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3205 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3206 let sql_dir =
3208 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3209 let _ = std::fs::remove_dir_all(&sql_dir);
3210 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3211 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3212
3213 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3214 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3215 let inner = rt.inner.as_ref().unwrap();
3216
3217 let site = HandlersSiteConfig {
3219 enabled: true,
3220 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3221 ..Default::default()
3222 };
3223 let env = std::collections::BTreeMap::new();
3224 let build = |imports: &[&str]| {
3225 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3226 let site = &site;
3227 let env = &env;
3228 async move {
3229 crate::handler_dispatch::build_bindings(
3230 inner,
3231 ProjectRef::new("default"),
3232 "shop",
3233 "shop",
3234 None,
3235 &imports,
3236 site,
3237 env,
3238 &[],
3239 0,
3240 None,
3241 )
3242 .await
3243 .sql_database_names()
3244 }
3245 };
3246
3247 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3250 assert_eq!(
3252 build(&["sql", "sql:*"]).await,
3253 vec!["", "privileged", "product"]
3254 );
3255 assert!(build(&["sql:secret"]).await.is_empty());
3257 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3259
3260 let _ = std::fs::remove_dir_all(&sql_dir);
3261 }
3262}