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};
137mod splice;
138#[cfg(feature = "handlers")]
140pub(crate) use proxy::is_upgrade_request;
141#[cfg(all(test, feature = "handlers"))]
142use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
143mod project_api;
144pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
145mod project_scope;
146pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
147mod ratelimit;
148mod routes;
149pub use routes::{router, router_with};
150#[cfg(feature = "mcp")]
151mod mcp_http;
152#[cfg(feature = "handlers")]
153mod scheduler;
154mod serve_pipeline;
155pub use serve_pipeline::http_redirect_router;
156#[cfg(all(test, feature = "handlers"))]
157use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
158pub(crate) use serve_pipeline::{
159 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
160 BootstrapAttestation,
161};
162pub mod signer;
165mod srvmetrics;
166#[cfg(all(feature = "handlers", test))]
167use scheduler::run_scheduler_tick;
168#[cfg(feature = "handlers")]
169pub(crate) use scheduler::{
170 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
171};
172#[cfg(feature = "handlers")]
173use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
174#[cfg(feature = "handlers")]
175mod function_runtime;
176#[cfg(feature = "handlers")]
177pub(crate) use function_runtime::{
178 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
179 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
180 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
181 put_trigger_handler, webhook_ingress,
182};
183#[cfg(feature = "handlers")]
184mod stream;
185#[cfg(feature = "handlers")]
186mod workflow;
187pub use auth::{require_auth, Auth};
188#[cfg(feature = "http3")]
189pub use http3::{
190 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
191 Http3Error,
192};
193pub use limits::{ServerLimits, UploadGuard};
194#[cfg(feature = "oidc")]
195pub use oidc::{OidcConfig, OidcError, OidcVerifier};
196use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
197#[cfg(feature = "handlers")]
198pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
199#[cfg(feature = "handlers")]
200pub(crate) use workflow::{
201 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
202 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
203};
204pub use srvmetrics::{server_metrics, ServerMetrics};
207
208#[derive(Clone, Default)]
213pub struct HandlerRuntime {
214 #[cfg(feature = "handlers")]
215 inner: Option<Arc<HandlerRuntimeInner>>,
216}
217
218#[cfg(feature = "handlers")]
219struct HandlerRuntimeInner {
220 engine: boatramp_handlers::HandlerEngine,
221 async_drain_gate: Arc<tokio::sync::Semaphore>,
227 kv: Arc<dyn boatramp_core::kv::KvStore>,
228 storage: Arc<dyn boatramp_core::Storage>,
229 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
233 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
236 site_semaphores:
239 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
240 stream_semaphores:
244 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
245 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
248 metrics: metrics::Metrics,
251 logs: Arc<logs::LogStore>,
253 #[cfg(feature = "handlers")]
257 graphql_cache: graphql_cache::GraphqlCache,
258 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
264 max_blob_bytes: std::sync::OnceLock<u64>,
268 max_component_bytes: std::sync::OnceLock<u64>,
273 function_meter_locks:
277 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
278 function_semaphores:
281 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
282 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
287 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
291 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
299 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
304}
305
306pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
309
310impl HandlerRuntime {
311 pub fn disabled() -> Self {
313 Self::default()
314 }
315
316 #[cfg(feature = "handlers")]
322 pub fn new(
323 engine: boatramp_handlers::HandlerEngine,
324 kv: Arc<dyn boatramp_core::kv::KvStore>,
325 storage: Arc<dyn boatramp_core::Storage>,
326 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
327 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
328 ) -> Self {
329 let async_drain_slots = engine.async_max_concurrency().max(1);
332 Self {
333 inner: Some(Arc::new(HandlerRuntimeInner {
334 engine,
335 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
336 kv,
337 storage,
338 sql,
339 messaging,
340 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
341 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
342 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
343 metrics: metrics::Metrics::default(),
344 logs: Arc::new(logs::LogStore::default()),
345 #[cfg(feature = "handlers")]
346 graphql_cache: graphql_cache::GraphqlCache::default(),
347 cron_leader_gate: std::sync::OnceLock::new(),
348 max_blob_bytes: std::sync::OnceLock::new(),
349 max_component_bytes: std::sync::OnceLock::new(),
350 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
351 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
352 watch_provider: std::sync::OnceLock::new(),
353 provision_tier: std::sync::OnceLock::new(),
354 invoker: std::sync::OnceLock::new(),
355 federation_runner: std::sync::OnceLock::new(),
356 })),
357 }
358 }
359
360 #[cfg(feature = "handlers")]
363 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
364 self.inner.as_ref().and_then(|inner| inner.sql.clone())
365 }
366
367 #[cfg(feature = "handlers")]
371 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
372 self.inner
373 .as_ref()
374 .and_then(|inner| inner.invoker.get().cloned())
375 }
376
377 #[cfg(feature = "handlers")]
381 pub(crate) async fn introspect_subgraph_sdl(
382 &self,
383 deploy: &DeployStore,
384 project: boatramp_core::project::ProjectRef<'_>,
385 function: &boatramp_core::function::Function,
386 component: &str,
387 ) -> Result<String, function_runtime::SubgraphSdlError> {
388 match self.inner.as_ref() {
389 Some(inner) => {
390 function_runtime::introspect_service_sdl(
391 inner, deploy, project, function, component,
392 )
393 .await
394 }
395 None => Err(function_runtime::SubgraphSdlError::Unavailable),
396 }
397 }
398
399 #[cfg(feature = "handlers")]
405 pub fn set_invoker(&self, deploy: DeployStore) {
406 if let Some(inner) = self.inner.as_ref() {
407 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
408 deploy,
409 Arc::downgrade(inner),
410 ));
411 let _ = inner.invoker.set(invoker);
412 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
415 inner,
416 )));
417 let _ = inner.federation_runner.set(runner);
418 }
419 }
420
421 #[cfg(feature = "handlers")]
425 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
426 self.inner.as_ref().and_then(|inner| inner.sql.clone())
427 }
428
429 #[cfg(feature = "handlers")]
433 pub fn set_watch_provider(
434 &self,
435 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
436 ) {
437 if let Some(inner) = self.inner.as_ref() {
438 let _ = inner.watch_provider.set(provider);
439 }
440 }
441
442 #[cfg(feature = "handlers")]
446 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
447 if let Some(inner) = self.inner.as_ref() {
448 let _ = inner.provision_tier.set(tier);
449 }
450 }
451
452 #[cfg(feature = "handlers")]
456 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
457 if let Some(inner) = self.inner.as_ref() {
458 let _ = inner.max_blob_bytes.set(max_bytes);
459 }
460 }
461
462 #[cfg(feature = "handlers")]
465 pub fn set_max_component_bytes(&self, max_bytes: u64) {
466 if let Some(inner) = self.inner.as_ref() {
467 let _ = inner.max_component_bytes.set(max_bytes);
468 }
469 }
470
471 #[cfg(feature = "handlers")]
476 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
477 if let Some(inner) = self.inner.as_ref() {
478 let _ = inner.cron_leader_gate.set(gate);
479 }
480 }
481
482 #[cfg(feature = "handlers")]
489 async fn precheck_activation(
490 &self,
491 deploy: &DeployStore,
492 manifest: &Manifest,
493 site_config: Option<&SiteConfig>,
494 ) -> Result<(), String> {
495 let Some(inner) = self.inner.as_ref() else {
496 return Ok(());
497 };
498 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
501 return Ok(());
502 }
503 let site_handlers = site_config
505 .and_then(|c| c.handlers.as_ref())
506 .filter(|h| h.enabled)
507 .ok_or_else(|| {
508 "deployment ships handlers/consumers but the site has them disabled".to_string()
509 })?;
510 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
511
512 let sync_ceiling = inner.engine.sync_timeout_ms();
518 let async_ceiling = inner.engine.async_timeout_ms();
519 if let Some(ms) = site_handlers.max_timeout_ms {
520 if u64::from(ms) > sync_ceiling {
521 tracing::warn!(
522 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
523 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
524 applies only to async calls (?mode=async / triggers), capped at \
525 async_max_timeout_ms={async_ceiling}"
526 );
527 }
528 }
529
530 for handler in &manifest.config.handlers {
532 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
533 if u64::from(ms) > sync_ceiling {
534 let route = &handler.route;
535 tracing::warn!(
536 "route {route:?} declares limits.timeout_ms={ms}, above \
537 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
538 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
539 (?mode=async / a queue trigger / a #[consumer]), capped at \
540 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
541 that isn't possible — move the work to the async lane"
542 );
543 }
544 }
545 precheck_component(
546 deploy,
547 manifest,
548 site_handlers,
549 inner,
550 max_component,
551 &handler.imports,
552 &handler.component,
553 &format!("handler {:?}", handler.route),
554 false,
555 )
556 .await?;
557 }
558 for consumer in &manifest.config.consumers {
559 precheck_component(
560 deploy,
561 manifest,
562 site_handlers,
563 inner,
564 max_component,
565 &consumer.imports,
566 &consumer.component,
567 &format!("consumer {:?}", consumer.topic),
568 true,
569 )
570 .await?;
571 }
572 Ok(())
573 }
574
575 #[cfg(not(feature = "handlers"))]
576 async fn precheck_activation(
577 &self,
578 _deploy: &DeployStore,
579 _manifest: &Manifest,
580 _site_config: Option<&SiteConfig>,
581 ) -> Result<(), String> {
582 Ok(())
583 }
584}
585
586#[derive(Default, Clone)]
592pub struct ServerOptions {
593 pub limits: ServerLimits,
595 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
598 pub default_site: Option<String>,
601 pub implicit_routing: bool,
607 pub protect_previews: bool,
612 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
616 pub issuer: Option<Arc<dyn Signer>>,
620 pub bootstrap_secret: Option<String>,
625 pub bootstrap_attestation: Option<String>,
631 pub mesh_control: Option<Arc<dyn MeshControl>>,
635 pub cors_allowed_origins: Vec<String>,
645 #[cfg(feature = "oidc")]
648 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
649 pub posture: boatramp_core::security::SecurityPosture,
653 pub served_over_tls: bool,
658 pub pop_origin: Option<String>,
665 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
669 #[cfg(feature = "console")]
673 pub console: Option<console::ConsoleMount>,
674}
675
676#[derive(Clone, Copy)]
680struct ServedOverTls(bool);
681
682#[derive(Clone, Copy, Default)]
687struct ImplicitRouting(bool);
688
689const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
701
702pub struct DaemonRuntime {
703 baseline: boatramp_core::daemon_config::ConfigBaseline,
704 state: std::sync::RwLock<DaemonState>,
705 reload: tokio::sync::Notify,
708}
709
710struct DaemonState {
711 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
712 generation: Option<String>,
713}
714
715pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
720 #[cfg(feature = "console")]
724 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
725 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
726 None => (false, None, None),
727 };
728 #[cfg(not(feature = "console"))]
729 let (console_enabled, console_host, console_path) = (false, None, None);
730 boatramp_core::daemon_config::ConfigBaseline {
731 default_site: options.default_site.clone(),
732 protect_previews: options.protect_previews,
733 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
734 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
735 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
736 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
737 compute_vcpus: 0,
738 compute_mem_mib: 0,
739 console_enabled,
740 console_host,
741 console_path,
742 max_upload_ceiling: options.posture.max_upload_bytes,
743 max_concurrent_uploads_ceiling: None,
744 posture: options.posture,
745 }
746}
747
748impl DaemonRuntime {
749 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
753 let effective =
754 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
755 Self {
756 baseline,
757 state: std::sync::RwLock::new(DaemonState {
758 effective,
759 generation: None,
760 }),
761 reload: tokio::sync::Notify::new(),
762 }
763 }
764
765 pub fn notify_reload(&self) {
769 self.reload.notify_one();
770 }
771
772 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
774 self.state
775 .read()
776 .expect("daemon config lock")
777 .effective
778 .clone()
779 }
780
781 pub fn generation(&self) -> Option<String> {
784 self.state
785 .read()
786 .expect("daemon config lock")
787 .generation
788 .clone()
789 }
790
791 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
793 &self.baseline
794 }
795
796 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
799 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
800 let generation = deploy.daemon_config_generation().await?;
801 let effective = Arc::new(cfg.resolve(&self.baseline));
802 *self.state.write().expect("daemon config lock") = DaemonState {
803 effective,
804 generation,
805 };
806 Ok(())
807 }
808}
809
810#[derive(Clone, Copy, Default)]
813struct PreviewPolicy {
814 protect: bool,
815}
816
817#[derive(Clone, Default)]
822struct Issuer(Option<Arc<dyn Signer>>);
823
824#[derive(Clone, Default)]
829struct BootstrapGate(Option<Arc<BootstrapInner>>);
830
831struct BootstrapInner {
832 secret_hash: String,
835 lock: tokio::sync::Mutex<()>,
838}
839
840impl BootstrapGate {
841 fn new(secret: Option<&str>) -> Self {
842 Self(secret.filter(|s| !s.is_empty()).map(|s| {
843 Arc::new(BootstrapInner {
844 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
845 lock: tokio::sync::Mutex::new(()),
846 })
847 }))
848 }
849}
850
851#[async_trait::async_trait]
855pub trait MeshControl: Send + Sync {
856 async fn admit(
864 &self,
865 mesh_pubkey_hex: &str,
866 jti: &str,
867 possession_proof: &[u8],
868 proof_iat: u64,
869 now: u64,
870 advertise_addr: Option<&str>,
871 ) -> Result<JoinOutcome, String>;
872
873 async fn rotate_key(&self) -> Result<String, String>;
877
878 async fn revoke(&self, node: u64) -> Result<(), String>;
882
883 async fn members(&self) -> Result<Vec<MeshMember>, String>;
887
888 async fn promote(&self, node: u64) -> Result<(), String>;
891}
892
893pub enum JoinOutcome {
895 Admitted {
898 members: Vec<String>,
900 addrs: std::collections::BTreeMap<u64, String>,
902 },
903 TokenSpent,
905 ProofInvalid,
907 Revoked,
910}
911
912#[derive(Debug, Clone, Serialize)]
914pub struct MeshMember {
915 pub node: u64,
917 pub voter: bool,
919 pub caught_up: bool,
921 pub leader: bool,
923 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub addr: Option<String>,
928}
929
930#[derive(Clone, Default)]
933struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
934
935#[cfg(feature = "oidc")]
937#[derive(Clone, Default)]
938struct OidcState(Option<Arc<oidc::OidcVerifier>>);
939
940#[cfg(feature = "oidc")]
943const EXCHANGE_TTL_SECS: u64 = 3600;
944
945use boatramp_core::time::now_unix;
946
947#[derive(Clone)]
949struct CorsState(Arc<Vec<String>>);
950
951const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
953const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
956const CORS_MAX_AGE: &str = "600";
958
959fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
963 allowed.iter().any(|a| a == "*" || a == origin)
964}
965
966async fn cors(
973 State(allowed): State<CorsState>,
974 request: Request,
975 next: axum::middleware::Next,
976) -> Response {
977 let origin = request
978 .headers()
979 .get(header::ORIGIN)
980 .and_then(|v| v.to_str().ok())
981 .filter(|o| cors_origin_allowed(&allowed.0, o))
982 .map(str::to_string);
983 let is_preflight = request.method() == Method::OPTIONS
985 && request
986 .headers()
987 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
988 if is_preflight {
989 let allow_headers = request
991 .headers()
992 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
993 .and_then(|v| v.to_str().ok())
994 .map(str::to_string)
995 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
996 let mut response = Response::new(Body::empty());
997 *response.status_mut() = StatusCode::NO_CONTENT;
998 if let Some(origin) = origin {
999 let headers = response.headers_mut();
1000 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1001 set_header(headers, header::VARY, "Origin");
1002 set_header(
1003 headers,
1004 header::ACCESS_CONTROL_ALLOW_METHODS,
1005 CORS_ALLOW_METHODS,
1006 );
1007 set_header(
1008 headers,
1009 header::ACCESS_CONTROL_ALLOW_HEADERS,
1010 &allow_headers,
1011 );
1012 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1013 }
1014 return response;
1015 }
1016 let mut response = next.run(request).await;
1017 if let Some(origin) = origin {
1018 let headers = response.headers_mut();
1019 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1020 if let Ok(value) = HeaderValue::from_str("Origin") {
1023 headers.append(header::VARY, value);
1024 }
1025 }
1026 response
1027}
1028
1029const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1034
1035#[derive(Debug, thiserror::Error)]
1037pub enum ServeError {
1038 #[error("server I/O: {0}")]
1040 Io(#[from] std::io::Error),
1041}
1042
1043pub async fn serve(
1046 addr: SocketAddr,
1047 deploy: DeployStore,
1048 auth: Auth,
1049 handlers: HandlerRuntime,
1050) -> Result<(), ServeError> {
1051 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1052}
1053
1054pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1064 if let Err(err) = stream.set_nodelay(true) {
1065 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1066 }
1067}
1068
1069pub async fn serve_with(
1071 addr: SocketAddr,
1072 deploy: DeployStore,
1073 auth: Auth,
1074 handlers: HandlerRuntime,
1075 options: ServerOptions,
1076) -> Result<(), ServeError> {
1077 let tcp = tokio::net::TcpListener::bind(addr).await?;
1078 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1079 let splice_ctx = splice::SpliceCtx {
1085 deploy: deploy.clone(),
1086 posture: options.posture,
1087 daemon: options.daemon_runtime.clone(),
1088 };
1089 #[cfg(feature = "handlers")]
1092 let scheduler = handlers.spawn_scheduler(deploy.clone());
1093 let gateway_prober = gateway::spawn_active_health_prober();
1097 let router = router_with(deploy, auth, handlers, options);
1100
1101 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1105 let server = splice::serve(tcp, splice_ctx, router, async move {
1109 shutdown_signal().await;
1110 let _ = signalled_tx.send(true);
1111 });
1112 let signalled = {
1113 let mut rx = signalled_rx;
1114 async move {
1115 let _ = rx.wait_for(|fired| *fired).await;
1116 }
1117 };
1118 let result = serve_with_drain_deadline(
1119 async move { server.await.map_err(ServeError::from) },
1120 signalled,
1121 DRAIN_DEADLINE,
1122 )
1123 .await;
1124 #[cfg(feature = "handlers")]
1126 if let Some(handle) = scheduler {
1127 handle.abort();
1128 }
1129 gateway_prober.abort();
1130 result
1131}
1132
1133async fn serve_with_drain_deadline<Srv, Sig>(
1138 server: Srv,
1139 signalled: Sig,
1140 deadline: Duration,
1141) -> Result<(), ServeError>
1142where
1143 Srv: Future<Output = Result<(), ServeError>>,
1144 Sig: Future<Output = ()>,
1145{
1146 tokio::pin!(server);
1147 let drain_cap = async move {
1148 signalled.await;
1149 tokio::time::sleep(deadline).await;
1150 };
1151 tokio::select! {
1152 result = &mut server => result,
1153 _ = drain_cap => {
1154 tracing::warn!(
1155 deadline_s = deadline.as_secs(),
1156 "drain deadline exceeded; forcing shutdown with requests still in flight"
1157 );
1158 Ok(())
1159 }
1160 }
1161}
1162
1163pub async fn shutdown_signal() {
1166 let ctrl_c = async {
1167 let _ = tokio::signal::ctrl_c().await;
1168 };
1169 #[cfg(unix)]
1170 let terminate = async {
1171 if let Ok(mut sig) =
1172 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1173 {
1174 sig.recv().await;
1175 }
1176 };
1177 #[cfg(not(unix))]
1178 let terminate = std::future::pending::<()>();
1179
1180 tokio::select! {
1181 _ = ctrl_c => {}
1182 _ = terminate => {}
1183 }
1184 tracing::info!("shutdown signal received; draining");
1185}
1186
1187async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1191 match daemon.generation() {
1192 Some(gen) => format!("ok gen={gen}"),
1193 None => "ok".to_string(),
1194 }
1195}
1196
1197async fn readyz(State(deploy): State<DeployStore>) -> Response {
1199 match deploy.ready().await {
1200 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1201 Err(err) => {
1202 tracing::warn!(error = %err, "readiness probe failed");
1203 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1204 }
1205 }
1206}
1207
1208#[derive(Clone)]
1213pub struct RequestId(pub String);
1214
1215fn request_id_for(headers: &HeaderMap) -> String {
1218 if let Some(id) = headers
1219 .get("x-request-id")
1220 .and_then(|v| v.to_str().ok())
1221 .map(str::trim)
1222 .filter(|s| !s.is_empty())
1223 {
1224 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1225 }
1226 use std::sync::atomic::{AtomicU64, Ordering};
1227 static SEQ: AtomicU64 = AtomicU64::new(0);
1228 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1229 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1230}
1231
1232struct AccessLog {
1236 request_id: String,
1237 method: Method,
1238 path: String,
1239 host: String,
1240 client: String,
1241 status: u16,
1242 encoding: String,
1244 start: std::time::Instant,
1245 bytes: std::sync::atomic::AtomicU64,
1246}
1247
1248impl Drop for AccessLog {
1249 fn drop(&mut self) {
1250 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1251 srvmetrics::server_metrics().record_request(self.status, bytes);
1254 tracing::info!(
1255 target: "boatramp::access",
1256 request_id = %self.request_id,
1257 method = %self.method,
1258 path = %self.path,
1259 host = %self.host,
1260 client = %self.client,
1261 status = self.status,
1262 bytes = bytes,
1263 encoding = %self.encoding,
1264 cache_result = srvmetrics::cache_result(self.status),
1265 elapsed_ms = self.start.elapsed().as_millis() as u64,
1266 "request"
1267 );
1268 }
1269}
1270
1271async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1275 let request_id = request_id_for(request.headers());
1278 request
1279 .extensions_mut()
1280 .insert(RequestId(request_id.clone()));
1281 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1286 return next.run(request).await;
1287 }
1288 let method = request.method().clone();
1289 let path = request.uri().path().to_string();
1290 let host = request
1291 .headers()
1292 .get(header::HOST)
1293 .and_then(|value| value.to_str().ok())
1294 .or_else(|| request.uri().host()) .unwrap_or("-")
1296 .to_string();
1297 let client = request
1298 .extensions()
1299 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1300 .map(|info| info.0.ip().to_string())
1301 .unwrap_or_else(|| "-".to_string());
1302
1303 let start = std::time::Instant::now();
1304 let response = next.run(request).await;
1305 let encoding = response
1306 .headers()
1307 .get(header::CONTENT_ENCODING)
1308 .and_then(|v| v.to_str().ok())
1309 .unwrap_or("identity")
1310 .to_string();
1311 let log = AccessLog {
1312 request_id,
1313 method,
1314 path,
1315 host,
1316 client,
1317 status: response.status().as_u16(),
1318 encoding,
1319 start,
1320 bytes: std::sync::atomic::AtomicU64::new(0),
1321 };
1322
1323 let (parts, body) = response.into_parts();
1327 let counted = body.into_data_stream().map(move |chunk| {
1328 if let Ok(bytes) = &chunk {
1329 log.bytes
1330 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1331 }
1332 chunk
1333 });
1334 Response::from_parts(parts, Body::from_stream(counted))
1335}
1336
1337fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1339 req_headers
1340 .get(header::IF_NONE_MATCH)
1341 .and_then(|value| value.to_str().ok())
1342 .is_some_and(|value| {
1343 value
1344 .split(',')
1345 .map(str::trim)
1346 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1347 })
1348}
1349
1350fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1351 if let Ok(value) = HeaderValue::from_str(value) {
1352 headers.insert(name, value);
1353 }
1354}
1355
1356fn not_found() -> Response {
1357 (StatusCode::NOT_FOUND, "not found\n").into_response()
1358}
1359
1360fn redirect(status: u16, location: &str) -> Response {
1361 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1362 match HeaderValue::from_str(location) {
1363 Ok(location) => {
1364 let mut headers = HeaderMap::new();
1365 headers.insert(header::LOCATION, location);
1366 (status, headers).into_response()
1367 }
1368 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1369 }
1370}
1371
1372fn deploy_error_response(err: DeployError) -> Response {
1374 let status = match &err {
1375 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1376 StatusCode::NOT_FOUND
1377 }
1378 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1379 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1380 DeployError::Conflict(_) => StatusCode::CONFLICT,
1382 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1384 _ => StatusCode::INTERNAL_SERVER_ERROR,
1385 };
1386 tracing::warn!(error = %err, "request failed");
1387 (status, format!("{err}\n")).into_response()
1388}
1389
1390fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1395 boatramp_core::project::validate_resource_name(kind, value)
1396 .err()
1397 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1398}
1399
1400#[cfg(test)]
1401mod drain_tests {
1402 use super::*;
1403
1404 #[tokio::test]
1405 async fn deadline_forces_shutdown_after_signal() {
1406 let server = std::future::pending::<Result<(), ServeError>>();
1409 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1411 assert!(result.is_ok());
1412 }
1413
1414 #[tokio::test]
1415 async fn server_finishing_first_wins() {
1416 let server = async { Ok(()) };
1419 let signalled = std::future::pending::<()>();
1420 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1421 assert!(result.is_ok());
1422 }
1423
1424 #[tokio::test]
1425 async fn deadline_does_not_trip_before_signal() {
1426 let server = async {
1430 tokio::time::sleep(Duration::from_millis(40)).await;
1431 Err(ServeError::Io(std::io::Error::other("server error")))
1432 };
1433 let signalled = std::future::pending::<()>();
1434 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1435 assert!(result.is_err());
1436 }
1437}
1438
1439#[cfg(all(test, feature = "handlers"))]
1440mod tests {
1441 use super::*;
1442 use boatramp_core::cose::{LocalSigner, TokenAlg};
1443 use boatramp_core::project::ProjectRef;
1444
1445 #[test]
1446 fn query_string_parses_and_url_decodes() {
1447 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1448 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1449 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")); }
1453
1454 #[test]
1455 fn cookie_header_parses_pairs() {
1456 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1457 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1458 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1459 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1460 }
1461
1462 #[test]
1463 fn apply_vary_merges_without_duplicates() {
1464 let base = (StatusCode::OK, "x").into_response();
1465 let r = apply_vary(base, &["accept-language".into()]);
1466 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1467 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1469 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1470 assert!(v.contains("accept-language") && v.contains("cookie"));
1471 assert_eq!(v.matches("accept-language").count(), 1);
1472 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1474 assert!(plain.headers().get(header::VARY).is_none());
1475 }
1476
1477 #[tokio::test]
1481 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1482 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1483 let public = keys.public_key();
1484
1485 let resp = create_join_token(
1487 Extension(Issuer(Some(keys.clone()))),
1488 Json(CreateJoinTokenRequest {
1489 ttl_secs: Some(600),
1490 }),
1491 )
1492 .await;
1493 assert_eq!(resp.status(), StatusCode::CREATED);
1494 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1495 .await
1496 .unwrap();
1497 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1498 let token = parsed["token"].as_str().unwrap();
1499 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1500 assert!(!jti.is_empty());
1501
1502 let no_issuer = create_join_token(
1504 Extension(Issuer(None)),
1505 Json(CreateJoinTokenRequest { ttl_secs: None }),
1506 )
1507 .await;
1508 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1509 }
1510
1511 #[tokio::test]
1516 async fn function_write_path_deploy_rollback_alias_remove() {
1517 use boatramp_core::function::Lifecycle;
1518 use boatramp_core::kv::MemoryKv;
1519 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1520
1521 struct FakeStorage {
1524 present: bool,
1525 }
1526 #[async_trait::async_trait]
1527 impl Storage for FakeStorage {
1528 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1529 Err(StorageError::NotFound(String::new()))
1530 }
1531 async fn get_range(
1532 &self,
1533 _: &str,
1534 _: u64,
1535 _: Option<u64>,
1536 ) -> Result<GetObject, StorageError> {
1537 Err(StorageError::NotFound(String::new()))
1538 }
1539 async fn put(
1540 &self,
1541 _: &str,
1542 _: ByteStream,
1543 _: PutMeta,
1544 ) -> Result<ObjectMeta, StorageError> {
1545 Err(StorageError::unsupported("fake"))
1546 }
1547 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1548 if self.present {
1549 Ok(ObjectMeta {
1550 key: key.to_string(),
1551 ..Default::default()
1552 })
1553 } else {
1554 Err(StorageError::NotFound(key.to_string()))
1555 }
1556 }
1557 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1558 Ok(())
1559 }
1560 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1561 Ok(Vec::new())
1562 }
1563 }
1564
1565 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1566 let status = resp.status();
1567 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1568 .await
1569 .unwrap();
1570 let value = if bytes.is_empty() {
1571 serde_json::Value::Null
1572 } else {
1573 serde_json::from_slice(&bytes).unwrap()
1574 };
1575 (status, value)
1576 }
1577
1578 let deploy = DeployStore::new(
1579 Arc::new(FakeStorage { present: true }),
1580 Arc::new(MemoryKv::new()),
1581 );
1582 let v1 = "a".repeat(64);
1583 let v2 = "b".repeat(64);
1584
1585 let (st, body) = body_json(
1587 deploy_function(
1588 State(deploy.clone()),
1589 axum::extract::Extension(crate::ProjectContext::default()),
1590 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1591 axum::extract::Query(DeployFunctionQuery::default()),
1592 Path("greeter".to_string()),
1593 Json(FunctionUpsert {
1594 component: v1.clone(),
1595 config: Default::default(),
1596 lifecycle: Lifecycle::Independent,
1597 }),
1598 )
1599 .await,
1600 )
1601 .await;
1602 assert_eq!(st, StatusCode::OK);
1603 assert_eq!(body["active"], v1);
1604
1605 let (_, body) = body_json(
1607 deploy_function(
1608 State(deploy.clone()),
1609 axum::extract::Extension(crate::ProjectContext::default()),
1610 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1611 axum::extract::Query(DeployFunctionQuery::default()),
1612 Path("greeter".to_string()),
1613 Json(FunctionUpsert {
1614 component: v2.clone(),
1615 config: Default::default(),
1616 lifecycle: Lifecycle::Independent,
1617 }),
1618 )
1619 .await,
1620 )
1621 .await;
1622 assert_eq!(body["active"], v2);
1623 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1624
1625 let (st, body) = body_json(
1627 rollback_function(
1628 State(deploy.clone()),
1629 axum::extract::Extension(crate::ProjectContext::default()),
1630 Path("greeter".to_string()),
1631 Json(RollbackBody { to: v1.clone() }),
1632 )
1633 .await,
1634 )
1635 .await;
1636 assert_eq!(st, StatusCode::OK);
1637 assert_eq!(body["active"], v1);
1638
1639 let resp = rollback_function(
1641 State(deploy.clone()),
1642 axum::extract::Extension(crate::ProjectContext::default()),
1643 Path("greeter".to_string()),
1644 Json(RollbackBody { to: "c".repeat(64) }),
1645 )
1646 .await;
1647 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1648
1649 let (st, body) = body_json(
1651 alias_function(
1652 State(deploy.clone()),
1653 axum::extract::Extension(crate::ProjectContext::default()),
1654 Path(("greeter".to_string(), "prod".to_string())),
1655 Json(AliasBody {
1656 version: v2.clone(),
1657 }),
1658 )
1659 .await,
1660 )
1661 .await;
1662 assert_eq!(st, StatusCode::OK);
1663 assert_eq!(body["aliases"]["prod"], v2);
1664
1665 let (st, _) = body_json(
1667 remove_function(
1668 State(deploy.clone()),
1669 axum::extract::Extension(crate::ProjectContext::default()),
1670 Path("greeter".to_string()),
1671 )
1672 .await,
1673 )
1674 .await;
1675 assert_eq!(st, StatusCode::NO_CONTENT);
1676 assert!(deploy
1677 .get_function(ProjectRef::DEFAULT, "greeter")
1678 .await
1679 .unwrap()
1680 .is_none());
1681
1682 let empty = DeployStore::new(
1684 Arc::new(FakeStorage { present: false }),
1685 Arc::new(MemoryKv::new()),
1686 );
1687 let resp = deploy_function(
1688 State(empty),
1689 axum::extract::Extension(crate::ProjectContext::default()),
1690 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1691 axum::extract::Query(DeployFunctionQuery::default()),
1692 Path("orphan".to_string()),
1693 Json(FunctionUpsert {
1694 component: v1.clone(),
1695 config: Default::default(),
1696 lifecycle: Lifecycle::default(),
1697 }),
1698 )
1699 .await;
1700 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1701 }
1702
1703 struct StubControl {
1707 admits: std::sync::Mutex<Vec<(String, String)>>,
1708 respond: StubJoin,
1709 }
1710 #[derive(Clone, Copy)]
1711 enum StubJoin {
1712 Admit,
1713 Spent,
1714 Invalid,
1715 Revoked,
1716 }
1717
1718 #[async_trait::async_trait]
1719 impl MeshControl for StubControl {
1720 async fn admit(
1721 &self,
1722 mesh_pubkey_hex: &str,
1723 jti: &str,
1724 _proof: &[u8],
1725 _proof_iat: u64,
1726 _now: u64,
1727 _advertise_addr: Option<&str>,
1728 ) -> Result<JoinOutcome, String> {
1729 self.admits
1730 .lock()
1731 .unwrap()
1732 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1733 Ok(match self.respond {
1734 StubJoin::Admit => JoinOutcome::Admitted {
1735 members: vec!["signed-member".to_string()],
1736 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1737 },
1738 StubJoin::Spent => JoinOutcome::TokenSpent,
1739 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1740 StubJoin::Revoked => JoinOutcome::Revoked,
1741 })
1742 }
1743 async fn rotate_key(&self) -> Result<String, String> {
1744 Ok("cafe".to_string())
1745 }
1746 async fn revoke(&self, _node: u64) -> Result<(), String> {
1747 Ok(())
1748 }
1749 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1750 Ok(Vec::new())
1751 }
1752 async fn promote(&self, _node: u64) -> Result<(), String> {
1753 Ok(())
1754 }
1755 }
1756
1757 #[tokio::test]
1761 async fn cluster_join_dispatches_and_maps_outcomes() {
1762 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1763 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1764 let auth = Auth::with_key(keys.public_key(), kv);
1765 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1766 let req = |proof: &str| JoinRequest {
1767 token: token.clone(),
1768 mesh_pubkey: "302a300506032b6570032100feed".into(),
1769 possession_proof: proof.to_string(),
1770 proof_iat: now_unix(),
1771 advertise_addr: Some("https://joiner:7000".into()),
1772 };
1773
1774 let admitter = Arc::new(StubControl {
1776 admits: std::sync::Mutex::new(Vec::new()),
1777 respond: StubJoin::Admit,
1778 });
1779 let resp = cluster_join(
1780 Extension(auth.clone()),
1781 Extension(MeshControlHandle(Some(admitter.clone()))),
1782 Json(req("aa01")),
1783 )
1784 .await;
1785 assert_eq!(resp.status(), StatusCode::OK);
1786 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1787
1788 let spent = Arc::new(StubControl {
1790 admits: std::sync::Mutex::new(Vec::new()),
1791 respond: StubJoin::Spent,
1792 });
1793 assert_eq!(
1794 cluster_join(
1795 Extension(auth.clone()),
1796 Extension(MeshControlHandle(Some(spent))),
1797 Json(req("aa01")),
1798 )
1799 .await
1800 .status(),
1801 StatusCode::CONFLICT
1802 );
1803 let invalid = Arc::new(StubControl {
1804 admits: std::sync::Mutex::new(Vec::new()),
1805 respond: StubJoin::Invalid,
1806 });
1807 assert_eq!(
1808 cluster_join(
1809 Extension(auth.clone()),
1810 Extension(MeshControlHandle(Some(invalid))),
1811 Json(req("aa01")),
1812 )
1813 .await
1814 .status(),
1815 StatusCode::FORBIDDEN
1816 );
1817 let revoked = Arc::new(StubControl {
1819 admits: std::sync::Mutex::new(Vec::new()),
1820 respond: StubJoin::Revoked,
1821 });
1822 assert_eq!(
1823 cluster_join(
1824 Extension(auth.clone()),
1825 Extension(MeshControlHandle(Some(revoked))),
1826 Json(req("aa01")),
1827 )
1828 .await
1829 .status(),
1830 StatusCode::FORBIDDEN
1831 );
1832
1833 let ok = Arc::new(StubControl {
1835 admits: std::sync::Mutex::new(Vec::new()),
1836 respond: StubJoin::Admit,
1837 });
1838 assert_eq!(
1839 cluster_join(
1840 Extension(auth.clone()),
1841 Extension(MeshControlHandle(Some(ok))),
1842 Json(req("not-hex")),
1843 )
1844 .await
1845 .status(),
1846 StatusCode::BAD_REQUEST
1847 );
1848
1849 let none = cluster_join(
1851 Extension(auth),
1852 Extension(MeshControlHandle(None)),
1853 Json(req("aa01")),
1854 )
1855 .await;
1856 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1857 }
1858
1859 #[tokio::test]
1863 async fn bootstrap_mints_the_first_token_once() {
1864 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1865 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1866 let public = keys.public_key();
1867 let deploy = DeployStore::new(
1868 Arc::new(MemStorage::default()),
1869 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1870 );
1871 let secret = "s3cr3t-bootstrap-value";
1872 let gate = BootstrapGate::new(Some(secret));
1873 let issuer = Issuer(Some(keys.clone()));
1874 let bearer = |s: &str| {
1875 let mut h = HeaderMap::new();
1876 h.insert(
1877 AUTHORIZATION,
1878 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1879 );
1880 h
1881 };
1882 let req = || BootstrapRequest {
1883 roles: vec!["admin".to_string()],
1884 ttl_secs: None,
1885 };
1886
1887 let bad = bootstrap_token(
1889 State(deploy.clone()),
1890 Extension(issuer.clone()),
1891 Extension(gate.clone()),
1892 bearer("wrong"),
1893 Json(req()),
1894 )
1895 .await;
1896 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1897
1898 let ok = bootstrap_token(
1900 State(deploy.clone()),
1901 Extension(issuer.clone()),
1902 Extension(gate.clone()),
1903 bearer(secret),
1904 Json(req()),
1905 )
1906 .await;
1907 assert_eq!(ok.status(), StatusCode::CREATED);
1908 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1909 .await
1910 .unwrap();
1911 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1912 let token = json["token"].as_str().unwrap();
1913 let id = json["id"].as_str().unwrap();
1914 let verified = cose::verify(token, &public, now_unix()).unwrap();
1915 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1916 assert!(deploy
1917 .list_token_meta()
1918 .await
1919 .unwrap()
1920 .iter()
1921 .any(|m| m.revocation_id == id));
1922
1923 let reuse = bootstrap_token(
1925 State(deploy.clone()),
1926 Extension(issuer.clone()),
1927 Extension(gate),
1928 bearer(secret),
1929 Json(req()),
1930 )
1931 .await;
1932 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1933
1934 let disabled = bootstrap_token(
1936 State(deploy),
1937 Extension(issuer),
1938 Extension(BootstrapGate(None)),
1939 bearer(secret),
1940 Json(req()),
1941 )
1942 .await;
1943 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1944 }
1945
1946 #[tokio::test]
1949 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1950 let control = Arc::new(StubControl {
1951 admits: std::sync::Mutex::new(Vec::new()),
1952 respond: StubJoin::Admit,
1953 });
1954 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1955 assert_eq!(resp.status(), StatusCode::OK);
1956 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1957 .await
1958 .unwrap();
1959 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1960 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1961
1962 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1963 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1964 }
1965
1966 #[test]
1967 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1968 use boatramp_core::security::SecurityProfile;
1969 let strict = SecurityProfile::MultiTenant.preset();
1970 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
1974 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1975 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1976
1977 assert!(gateway_addr_allowed(public, &strict));
1979 assert!(!gateway_addr_allowed(private, &strict));
1980 assert!(!gateway_addr_allowed(loopback, &strict));
1981 assert!(!gateway_addr_allowed(metadata, &strict));
1982
1983 assert!(gateway_addr_allowed(public, &loose));
1986 assert!(gateway_addr_allowed(private, &loose));
1987 assert!(gateway_addr_allowed(loopback, &loose));
1988 assert!(!gateway_addr_allowed(metadata, &loose));
1989 }
1990
1991 #[test]
1992 fn resolve_env_merges_static_and_host_secrets() {
1993 use boatramp_core::config::HandlersSiteConfig;
1994
1995 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
1997
1998 let deploy_env = std::collections::BTreeMap::from([
1999 ("GREETING".to_string(), "hi".to_string()),
2000 ("OVERRIDE_ME".to_string(), "static".to_string()),
2001 ]);
2002 let site_handlers = HandlersSiteConfig {
2003 enabled: true,
2004 secrets: std::collections::BTreeMap::from([
2005 (
2007 "SECRET_TOKEN".to_string(),
2008 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2009 ),
2010 (
2011 "OVERRIDE_ME".to_string(),
2012 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2013 ),
2014 (
2015 "MISSING".to_string(),
2016 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2017 ),
2018 ]),
2019 ..Default::default()
2020 };
2021 let env = resolve_env("blog", &deploy_env, &site_handlers);
2022
2023 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2027 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2028 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2029 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2030
2031 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2032 }
2033
2034 fn req() -> Request {
2035 Request::builder()
2036 .uri("/")
2037 .header(header::HOST, "example.com")
2038 .body(Body::empty())
2039 .unwrap()
2040 }
2041
2042 #[test]
2043 fn forwarded_headers_set_standard_triple() {
2044 let mut request = req();
2045 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2046 let h = request.headers();
2047 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2048 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2049 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2050 }
2051
2052 #[test]
2053 fn forwarded_for_overwrites_spoofed_value() {
2054 let mut request = Request::builder()
2057 .uri("/")
2058 .header(header::HOST, "example.com")
2059 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2060 .body(Body::empty())
2061 .unwrap();
2062 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2063 let values: Vec<_> = request
2064 .headers()
2065 .get_all("x-forwarded-for")
2066 .iter()
2067 .collect();
2068 assert_eq!(values.len(), 1);
2069 assert_eq!(values[0], "203.0.113.7");
2070 }
2071
2072 #[test]
2073 fn forwarded_proto_preserves_upstream_tls() {
2074 let mut request = Request::builder()
2076 .uri("/")
2077 .header(header::HOST, "example.com")
2078 .header("x-forwarded-proto", "https")
2079 .body(Body::empty())
2080 .unwrap();
2081 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2082 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2083 }
2084
2085 #[test]
2086 fn forwarded_host_absent_when_no_host_header() {
2087 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2088 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2089 assert!(request.headers().get("x-forwarded-host").is_none());
2090 assert_eq!(
2091 request.headers().get("x-forwarded-for").unwrap(),
2092 "203.0.113.7"
2093 );
2094 }
2095
2096 use boatramp_core::kv::{KvStore, MemoryKv};
2099 use boatramp_core::messaging::{LogMessaging, Messaging};
2100 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2101
2102 const EVENT_CONSUMER: &[u8] =
2103 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2104
2105 #[derive(Default)]
2106 struct MemStorage {
2107 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2108 }
2109
2110 #[async_trait::async_trait]
2111 impl boatramp_core::Storage for MemStorage {
2112 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2113 let bytes = self
2114 .objects
2115 .lock()
2116 .unwrap()
2117 .get(key)
2118 .cloned()
2119 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2120 let body: ByteStream =
2121 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2122 Ok(GetObject {
2123 meta: ObjectMeta {
2124 key: key.to_string(),
2125 ..Default::default()
2126 },
2127 body,
2128 })
2129 }
2130 async fn get_range(
2131 &self,
2132 key: &str,
2133 _: u64,
2134 _: Option<u64>,
2135 ) -> Result<GetObject, StorageError> {
2136 self.get(key).await
2137 }
2138 async fn put(
2139 &self,
2140 key: &str,
2141 mut body: ByteStream,
2142 _: PutMeta,
2143 ) -> Result<ObjectMeta, StorageError> {
2144 use futures::StreamExt;
2145 let mut buf = Vec::new();
2146 while let Some(chunk) = body.next().await {
2147 buf.extend_from_slice(&chunk?);
2148 }
2149 self.objects.lock().unwrap().insert(key.to_string(), buf);
2150 Ok(ObjectMeta {
2151 key: key.to_string(),
2152 ..Default::default()
2153 })
2154 }
2155 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2156 self.objects
2157 .lock()
2158 .unwrap()
2159 .get(key)
2160 .map(|_| ObjectMeta {
2161 key: key.to_string(),
2162 ..Default::default()
2163 })
2164 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2165 }
2166 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2167 self.objects.lock().unwrap().remove(key);
2168 Ok(())
2169 }
2170 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2171 Ok(Vec::new())
2172 }
2173 }
2174
2175 fn observed_state(
2177 workload: &str,
2178 healthy: bool,
2179 phase: boatramp_core::compute::ReplicaPhase,
2180 ) -> boatramp_core::compute::ObservedInstance {
2181 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2182 boatramp_core::compute::ObservedInstance {
2183 handle: InstanceHandle {
2184 workload: workload.into(),
2185 replica: 0,
2186 backend_ref: "ref-0".into(),
2187 },
2188 node: 1,
2189 backend: "vmm".into(),
2190 endpoint: Endpoint {
2191 scheme: Scheme::Http,
2192 host: "10.0.0.2".into(),
2193 port: 80,
2194 },
2195 region: None,
2196 healthy,
2197 phase,
2198 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2199 workload: workload.into(),
2200 replica: 0,
2201 data_ref: "snap-0".into(),
2202 }),
2203 }
2204 }
2205
2206 #[tokio::test]
2207 async fn has_parked_replica_detects_a_zeroed_replica() {
2208 use boatramp_core::compute::ReplicaPhase;
2209 let storage = Arc::new(MemStorage::default());
2210 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2211 let deploy = DeployStore::new(storage, kv);
2212
2213 assert!(!has_parked_replica(&deploy, "w").await);
2215 deploy
2217 .set_replica_state(
2218 ProjectRef::DEFAULT,
2219 &observed_state("w", true, ReplicaPhase::Running),
2220 )
2221 .await
2222 .unwrap();
2223 assert!(!has_parked_replica(&deploy, "w").await);
2224 deploy
2226 .set_replica_state(
2227 ProjectRef::DEFAULT,
2228 &observed_state("w", false, ReplicaPhase::Zero),
2229 )
2230 .await
2231 .unwrap();
2232 assert!(has_parked_replica(&deploy, "w").await);
2233 }
2234
2235 #[tokio::test]
2236 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2237 use boatramp_core::compute::ReplicaPhase;
2238 let storage = Arc::new(MemStorage::default());
2239 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2240 let deploy = DeployStore::new(storage, kv);
2241
2242 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2244 assert!(empty.is_empty());
2245
2246 deploy
2248 .set_replica_state(
2249 ProjectRef::DEFAULT,
2250 &observed_state("w", true, ReplicaPhase::Running),
2251 )
2252 .await
2253 .unwrap();
2254 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2255 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2256 }
2257
2258 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2262 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2263 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2264 let storage = Arc::new(MemStorage::default());
2265 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2266 let mq = LogMessaging::new(storage, kv.clone());
2267 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2268 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2269 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2270 let topic = "blog/orders/created";
2271
2272 for _ in 0..3 {
2274 mq.publish(topic, b"ok").await.unwrap();
2275 }
2276 loop {
2277 let acked = dispatch_consumer_batch(
2278 &engine,
2279 &mq,
2280 &metrics::Metrics::default(),
2281 "blog",
2282 topic,
2283 "blog/",
2284 "",
2285 boatramp_core::messaging::StartPosition::Latest,
2286 &hash,
2287 EVENT_CONSUMER,
2288 &bindings,
2289 Limits::default(),
2290 Duration::from_secs(30),
2291 5,
2292 10,
2293 )
2294 .await;
2295 if acked == 0 {
2296 break;
2297 }
2298 }
2299 assert_eq!(
2300 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2301 Some(b"3".to_vec())
2302 );
2303
2304 mq.publish(topic, b"fail").await.unwrap();
2307 for _ in 0..5 {
2308 dispatch_consumer_batch(
2309 &engine,
2310 &mq,
2311 &metrics::Metrics::default(),
2312 "blog",
2313 topic,
2314 "blog/",
2315 "",
2316 boatramp_core::messaging::StartPosition::Latest,
2317 &hash,
2318 EVENT_CONSUMER,
2319 &bindings,
2320 Limits::default(),
2321 Duration::ZERO,
2322 2,
2323 10,
2324 )
2325 .await;
2326 }
2327 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2328 assert_eq!(
2330 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2331 Some(b"3".to_vec())
2332 );
2333 }
2334
2335 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2340 async fn consumer_groups_fan_out_through_the_dispatcher() {
2341 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2342 let storage = Arc::new(MemStorage::default());
2343 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2344 let mq = LogMessaging::new(storage, kv.clone());
2345 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2346 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2347 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2348 let topic = "blog/orders/created";
2349 let start = boatramp_core::messaging::StartPosition::Latest;
2350
2351 for g in ["billing", "audit"] {
2354 let n = dispatch_consumer_batch(
2355 &engine,
2356 &mq,
2357 &metrics::Metrics::default(),
2358 "blog",
2359 topic,
2360 "blog/",
2361 g,
2362 start,
2363 &hash,
2364 EVENT_CONSUMER,
2365 &bindings,
2366 Limits::default(),
2367 Duration::from_secs(30),
2368 5,
2369 10,
2370 )
2371 .await;
2372 assert_eq!(n, 0, "no events yet for group {g}");
2373 }
2374 mq.publish(topic, b"ok").await.unwrap();
2375
2376 for g in ["billing", "audit"] {
2378 let n = dispatch_consumer_batch(
2379 &engine,
2380 &mq,
2381 &metrics::Metrics::default(),
2382 "blog",
2383 topic,
2384 "blog/",
2385 g,
2386 start,
2387 &hash,
2388 EVENT_CONSUMER,
2389 &bindings,
2390 Limits::default(),
2391 Duration::from_secs(30),
2392 5,
2393 10,
2394 )
2395 .await;
2396 assert_eq!(n, 1, "group {g} should receive the message");
2397 }
2398 assert_eq!(
2400 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2401 Some(b"2".to_vec())
2402 );
2403 }
2404
2405 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2409 async fn scheduler_runs_current_consumers_not_previews() {
2410 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2411 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2412 use boatramp_handlers::{HandlerEngine, Limits};
2413 use futures::StreamExt;
2414
2415 let storage = Arc::new(MemStorage::default());
2416 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2417 let deploy = DeployStore::new(storage.clone(), kv.clone());
2418 let messaging: Arc<dyn Messaging> =
2419 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2420
2421 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2423 let stream: ByteStream =
2424 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2425 .boxed();
2426 deploy.put_blob(&hash, stream).await.unwrap();
2427 let mut files = std::collections::BTreeMap::new();
2428 files.insert(
2429 "consumer.wasm".to_string(),
2430 FileEntry {
2431 hash: hash.clone(),
2432 size: EVENT_CONSUMER.len() as u64,
2433 content_type: None,
2434 variants: std::collections::BTreeMap::new(),
2435 },
2436 );
2437 let manifest = Manifest {
2438 files,
2439 config: DeployConfig {
2440 consumers: vec![ConsumerConfig {
2441 topic: "orders/created".into(),
2442 component: "consumer.wasm".into(),
2443 imports: vec!["wasi:keyvalue".into()],
2444 group: String::new(),
2445 start: Default::default(),
2446 }],
2447 ..Default::default()
2448 },
2449 ..Default::default()
2450 };
2451 let id = deploy.put_manifest(&manifest).await.unwrap();
2452 deploy
2453 .activate(ProjectRef::DEFAULT, "blog", &id)
2454 .await
2455 .unwrap();
2456 deploy
2457 .set_site_config(
2458 ProjectRef::DEFAULT,
2459 "blog",
2460 &SiteConfig {
2461 handlers: Some(HandlersSiteConfig {
2462 enabled: true,
2463 allow_imports: vec!["wasi:keyvalue".into()],
2464 ..Default::default()
2465 }),
2466 ..Default::default()
2467 },
2468 )
2469 .await
2470 .unwrap();
2471
2472 messaging
2474 .publish("blog/orders/created", b"live")
2475 .await
2476 .unwrap();
2477 messaging
2478 .publish("blog/_preview/abc/orders/created", b"preview")
2479 .await
2480 .unwrap();
2481
2482 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2483 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2484 let inner = rt.inner.clone().unwrap();
2485 let mut cache = std::collections::HashMap::new();
2486 let mut crons = std::collections::HashMap::new();
2487 let mut sweep = std::collections::HashMap::new();
2488 let now = CronNow {
2489 minute: 0,
2490 hour: 0,
2491 dom: 1,
2492 month: 1,
2493 dow: 0,
2494 minute_stamp: 0,
2495 };
2496 for _ in 0..3 {
2497 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
2498 .await
2499 .unwrap();
2500 }
2501
2502 assert_eq!(
2504 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2505 Some(b"1".to_vec())
2506 );
2507 assert_eq!(
2510 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2511 .await
2512 .unwrap(),
2513 None
2514 );
2515 }
2516
2517 const KV_COUNTER: &[u8] =
2522 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2523
2524 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2529 async fn function_invoker_runs_target_buffers_and_meters() {
2530 use boatramp_core::deploy::DeployStore;
2531 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2532 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2533 use futures::StreamExt;
2534
2535 const HTTP_200: &[u8] =
2538 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2539
2540 let storage = Arc::new(MemStorage::default());
2541 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2542 let deploy = DeployStore::new(storage.clone(), kv.clone());
2543
2544 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2545 let stream: ByteStream =
2546 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2547 deploy.put_blob(&hash, stream).await.unwrap();
2548 let function = Function {
2549 name: "target".into(),
2550 owner: Owner::Project("default".into()),
2551 versions: vec![FunctionVersion {
2552 id: "v1".into(),
2553 component: hash.clone(),
2554 created: 0,
2555 lifecycle: Lifecycle::Independent,
2556 }],
2557 active: "v1".into(),
2558 aliases: Default::default(),
2559 config: Default::default(),
2560 };
2561 deploy
2562 .put_function(ProjectRef::DEFAULT, &function)
2563 .await
2564 .unwrap();
2565
2566 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2567 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2568 rt.set_invoker(deploy.clone());
2569 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2570
2571 let request = || InvokeRequest {
2572 method: "GET".into(),
2573 path: "/".into(),
2574 headers: vec![],
2575 body: vec![],
2576 };
2577
2578 let response = invoker.invoke("target", request(), 1).await.unwrap();
2580 assert_eq!(response.status, 200);
2581
2582 let metering = deploy
2584 .get_metering(ProjectRef::DEFAULT, "target")
2585 .await
2586 .unwrap()
2587 .unwrap();
2588 assert_eq!(metering.invocations, 1);
2589
2590 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2592 assert!(matches!(err, InvokeError::NotFound));
2593 }
2594
2595 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2601 async fn federation_runner_enforces_the_safelist_before_planning() {
2602 use boatramp_core::deploy::DeployStore;
2603 use boatramp_core::project::ProjectRef;
2604 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2605
2606 let storage = Arc::new(MemStorage::default());
2607 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2608 let deploy = DeployStore::new(storage.clone(), kv.clone());
2609 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2610 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2611 rt.set_invoker(deploy.clone());
2612 let runner = rt
2613 .inner
2614 .as_ref()
2615 .unwrap()
2616 .federation_runner
2617 .get()
2618 .unwrap()
2619 .scoped(ProjectRef::new("default"));
2620
2621 let req = |query: &str| GraphqlRequest {
2622 query: Some(query.to_string()),
2623 persisted_hash: None,
2624 variables: "{}".to_string(),
2625 operation_name: None,
2626 authorization: None,
2627 };
2628
2629 assert!(matches!(
2631 runner.run(req("{ me { id } }"), 1).await,
2632 Err(SupergraphRunError::NotSafelisted)
2633 ));
2634
2635 let query = "{ me { id } }";
2638 let hash = crate::graphql_apq::sha256_hex(query);
2639 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2640 .await
2641 .unwrap();
2642 assert!(matches!(
2643 runner.run(req(query), 1).await,
2644 Err(SupergraphRunError::PlanFailed(_))
2645 ));
2646
2647 let persisted = GraphqlRequest {
2649 query: None,
2650 persisted_hash: Some("deadbeef".to_string()),
2651 variables: "{}".to_string(),
2652 operation_name: None,
2653 authorization: None,
2654 };
2655 assert!(matches!(
2656 runner.run(persisted, 1).await,
2657 Err(SupergraphRunError::NotSafelisted)
2658 ));
2659 }
2660
2661 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2667 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2668 use crate::scheduler::{run_scheduler_tick, CronNow};
2669 use boatramp_core::deploy::DeployStore;
2670 use boatramp_core::function::{
2671 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2672 };
2673 use boatramp_handlers::{HandlerEngine, Limits};
2674 use futures::StreamExt;
2675
2676 const HTTP_200: &[u8] =
2677 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2678
2679 let storage = Arc::new(MemStorage::default());
2680 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2681 let deploy = DeployStore::new(storage.clone(), kv.clone());
2682
2683 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2684 let stream: ByteStream =
2685 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2686 deploy.put_blob(&hash, stream).await.unwrap();
2687
2688 let acme = ProjectRef::new("acme");
2690 let function = Function {
2691 name: "worker".into(),
2692 owner: Owner::Project("acme".into()),
2693 versions: vec![FunctionVersion {
2694 id: "v1".into(),
2695 component: hash.clone(),
2696 created: 0,
2697 lifecycle: Lifecycle::Independent,
2698 }],
2699 active: "v1".into(),
2700 aliases: Default::default(),
2701 config: Default::default(),
2702 };
2703 deploy.put_function(acme, &function).await.unwrap();
2704 let inv = Invocation {
2705 id: "inv1".into(),
2706 function: "worker".into(),
2707 version: "v1".into(),
2708 mode: InvokeMode::Async,
2709 status: InvocationStatus::Queued,
2710 idempotency_key: None,
2711 attempts: 0,
2712 lease_expires: None,
2713 request_b64: None,
2714 request_content_type: None,
2715 result: None,
2716 created: 0,
2717 updated: 0,
2718 };
2719 deploy.put_invocation(acme, &inv).await.unwrap();
2720
2721 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2722 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2723 let inner = rt.inner.as_ref().unwrap();
2724
2725 let mut wasm_cache = std::collections::HashMap::new();
2728 let mut cron_state = std::collections::HashMap::new();
2729 let mut sweep = std::collections::HashMap::new();
2730 let now = CronNow {
2731 minute: 0,
2732 hour: 0,
2733 dom: 1,
2734 month: 1,
2735 dow: 0,
2736 minute_stamp: 0,
2737 };
2738 run_scheduler_tick(
2739 inner,
2740 &deploy,
2741 &mut wasm_cache,
2742 &mut cron_state,
2743 &mut sweep,
2744 now,
2745 )
2746 .await
2747 .unwrap();
2748
2749 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
2752 assert_eq!(settled.status, InvocationStatus::Succeeded);
2754 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2756 assert_eq!(metering.invocations, 1);
2757 assert!(deploy
2759 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2760 .await
2761 .unwrap()
2762 .is_none());
2763 assert!(deploy
2764 .get_metering(ProjectRef::DEFAULT, "worker")
2765 .await
2766 .unwrap()
2767 .is_none());
2768 }
2769
2770 #[cfg(feature = "handlers")]
2774 async fn poll_invocation_settled(
2775 deploy: &boatramp_core::deploy::DeployStore,
2776 project: ProjectRef<'_>,
2777 function: &str,
2778 id: &str,
2779 ) -> boatramp_core::function::Invocation {
2780 use boatramp_core::function::InvocationStatus;
2781 for _ in 0..200 {
2782 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
2783 if matches!(
2784 inv.status,
2785 InvocationStatus::Succeeded | InvocationStatus::Failed
2786 ) {
2787 return inv;
2788 }
2789 }
2790 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2791 }
2792 panic!("invocation {function}/{id} never settled");
2793 }
2794
2795 #[cfg(feature = "handlers")]
2800 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2801 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
2802 use crate::scheduler::{run_scheduler_tick, CronNow};
2803 use boatramp_core::deploy::DeployStore;
2804 use boatramp_core::function::{
2805 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2806 };
2807 use boatramp_handlers::{HandlerEngine, Limits};
2808 use futures::StreamExt;
2809
2810 const HTTP_200: &[u8] =
2811 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2812
2813 let storage = Arc::new(MemStorage::default());
2814 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2815 let deploy = DeployStore::new(storage.clone(), kv.clone());
2816 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2817 let stream: ByteStream =
2818 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2819 deploy.put_blob(&hash, stream).await.unwrap();
2820
2821 let function = Function {
2822 name: "worker".into(),
2823 owner: Owner::Project("default".into()),
2824 versions: vec![FunctionVersion {
2825 id: "v1".into(),
2826 component: hash.clone(),
2827 created: 0,
2828 lifecycle: Lifecycle::Independent,
2829 }],
2830 active: "v1".into(),
2831 aliases: Default::default(),
2832 config: Default::default(),
2833 };
2834 deploy
2835 .put_function(ProjectRef::DEFAULT, &function)
2836 .await
2837 .unwrap();
2838
2839 let base = Invocation {
2842 id: String::new(),
2843 function: "worker".into(),
2844 version: "v1".into(),
2845 mode: InvokeMode::Async,
2846 status: InvocationStatus::Running,
2847 idempotency_key: None,
2848 attempts: 1,
2849 lease_expires: None,
2850 request_b64: None,
2851 request_content_type: None,
2852 result: None,
2853 created: 0,
2854 updated: 0,
2855 };
2856 let orphan = Invocation {
2857 id: "orphan".into(),
2858 lease_expires: Some(1),
2859 ..base.clone()
2860 };
2861 deploy
2862 .put_invocation(ProjectRef::DEFAULT, &orphan)
2863 .await
2864 .unwrap();
2865 let live = Invocation {
2866 id: "live".into(),
2867 lease_expires: Some(u64::MAX),
2868 ..base.clone()
2869 };
2870 deploy
2871 .put_invocation(ProjectRef::DEFAULT, &live)
2872 .await
2873 .unwrap();
2874
2875 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2876 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2877 let inner = rt.inner.as_ref().unwrap();
2878
2879 let now = CronNow {
2880 minute: 0,
2881 hour: 0,
2882 dom: 1,
2883 month: 1,
2884 dow: 0,
2885 minute_stamp: 0,
2886 };
2887 let mut wasm_cache = std::collections::HashMap::new();
2888 let mut cron_state = std::collections::HashMap::new();
2889 let mut sweep = std::collections::HashMap::new();
2890 run_scheduler_tick(
2891 inner,
2892 &deploy,
2893 &mut wasm_cache,
2894 &mut cron_state,
2895 &mut sweep,
2896 now,
2897 )
2898 .await
2899 .unwrap();
2900
2901 let settled =
2903 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
2904 assert_eq!(settled.status, InvocationStatus::Succeeded);
2905 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
2906 assert_eq!(
2907 settled.lease_expires, None,
2908 "a settled invocation drops its lease"
2909 );
2910 let live_after = deploy
2912 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
2913 .await
2914 .unwrap()
2915 .unwrap();
2916 assert_eq!(live_after.status, InvocationStatus::Running);
2917 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
2918 assert_eq!(live_after.lease_expires, Some(u64::MAX));
2919 }
2920
2921 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2936 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2937 use boatramp_core::deploy::DeployStore;
2938 use boatramp_core::function::{
2939 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2940 };
2941 use boatramp_handlers::{HandlerEngine, Limits};
2942 use futures::StreamExt;
2943
2944 let storage = Arc::new(MemStorage::default());
2945 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2946 let deploy = DeployStore::new(storage.clone(), kv.clone());
2947
2948 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2951 let stream: ByteStream =
2952 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2953 deploy.put_blob(&hash, stream).await.unwrap();
2954
2955 let store = Function {
2960 name: "store".into(),
2961 owner: Owner::Project("default".into()),
2962 versions: vec![FunctionVersion {
2963 id: "v1".into(),
2964 component: hash.clone(),
2965 created: 0,
2966 lifecycle: Lifecycle::Independent,
2967 }],
2968 active: "v1".into(),
2969 aliases: Default::default(),
2970 config: FunctionConfig {
2971 imports: vec!["wasi:keyvalue".into()],
2972 ..Default::default()
2973 },
2974 };
2975 let acme = ProjectRef::new("acme");
2976 let globex = ProjectRef::new("globex");
2977
2978 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2979 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2980 let inner = rt.inner.as_ref().unwrap();
2981
2982 let request = || {
2983 axum::http::Request::builder()
2984 .method("GET")
2985 .uri("/")
2986 .body(axum::body::Body::empty())
2987 .unwrap()
2988 };
2989
2990 let component = store.resolve(&store.active).unwrap().to_owned();
2993 for project in [acme, globex, ProjectRef::DEFAULT] {
2994 let (response, _) = execute_function(
2995 inner,
2996 &deploy,
2997 project,
2998 &store,
2999 &component,
3000 request(),
3001 0,
3002 boatramp_handlers::Lane::Sync,
3003 )
3004 .await;
3005 assert!(response.status().is_success(), "invocation should succeed");
3006 }
3007
3008 assert_eq!(
3011 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3012 Some(b"1".to_vec()),
3013 "acme's write must be tenant-qualified"
3014 );
3015 assert_eq!(
3016 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3017 Some(b"1".to_vec()),
3018 "globex's write must be tenant-qualified"
3019 );
3020 assert_eq!(
3021 kv.get("hkv/fn/store/hits").await.unwrap(),
3022 Some(b"1".to_vec()),
3023 "the default project must keep the byte-identical pre-project key"
3024 );
3025 }
3028
3029 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3033 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3034 use boatramp_core::config::{
3035 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3036 };
3037 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3038 use boatramp_handlers::{HandlerEngine, Limits};
3039 use futures::StreamExt;
3040 use std::sync::atomic::Ordering;
3041
3042 let storage = Arc::new(MemStorage::default());
3043 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3044 let deploy = DeployStore::new(storage.clone(), kv.clone());
3045
3046 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3047 let stream: ByteStream =
3048 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3049 deploy.put_blob(&hash, stream).await.unwrap();
3050 let mut files = std::collections::BTreeMap::new();
3051 files.insert(
3052 "counter.wasm".to_string(),
3053 FileEntry {
3054 hash: hash.clone(),
3055 size: KV_COUNTER.len() as u64,
3056 content_type: None,
3057 variants: std::collections::BTreeMap::new(),
3058 },
3059 );
3060 let manifest = Manifest {
3061 files,
3062 config: DeployConfig {
3063 handlers: vec![HandlerConfig {
3064 route: "/".into(),
3065 methods: Vec::new(),
3066 component: "counter.wasm".into(),
3067 imports: vec!["wasi:keyvalue".into()],
3068 limits: None,
3069 env: std::collections::BTreeMap::new(),
3070 invoke_targets: Vec::new(),
3071 }],
3072 crons: vec![CronConfig {
3073 schedule: "* * * * *".into(),
3074 route: "/".into(),
3075 overlap: Overlap::Skip,
3076 }],
3077 ..Default::default()
3078 },
3079 ..Default::default()
3080 };
3081 let id = deploy.put_manifest(&manifest).await.unwrap();
3082 deploy
3083 .activate(ProjectRef::DEFAULT, "blog", &id)
3084 .await
3085 .unwrap();
3086 deploy
3087 .set_site_config(
3088 ProjectRef::DEFAULT,
3089 "blog",
3090 &SiteConfig {
3091 handlers: Some(HandlersSiteConfig {
3092 enabled: true,
3093 allow_imports: vec!["wasi:keyvalue".into()],
3094 ..Default::default()
3095 }),
3096 ..Default::default()
3097 },
3098 )
3099 .await
3100 .unwrap();
3101
3102 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3103 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3104 let inner = rt.inner.clone().unwrap();
3105 let mut wasm = std::collections::HashMap::new();
3106 let mut crons = std::collections::HashMap::new();
3107 let mut sweep = std::collections::HashMap::new();
3108 let at = |stamp| CronNow {
3109 minute: 0,
3110 hour: 0,
3111 dom: 1,
3112 month: 1,
3113 dow: 0,
3114 minute_stamp: stamp,
3115 };
3116
3117 let (_, handles) =
3119 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3120 .await
3121 .unwrap();
3122 for h in handles {
3123 h.await.unwrap();
3124 }
3125 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3126
3127 let (_, handles) =
3129 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3130 .await
3131 .unwrap();
3132 assert!(handles.is_empty());
3133 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3134
3135 let (_, handles) =
3137 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3138 .await
3139 .unwrap();
3140 for h in handles {
3141 h.await.unwrap();
3142 }
3143 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3144
3145 crons
3149 .get("default|blog|cron|0")
3150 .unwrap()
3151 .running
3152 .store(true, Ordering::Release);
3153 let (_, handles) =
3154 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3155 .await
3156 .unwrap();
3157 assert!(handles.is_empty());
3158 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3159 }
3160
3161 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3165 async fn cron_leader_gate_suppresses_crons_off_leader() {
3166 use boatramp_core::config::{
3167 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3168 };
3169 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3170 use boatramp_handlers::{HandlerEngine, Limits};
3171 use futures::StreamExt;
3172
3173 let storage = Arc::new(MemStorage::default());
3174 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3175 let deploy = DeployStore::new(storage.clone(), kv.clone());
3176
3177 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3178 let stream: ByteStream =
3179 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3180 deploy.put_blob(&hash, stream).await.unwrap();
3181 let mut files = std::collections::BTreeMap::new();
3182 files.insert(
3183 "counter.wasm".to_string(),
3184 FileEntry {
3185 hash: hash.clone(),
3186 size: KV_COUNTER.len() as u64,
3187 content_type: None,
3188 variants: std::collections::BTreeMap::new(),
3189 },
3190 );
3191 let manifest = Manifest {
3192 files,
3193 config: DeployConfig {
3194 handlers: vec![HandlerConfig {
3195 route: "/".into(),
3196 methods: Vec::new(),
3197 component: "counter.wasm".into(),
3198 imports: vec!["wasi:keyvalue".into()],
3199 limits: None,
3200 env: std::collections::BTreeMap::new(),
3201 invoke_targets: Vec::new(),
3202 }],
3203 crons: vec![CronConfig {
3204 schedule: "* * * * *".into(),
3205 route: "/".into(),
3206 overlap: Overlap::Skip,
3207 }],
3208 ..Default::default()
3209 },
3210 ..Default::default()
3211 };
3212 let id = deploy.put_manifest(&manifest).await.unwrap();
3213 deploy
3214 .activate(ProjectRef::DEFAULT, "blog", &id)
3215 .await
3216 .unwrap();
3217 deploy
3218 .set_site_config(
3219 ProjectRef::DEFAULT,
3220 "blog",
3221 &SiteConfig {
3222 handlers: Some(HandlersSiteConfig {
3223 enabled: true,
3224 allow_imports: vec!["wasi:keyvalue".into()],
3225 ..Default::default()
3226 }),
3227 ..Default::default()
3228 },
3229 )
3230 .await
3231 .unwrap();
3232
3233 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3234 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3235 rt.set_cron_leader_gate(Arc::new(|| false));
3237 let inner = rt.inner.clone().unwrap();
3238 let mut wasm = std::collections::HashMap::new();
3239 let mut crons = std::collections::HashMap::new();
3240 let mut sweep = std::collections::HashMap::new();
3241 let now = CronNow {
3242 minute: 0,
3243 hour: 0,
3244 dom: 1,
3245 month: 1,
3246 dow: 0,
3247 minute_stamp: 100,
3248 };
3249
3250 let (_, handles) =
3251 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3252 .await
3253 .unwrap();
3254 assert!(handles.is_empty(), "a non-leader must not fire crons");
3256 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3257 }
3258
3259 #[tokio::test]
3266 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3267 use boatramp_core::config::HandlersSiteConfig;
3268 use boatramp_core::project::ProjectRef;
3269 use boatramp_handlers::{HandlerEngine, Limits};
3270
3271 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3272 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3273 let sql_dir =
3275 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3276 let _ = std::fs::remove_dir_all(&sql_dir);
3277 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3278 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3279
3280 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3281 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3282 let inner = rt.inner.as_ref().unwrap();
3283
3284 let site = HandlersSiteConfig {
3286 enabled: true,
3287 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3288 ..Default::default()
3289 };
3290 let env = std::collections::BTreeMap::new();
3291 let build = |imports: &[&str]| {
3292 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3293 let site = &site;
3294 let env = &env;
3295 async move {
3296 crate::handler_dispatch::build_bindings(
3297 inner,
3298 ProjectRef::new("default"),
3299 "shop",
3300 "shop",
3301 None,
3302 &imports,
3303 site,
3304 env,
3305 &[],
3306 0,
3307 None,
3308 )
3309 .await
3310 .sql_database_names()
3311 }
3312 };
3313
3314 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3317 assert_eq!(
3319 build(&["sql", "sql:*"]).await,
3320 vec!["", "privileged", "product"]
3321 );
3322 assert!(build(&["sql:secret"]).await.is_empty());
3324 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3326
3327 let _ = std::fs::remove_dir_all(&sql_dir);
3328 }
3329}