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 let sync_ceiling = inner.engine.sync_timeout_ms();
517 let async_ceiling = inner.engine.async_timeout_ms();
518 if let Some(ms) = site_handlers.max_timeout_ms {
519 if u64::from(ms) > sync_ceiling {
520 tracing::warn!(
521 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
522 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
523 applies only to async calls (?mode=async / triggers), capped at \
524 async_max_timeout_ms={async_ceiling}"
525 );
526 }
527 }
528
529 for handler in &manifest.config.handlers {
531 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
532 if u64::from(ms) > sync_ceiling {
533 let route = &handler.route;
534 tracing::warn!(
535 "route {route:?} declares limits.timeout_ms={ms}, above \
536 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
537 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
538 (?mode=async / a queue trigger / a #[consumer]), capped at \
539 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
540 that isn't possible — move the work to the async lane"
541 );
542 }
543 }
544 precheck_component(
545 deploy,
546 manifest,
547 site_handlers,
548 inner,
549 max_component,
550 &handler.imports,
551 &handler.component,
552 &format!("handler {:?}", handler.route),
553 false,
554 )
555 .await?;
556 }
557 for consumer in &manifest.config.consumers {
558 precheck_component(
559 deploy,
560 manifest,
561 site_handlers,
562 inner,
563 max_component,
564 &consumer.imports,
565 &consumer.component,
566 &format!("consumer {:?}", consumer.topic),
567 true,
568 )
569 .await?;
570 }
571 Ok(())
572 }
573
574 #[cfg(not(feature = "handlers"))]
575 async fn precheck_activation(
576 &self,
577 _deploy: &DeployStore,
578 _manifest: &Manifest,
579 _site_config: Option<&SiteConfig>,
580 ) -> Result<(), String> {
581 Ok(())
582 }
583}
584
585#[derive(Default, Clone)]
591pub struct ServerOptions {
592 pub limits: ServerLimits,
594 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
597 pub default_site: Option<String>,
600 pub implicit_routing: bool,
606 pub protect_previews: bool,
611 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
615 pub issuer: Option<Arc<dyn Signer>>,
619 pub bootstrap_secret: Option<String>,
624 pub bootstrap_attestation: Option<String>,
630 pub mesh_control: Option<Arc<dyn MeshControl>>,
634 pub cors_allowed_origins: Vec<String>,
644 #[cfg(feature = "oidc")]
647 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
648 pub posture: boatramp_core::security::SecurityPosture,
652 pub served_over_tls: bool,
657 pub pop_origin: Option<String>,
664 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
668 #[cfg(feature = "console")]
672 pub console: Option<console::ConsoleMount>,
673}
674
675#[derive(Clone, Copy)]
679struct ServedOverTls(bool);
680
681#[derive(Clone, Copy, Default)]
686struct ImplicitRouting(bool);
687
688const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
700
701pub struct DaemonRuntime {
702 baseline: boatramp_core::daemon_config::ConfigBaseline,
703 state: std::sync::RwLock<DaemonState>,
704 reload: tokio::sync::Notify,
707}
708
709struct DaemonState {
710 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
711 generation: Option<String>,
712}
713
714pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
719 #[cfg(feature = "console")]
723 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
724 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
725 None => (false, None, None),
726 };
727 #[cfg(not(feature = "console"))]
728 let (console_enabled, console_host, console_path) = (false, None, None);
729 boatramp_core::daemon_config::ConfigBaseline {
730 default_site: options.default_site.clone(),
731 protect_previews: options.protect_previews,
732 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
733 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
734 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
735 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
736 compute_vcpus: 0,
737 compute_mem_mib: 0,
738 console_enabled,
739 console_host,
740 console_path,
741 max_upload_ceiling: options.posture.max_upload_bytes,
742 max_concurrent_uploads_ceiling: None,
743 posture: options.posture,
744 }
745}
746
747impl DaemonRuntime {
748 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
752 let effective =
753 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
754 Self {
755 baseline,
756 state: std::sync::RwLock::new(DaemonState {
757 effective,
758 generation: None,
759 }),
760 reload: tokio::sync::Notify::new(),
761 }
762 }
763
764 pub fn notify_reload(&self) {
768 self.reload.notify_one();
769 }
770
771 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
773 self.state
774 .read()
775 .expect("daemon config lock")
776 .effective
777 .clone()
778 }
779
780 pub fn generation(&self) -> Option<String> {
783 self.state
784 .read()
785 .expect("daemon config lock")
786 .generation
787 .clone()
788 }
789
790 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
792 &self.baseline
793 }
794
795 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
798 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
799 let generation = deploy.daemon_config_generation().await?;
800 let effective = Arc::new(cfg.resolve(&self.baseline));
801 *self.state.write().expect("daemon config lock") = DaemonState {
802 effective,
803 generation,
804 };
805 Ok(())
806 }
807}
808
809#[derive(Clone, Copy, Default)]
812struct PreviewPolicy {
813 protect: bool,
814}
815
816#[derive(Clone, Default)]
821struct Issuer(Option<Arc<dyn Signer>>);
822
823#[derive(Clone, Default)]
828struct BootstrapGate(Option<Arc<BootstrapInner>>);
829
830struct BootstrapInner {
831 secret_hash: String,
834 lock: tokio::sync::Mutex<()>,
837}
838
839impl BootstrapGate {
840 fn new(secret: Option<&str>) -> Self {
841 Self(secret.filter(|s| !s.is_empty()).map(|s| {
842 Arc::new(BootstrapInner {
843 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
844 lock: tokio::sync::Mutex::new(()),
845 })
846 }))
847 }
848}
849
850#[async_trait::async_trait]
854pub trait MeshControl: Send + Sync {
855 async fn admit(
863 &self,
864 mesh_pubkey_hex: &str,
865 jti: &str,
866 possession_proof: &[u8],
867 proof_iat: u64,
868 now: u64,
869 advertise_addr: Option<&str>,
870 ) -> Result<JoinOutcome, String>;
871
872 async fn rotate_key(&self) -> Result<String, String>;
876
877 async fn revoke(&self, node: u64) -> Result<(), String>;
881
882 async fn members(&self) -> Result<Vec<MeshMember>, String>;
886
887 async fn promote(&self, node: u64) -> Result<(), String>;
890}
891
892pub enum JoinOutcome {
894 Admitted {
897 members: Vec<String>,
899 addrs: std::collections::BTreeMap<u64, String>,
901 },
902 TokenSpent,
904 ProofInvalid,
906 Revoked,
909}
910
911#[derive(Debug, Clone, Serialize)]
913pub struct MeshMember {
914 pub node: u64,
916 pub voter: bool,
918 pub caught_up: bool,
920 pub leader: bool,
922 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub addr: Option<String>,
927}
928
929#[derive(Clone, Default)]
932struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
933
934#[cfg(feature = "oidc")]
936#[derive(Clone, Default)]
937struct OidcState(Option<Arc<oidc::OidcVerifier>>);
938
939#[cfg(feature = "oidc")]
942const EXCHANGE_TTL_SECS: u64 = 3600;
943
944use boatramp_core::time::now_unix;
945
946#[derive(Clone)]
948struct CorsState(Arc<Vec<String>>);
949
950const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
952const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
955const CORS_MAX_AGE: &str = "600";
957
958fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
962 allowed.iter().any(|a| a == "*" || a == origin)
963}
964
965async fn cors(
972 State(allowed): State<CorsState>,
973 request: Request,
974 next: axum::middleware::Next,
975) -> Response {
976 let origin = request
977 .headers()
978 .get(header::ORIGIN)
979 .and_then(|v| v.to_str().ok())
980 .filter(|o| cors_origin_allowed(&allowed.0, o))
981 .map(str::to_string);
982 let is_preflight = request.method() == Method::OPTIONS
984 && request
985 .headers()
986 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
987 if is_preflight {
988 let allow_headers = request
990 .headers()
991 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
992 .and_then(|v| v.to_str().ok())
993 .map(str::to_string)
994 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
995 let mut response = Response::new(Body::empty());
996 *response.status_mut() = StatusCode::NO_CONTENT;
997 if let Some(origin) = origin {
998 let headers = response.headers_mut();
999 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1000 set_header(headers, header::VARY, "Origin");
1001 set_header(
1002 headers,
1003 header::ACCESS_CONTROL_ALLOW_METHODS,
1004 CORS_ALLOW_METHODS,
1005 );
1006 set_header(
1007 headers,
1008 header::ACCESS_CONTROL_ALLOW_HEADERS,
1009 &allow_headers,
1010 );
1011 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1012 }
1013 return response;
1014 }
1015 let mut response = next.run(request).await;
1016 if let Some(origin) = origin {
1017 let headers = response.headers_mut();
1018 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1019 if let Ok(value) = HeaderValue::from_str("Origin") {
1022 headers.append(header::VARY, value);
1023 }
1024 }
1025 response
1026}
1027
1028const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1033
1034#[derive(Debug, thiserror::Error)]
1036pub enum ServeError {
1037 #[error("server I/O: {0}")]
1039 Io(#[from] std::io::Error),
1040}
1041
1042pub async fn serve(
1045 addr: SocketAddr,
1046 deploy: DeployStore,
1047 auth: Auth,
1048 handlers: HandlerRuntime,
1049) -> Result<(), ServeError> {
1050 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1051}
1052
1053pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1063 if let Err(err) = stream.set_nodelay(true) {
1064 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1065 }
1066}
1067
1068pub async fn serve_with(
1070 addr: SocketAddr,
1071 deploy: DeployStore,
1072 auth: Auth,
1073 handlers: HandlerRuntime,
1074 options: ServerOptions,
1075) -> Result<(), ServeError> {
1076 let listener = {
1077 use axum::serve::ListenerExt;
1078 tokio::net::TcpListener::bind(addr)
1079 .await?
1080 .tap_io(disable_nagle)
1081 };
1082 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1083 #[cfg(feature = "handlers")]
1086 let scheduler = handlers.spawn_scheduler(deploy.clone());
1087 let gateway_prober = gateway::spawn_active_health_prober();
1091 let app = router_with(deploy, auth, handlers, options)
1094 .into_make_service_with_connect_info::<SocketAddr>();
1095
1096 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1100 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
1101 shutdown_signal().await;
1102 let _ = signalled_tx.send(true);
1103 });
1104 let signalled = {
1105 let mut rx = signalled_rx;
1106 async move {
1107 let _ = rx.wait_for(|fired| *fired).await;
1108 }
1109 };
1110 let result = serve_with_drain_deadline(
1111 async move { server.await.map_err(ServeError::from) },
1112 signalled,
1113 DRAIN_DEADLINE,
1114 )
1115 .await;
1116 #[cfg(feature = "handlers")]
1118 if let Some(handle) = scheduler {
1119 handle.abort();
1120 }
1121 gateway_prober.abort();
1122 result
1123}
1124
1125async fn serve_with_drain_deadline<Srv, Sig>(
1130 server: Srv,
1131 signalled: Sig,
1132 deadline: Duration,
1133) -> Result<(), ServeError>
1134where
1135 Srv: Future<Output = Result<(), ServeError>>,
1136 Sig: Future<Output = ()>,
1137{
1138 tokio::pin!(server);
1139 let drain_cap = async move {
1140 signalled.await;
1141 tokio::time::sleep(deadline).await;
1142 };
1143 tokio::select! {
1144 result = &mut server => result,
1145 _ = drain_cap => {
1146 tracing::warn!(
1147 deadline_s = deadline.as_secs(),
1148 "drain deadline exceeded; forcing shutdown with requests still in flight"
1149 );
1150 Ok(())
1151 }
1152 }
1153}
1154
1155pub async fn shutdown_signal() {
1158 let ctrl_c = async {
1159 let _ = tokio::signal::ctrl_c().await;
1160 };
1161 #[cfg(unix)]
1162 let terminate = async {
1163 if let Ok(mut sig) =
1164 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1165 {
1166 sig.recv().await;
1167 }
1168 };
1169 #[cfg(not(unix))]
1170 let terminate = std::future::pending::<()>();
1171
1172 tokio::select! {
1173 _ = ctrl_c => {}
1174 _ = terminate => {}
1175 }
1176 tracing::info!("shutdown signal received; draining");
1177}
1178
1179async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1183 match daemon.generation() {
1184 Some(gen) => format!("ok gen={gen}"),
1185 None => "ok".to_string(),
1186 }
1187}
1188
1189async fn readyz(State(deploy): State<DeployStore>) -> Response {
1191 match deploy.ready().await {
1192 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1193 Err(err) => {
1194 tracing::warn!(error = %err, "readiness probe failed");
1195 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1196 }
1197 }
1198}
1199
1200#[derive(Clone)]
1205pub struct RequestId(pub String);
1206
1207fn request_id_for(headers: &HeaderMap) -> String {
1210 if let Some(id) = headers
1211 .get("x-request-id")
1212 .and_then(|v| v.to_str().ok())
1213 .map(str::trim)
1214 .filter(|s| !s.is_empty())
1215 {
1216 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1217 }
1218 use std::sync::atomic::{AtomicU64, Ordering};
1219 static SEQ: AtomicU64 = AtomicU64::new(0);
1220 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1221 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1222}
1223
1224struct AccessLog {
1228 request_id: String,
1229 method: Method,
1230 path: String,
1231 host: String,
1232 client: String,
1233 status: u16,
1234 encoding: String,
1236 start: std::time::Instant,
1237 bytes: std::sync::atomic::AtomicU64,
1238}
1239
1240impl Drop for AccessLog {
1241 fn drop(&mut self) {
1242 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1243 srvmetrics::server_metrics().record_request(self.status, bytes);
1246 tracing::info!(
1247 target: "boatramp::access",
1248 request_id = %self.request_id,
1249 method = %self.method,
1250 path = %self.path,
1251 host = %self.host,
1252 client = %self.client,
1253 status = self.status,
1254 bytes = bytes,
1255 encoding = %self.encoding,
1256 cache_result = srvmetrics::cache_result(self.status),
1257 elapsed_ms = self.start.elapsed().as_millis() as u64,
1258 "request"
1259 );
1260 }
1261}
1262
1263async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1267 let request_id = request_id_for(request.headers());
1270 request
1271 .extensions_mut()
1272 .insert(RequestId(request_id.clone()));
1273 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1278 return next.run(request).await;
1279 }
1280 let method = request.method().clone();
1281 let path = request.uri().path().to_string();
1282 let host = request
1283 .headers()
1284 .get(header::HOST)
1285 .and_then(|value| value.to_str().ok())
1286 .or_else(|| request.uri().host()) .unwrap_or("-")
1288 .to_string();
1289 let client = request
1290 .extensions()
1291 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1292 .map(|info| info.0.ip().to_string())
1293 .unwrap_or_else(|| "-".to_string());
1294
1295 let start = std::time::Instant::now();
1296 let response = next.run(request).await;
1297 let encoding = response
1298 .headers()
1299 .get(header::CONTENT_ENCODING)
1300 .and_then(|v| v.to_str().ok())
1301 .unwrap_or("identity")
1302 .to_string();
1303 let log = AccessLog {
1304 request_id,
1305 method,
1306 path,
1307 host,
1308 client,
1309 status: response.status().as_u16(),
1310 encoding,
1311 start,
1312 bytes: std::sync::atomic::AtomicU64::new(0),
1313 };
1314
1315 let (parts, body) = response.into_parts();
1319 let counted = body.into_data_stream().map(move |chunk| {
1320 if let Ok(bytes) = &chunk {
1321 log.bytes
1322 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1323 }
1324 chunk
1325 });
1326 Response::from_parts(parts, Body::from_stream(counted))
1327}
1328
1329fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1331 req_headers
1332 .get(header::IF_NONE_MATCH)
1333 .and_then(|value| value.to_str().ok())
1334 .is_some_and(|value| {
1335 value
1336 .split(',')
1337 .map(str::trim)
1338 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1339 })
1340}
1341
1342fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1343 if let Ok(value) = HeaderValue::from_str(value) {
1344 headers.insert(name, value);
1345 }
1346}
1347
1348fn not_found() -> Response {
1349 (StatusCode::NOT_FOUND, "not found\n").into_response()
1350}
1351
1352fn redirect(status: u16, location: &str) -> Response {
1353 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1354 match HeaderValue::from_str(location) {
1355 Ok(location) => {
1356 let mut headers = HeaderMap::new();
1357 headers.insert(header::LOCATION, location);
1358 (status, headers).into_response()
1359 }
1360 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1361 }
1362}
1363
1364fn deploy_error_response(err: DeployError) -> Response {
1366 let status = match &err {
1367 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1368 StatusCode::NOT_FOUND
1369 }
1370 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1371 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1372 DeployError::Conflict(_) => StatusCode::CONFLICT,
1374 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1376 _ => StatusCode::INTERNAL_SERVER_ERROR,
1377 };
1378 tracing::warn!(error = %err, "request failed");
1379 (status, format!("{err}\n")).into_response()
1380}
1381
1382fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1387 boatramp_core::project::validate_resource_name(kind, value)
1388 .err()
1389 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1390}
1391
1392#[cfg(test)]
1393mod drain_tests {
1394 use super::*;
1395
1396 #[tokio::test]
1397 async fn deadline_forces_shutdown_after_signal() {
1398 let server = std::future::pending::<Result<(), ServeError>>();
1401 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1403 assert!(result.is_ok());
1404 }
1405
1406 #[tokio::test]
1407 async fn server_finishing_first_wins() {
1408 let server = async { Ok(()) };
1411 let signalled = std::future::pending::<()>();
1412 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1413 assert!(result.is_ok());
1414 }
1415
1416 #[tokio::test]
1417 async fn deadline_does_not_trip_before_signal() {
1418 let server = async {
1422 tokio::time::sleep(Duration::from_millis(40)).await;
1423 Err(ServeError::Io(std::io::Error::other("server error")))
1424 };
1425 let signalled = std::future::pending::<()>();
1426 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1427 assert!(result.is_err());
1428 }
1429}
1430
1431#[cfg(all(test, feature = "handlers"))]
1432mod tests {
1433 use super::*;
1434 use boatramp_core::cose::{LocalSigner, TokenAlg};
1435 use boatramp_core::project::ProjectRef;
1436
1437 #[test]
1438 fn query_string_parses_and_url_decodes() {
1439 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1440 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1441 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")); }
1445
1446 #[test]
1447 fn cookie_header_parses_pairs() {
1448 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1449 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1450 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1451 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1452 }
1453
1454 #[test]
1455 fn apply_vary_merges_without_duplicates() {
1456 let base = (StatusCode::OK, "x").into_response();
1457 let r = apply_vary(base, &["accept-language".into()]);
1458 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1459 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1461 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1462 assert!(v.contains("accept-language") && v.contains("cookie"));
1463 assert_eq!(v.matches("accept-language").count(), 1);
1464 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1466 assert!(plain.headers().get(header::VARY).is_none());
1467 }
1468
1469 #[tokio::test]
1473 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1474 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1475 let public = keys.public_key();
1476
1477 let resp = create_join_token(
1479 Extension(Issuer(Some(keys.clone()))),
1480 Json(CreateJoinTokenRequest {
1481 ttl_secs: Some(600),
1482 }),
1483 )
1484 .await;
1485 assert_eq!(resp.status(), StatusCode::CREATED);
1486 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1487 .await
1488 .unwrap();
1489 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1490 let token = parsed["token"].as_str().unwrap();
1491 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1492 assert!(!jti.is_empty());
1493
1494 let no_issuer = create_join_token(
1496 Extension(Issuer(None)),
1497 Json(CreateJoinTokenRequest { ttl_secs: None }),
1498 )
1499 .await;
1500 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1501 }
1502
1503 #[tokio::test]
1508 async fn function_write_path_deploy_rollback_alias_remove() {
1509 use boatramp_core::function::Lifecycle;
1510 use boatramp_core::kv::MemoryKv;
1511 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1512
1513 struct FakeStorage {
1516 present: bool,
1517 }
1518 #[async_trait::async_trait]
1519 impl Storage for FakeStorage {
1520 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1521 Err(StorageError::NotFound(String::new()))
1522 }
1523 async fn get_range(
1524 &self,
1525 _: &str,
1526 _: u64,
1527 _: Option<u64>,
1528 ) -> Result<GetObject, StorageError> {
1529 Err(StorageError::NotFound(String::new()))
1530 }
1531 async fn put(
1532 &self,
1533 _: &str,
1534 _: ByteStream,
1535 _: PutMeta,
1536 ) -> Result<ObjectMeta, StorageError> {
1537 Err(StorageError::unsupported("fake"))
1538 }
1539 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1540 if self.present {
1541 Ok(ObjectMeta {
1542 key: key.to_string(),
1543 ..Default::default()
1544 })
1545 } else {
1546 Err(StorageError::NotFound(key.to_string()))
1547 }
1548 }
1549 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1550 Ok(())
1551 }
1552 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1553 Ok(Vec::new())
1554 }
1555 }
1556
1557 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1558 let status = resp.status();
1559 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1560 .await
1561 .unwrap();
1562 let value = if bytes.is_empty() {
1563 serde_json::Value::Null
1564 } else {
1565 serde_json::from_slice(&bytes).unwrap()
1566 };
1567 (status, value)
1568 }
1569
1570 let deploy = DeployStore::new(
1571 Arc::new(FakeStorage { present: true }),
1572 Arc::new(MemoryKv::new()),
1573 );
1574 let v1 = "a".repeat(64);
1575 let v2 = "b".repeat(64);
1576
1577 let (st, body) = body_json(
1579 deploy_function(
1580 State(deploy.clone()),
1581 axum::extract::Extension(crate::ProjectContext::default()),
1582 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1583 axum::extract::Query(DeployFunctionQuery::default()),
1584 Path("greeter".to_string()),
1585 Json(FunctionUpsert {
1586 component: v1.clone(),
1587 config: Default::default(),
1588 lifecycle: Lifecycle::Independent,
1589 }),
1590 )
1591 .await,
1592 )
1593 .await;
1594 assert_eq!(st, StatusCode::OK);
1595 assert_eq!(body["active"], v1);
1596
1597 let (_, body) = body_json(
1599 deploy_function(
1600 State(deploy.clone()),
1601 axum::extract::Extension(crate::ProjectContext::default()),
1602 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1603 axum::extract::Query(DeployFunctionQuery::default()),
1604 Path("greeter".to_string()),
1605 Json(FunctionUpsert {
1606 component: v2.clone(),
1607 config: Default::default(),
1608 lifecycle: Lifecycle::Independent,
1609 }),
1610 )
1611 .await,
1612 )
1613 .await;
1614 assert_eq!(body["active"], v2);
1615 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1616
1617 let (st, body) = body_json(
1619 rollback_function(
1620 State(deploy.clone()),
1621 axum::extract::Extension(crate::ProjectContext::default()),
1622 Path("greeter".to_string()),
1623 Json(RollbackBody { to: v1.clone() }),
1624 )
1625 .await,
1626 )
1627 .await;
1628 assert_eq!(st, StatusCode::OK);
1629 assert_eq!(body["active"], v1);
1630
1631 let resp = rollback_function(
1633 State(deploy.clone()),
1634 axum::extract::Extension(crate::ProjectContext::default()),
1635 Path("greeter".to_string()),
1636 Json(RollbackBody { to: "c".repeat(64) }),
1637 )
1638 .await;
1639 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1640
1641 let (st, body) = body_json(
1643 alias_function(
1644 State(deploy.clone()),
1645 axum::extract::Extension(crate::ProjectContext::default()),
1646 Path(("greeter".to_string(), "prod".to_string())),
1647 Json(AliasBody {
1648 version: v2.clone(),
1649 }),
1650 )
1651 .await,
1652 )
1653 .await;
1654 assert_eq!(st, StatusCode::OK);
1655 assert_eq!(body["aliases"]["prod"], v2);
1656
1657 let (st, _) = body_json(
1659 remove_function(
1660 State(deploy.clone()),
1661 axum::extract::Extension(crate::ProjectContext::default()),
1662 Path("greeter".to_string()),
1663 )
1664 .await,
1665 )
1666 .await;
1667 assert_eq!(st, StatusCode::NO_CONTENT);
1668 assert!(deploy
1669 .get_function(ProjectRef::DEFAULT, "greeter")
1670 .await
1671 .unwrap()
1672 .is_none());
1673
1674 let empty = DeployStore::new(
1676 Arc::new(FakeStorage { present: false }),
1677 Arc::new(MemoryKv::new()),
1678 );
1679 let resp = deploy_function(
1680 State(empty),
1681 axum::extract::Extension(crate::ProjectContext::default()),
1682 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1683 axum::extract::Query(DeployFunctionQuery::default()),
1684 Path("orphan".to_string()),
1685 Json(FunctionUpsert {
1686 component: v1.clone(),
1687 config: Default::default(),
1688 lifecycle: Lifecycle::default(),
1689 }),
1690 )
1691 .await;
1692 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1693 }
1694
1695 struct StubControl {
1699 admits: std::sync::Mutex<Vec<(String, String)>>,
1700 respond: StubJoin,
1701 }
1702 #[derive(Clone, Copy)]
1703 enum StubJoin {
1704 Admit,
1705 Spent,
1706 Invalid,
1707 Revoked,
1708 }
1709
1710 #[async_trait::async_trait]
1711 impl MeshControl for StubControl {
1712 async fn admit(
1713 &self,
1714 mesh_pubkey_hex: &str,
1715 jti: &str,
1716 _proof: &[u8],
1717 _proof_iat: u64,
1718 _now: u64,
1719 _advertise_addr: Option<&str>,
1720 ) -> Result<JoinOutcome, String> {
1721 self.admits
1722 .lock()
1723 .unwrap()
1724 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1725 Ok(match self.respond {
1726 StubJoin::Admit => JoinOutcome::Admitted {
1727 members: vec!["signed-member".to_string()],
1728 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1729 },
1730 StubJoin::Spent => JoinOutcome::TokenSpent,
1731 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1732 StubJoin::Revoked => JoinOutcome::Revoked,
1733 })
1734 }
1735 async fn rotate_key(&self) -> Result<String, String> {
1736 Ok("cafe".to_string())
1737 }
1738 async fn revoke(&self, _node: u64) -> Result<(), String> {
1739 Ok(())
1740 }
1741 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1742 Ok(Vec::new())
1743 }
1744 async fn promote(&self, _node: u64) -> Result<(), String> {
1745 Ok(())
1746 }
1747 }
1748
1749 #[tokio::test]
1753 async fn cluster_join_dispatches_and_maps_outcomes() {
1754 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1755 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1756 let auth = Auth::with_key(keys.public_key(), kv);
1757 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1758 let req = |proof: &str| JoinRequest {
1759 token: token.clone(),
1760 mesh_pubkey: "302a300506032b6570032100feed".into(),
1761 possession_proof: proof.to_string(),
1762 proof_iat: now_unix(),
1763 advertise_addr: Some("https://joiner:7000".into()),
1764 };
1765
1766 let admitter = Arc::new(StubControl {
1768 admits: std::sync::Mutex::new(Vec::new()),
1769 respond: StubJoin::Admit,
1770 });
1771 let resp = cluster_join(
1772 Extension(auth.clone()),
1773 Extension(MeshControlHandle(Some(admitter.clone()))),
1774 Json(req("aa01")),
1775 )
1776 .await;
1777 assert_eq!(resp.status(), StatusCode::OK);
1778 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1779
1780 let spent = Arc::new(StubControl {
1782 admits: std::sync::Mutex::new(Vec::new()),
1783 respond: StubJoin::Spent,
1784 });
1785 assert_eq!(
1786 cluster_join(
1787 Extension(auth.clone()),
1788 Extension(MeshControlHandle(Some(spent))),
1789 Json(req("aa01")),
1790 )
1791 .await
1792 .status(),
1793 StatusCode::CONFLICT
1794 );
1795 let invalid = Arc::new(StubControl {
1796 admits: std::sync::Mutex::new(Vec::new()),
1797 respond: StubJoin::Invalid,
1798 });
1799 assert_eq!(
1800 cluster_join(
1801 Extension(auth.clone()),
1802 Extension(MeshControlHandle(Some(invalid))),
1803 Json(req("aa01")),
1804 )
1805 .await
1806 .status(),
1807 StatusCode::FORBIDDEN
1808 );
1809 let revoked = Arc::new(StubControl {
1811 admits: std::sync::Mutex::new(Vec::new()),
1812 respond: StubJoin::Revoked,
1813 });
1814 assert_eq!(
1815 cluster_join(
1816 Extension(auth.clone()),
1817 Extension(MeshControlHandle(Some(revoked))),
1818 Json(req("aa01")),
1819 )
1820 .await
1821 .status(),
1822 StatusCode::FORBIDDEN
1823 );
1824
1825 let ok = Arc::new(StubControl {
1827 admits: std::sync::Mutex::new(Vec::new()),
1828 respond: StubJoin::Admit,
1829 });
1830 assert_eq!(
1831 cluster_join(
1832 Extension(auth.clone()),
1833 Extension(MeshControlHandle(Some(ok))),
1834 Json(req("not-hex")),
1835 )
1836 .await
1837 .status(),
1838 StatusCode::BAD_REQUEST
1839 );
1840
1841 let none = cluster_join(
1843 Extension(auth),
1844 Extension(MeshControlHandle(None)),
1845 Json(req("aa01")),
1846 )
1847 .await;
1848 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1849 }
1850
1851 #[tokio::test]
1855 async fn bootstrap_mints_the_first_token_once() {
1856 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1857 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1858 let public = keys.public_key();
1859 let deploy = DeployStore::new(
1860 Arc::new(MemStorage::default()),
1861 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1862 );
1863 let secret = "s3cr3t-bootstrap-value";
1864 let gate = BootstrapGate::new(Some(secret));
1865 let issuer = Issuer(Some(keys.clone()));
1866 let bearer = |s: &str| {
1867 let mut h = HeaderMap::new();
1868 h.insert(
1869 AUTHORIZATION,
1870 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1871 );
1872 h
1873 };
1874 let req = || BootstrapRequest {
1875 roles: vec!["admin".to_string()],
1876 ttl_secs: None,
1877 };
1878
1879 let bad = bootstrap_token(
1881 State(deploy.clone()),
1882 Extension(issuer.clone()),
1883 Extension(gate.clone()),
1884 bearer("wrong"),
1885 Json(req()),
1886 )
1887 .await;
1888 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1889
1890 let ok = bootstrap_token(
1892 State(deploy.clone()),
1893 Extension(issuer.clone()),
1894 Extension(gate.clone()),
1895 bearer(secret),
1896 Json(req()),
1897 )
1898 .await;
1899 assert_eq!(ok.status(), StatusCode::CREATED);
1900 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1901 .await
1902 .unwrap();
1903 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1904 let token = json["token"].as_str().unwrap();
1905 let id = json["id"].as_str().unwrap();
1906 let verified = cose::verify(token, &public, now_unix()).unwrap();
1907 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1908 assert!(deploy
1909 .list_token_meta()
1910 .await
1911 .unwrap()
1912 .iter()
1913 .any(|m| m.revocation_id == id));
1914
1915 let reuse = bootstrap_token(
1917 State(deploy.clone()),
1918 Extension(issuer.clone()),
1919 Extension(gate),
1920 bearer(secret),
1921 Json(req()),
1922 )
1923 .await;
1924 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1925
1926 let disabled = bootstrap_token(
1928 State(deploy),
1929 Extension(issuer),
1930 Extension(BootstrapGate(None)),
1931 bearer(secret),
1932 Json(req()),
1933 )
1934 .await;
1935 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1936 }
1937
1938 #[tokio::test]
1941 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1942 let control = Arc::new(StubControl {
1943 admits: std::sync::Mutex::new(Vec::new()),
1944 respond: StubJoin::Admit,
1945 });
1946 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1947 assert_eq!(resp.status(), StatusCode::OK);
1948 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1949 .await
1950 .unwrap();
1951 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1952 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1953
1954 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1955 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1956 }
1957
1958 #[test]
1959 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1960 use boatramp_core::security::SecurityProfile;
1961 let strict = SecurityProfile::MultiTenant.preset();
1962 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
1966 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1967 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1968
1969 assert!(gateway_addr_allowed(public, &strict));
1971 assert!(!gateway_addr_allowed(private, &strict));
1972 assert!(!gateway_addr_allowed(loopback, &strict));
1973 assert!(!gateway_addr_allowed(metadata, &strict));
1974
1975 assert!(gateway_addr_allowed(public, &loose));
1978 assert!(gateway_addr_allowed(private, &loose));
1979 assert!(gateway_addr_allowed(loopback, &loose));
1980 assert!(!gateway_addr_allowed(metadata, &loose));
1981 }
1982
1983 #[test]
1984 fn resolve_env_merges_static_and_host_secrets() {
1985 use boatramp_core::config::HandlersSiteConfig;
1986
1987 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
1989
1990 let deploy_env = std::collections::BTreeMap::from([
1991 ("GREETING".to_string(), "hi".to_string()),
1992 ("OVERRIDE_ME".to_string(), "static".to_string()),
1993 ]);
1994 let site_handlers = HandlersSiteConfig {
1995 enabled: true,
1996 secrets: std::collections::BTreeMap::from([
1997 (
1999 "SECRET_TOKEN".to_string(),
2000 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2001 ),
2002 (
2003 "OVERRIDE_ME".to_string(),
2004 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2005 ),
2006 (
2007 "MISSING".to_string(),
2008 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2009 ),
2010 ]),
2011 ..Default::default()
2012 };
2013 let env = resolve_env("blog", &deploy_env, &site_handlers);
2014
2015 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2019 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2020 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2021 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2022
2023 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2024 }
2025
2026 fn req() -> Request {
2027 Request::builder()
2028 .uri("/")
2029 .header(header::HOST, "example.com")
2030 .body(Body::empty())
2031 .unwrap()
2032 }
2033
2034 #[test]
2035 fn forwarded_headers_set_standard_triple() {
2036 let mut request = req();
2037 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2038 let h = request.headers();
2039 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2040 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2041 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2042 }
2043
2044 #[test]
2045 fn forwarded_for_overwrites_spoofed_value() {
2046 let mut request = Request::builder()
2049 .uri("/")
2050 .header(header::HOST, "example.com")
2051 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2052 .body(Body::empty())
2053 .unwrap();
2054 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2055 let values: Vec<_> = request
2056 .headers()
2057 .get_all("x-forwarded-for")
2058 .iter()
2059 .collect();
2060 assert_eq!(values.len(), 1);
2061 assert_eq!(values[0], "203.0.113.7");
2062 }
2063
2064 #[test]
2065 fn forwarded_proto_preserves_upstream_tls() {
2066 let mut request = Request::builder()
2068 .uri("/")
2069 .header(header::HOST, "example.com")
2070 .header("x-forwarded-proto", "https")
2071 .body(Body::empty())
2072 .unwrap();
2073 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2074 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2075 }
2076
2077 #[test]
2078 fn forwarded_host_absent_when_no_host_header() {
2079 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2080 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2081 assert!(request.headers().get("x-forwarded-host").is_none());
2082 assert_eq!(
2083 request.headers().get("x-forwarded-for").unwrap(),
2084 "203.0.113.7"
2085 );
2086 }
2087
2088 use boatramp_core::kv::{KvStore, MemoryKv};
2091 use boatramp_core::messaging::{LogMessaging, Messaging};
2092 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2093
2094 const EVENT_CONSUMER: &[u8] =
2095 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2096
2097 #[derive(Default)]
2098 struct MemStorage {
2099 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2100 }
2101
2102 #[async_trait::async_trait]
2103 impl boatramp_core::Storage for MemStorage {
2104 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2105 let bytes = self
2106 .objects
2107 .lock()
2108 .unwrap()
2109 .get(key)
2110 .cloned()
2111 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2112 let body: ByteStream =
2113 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2114 Ok(GetObject {
2115 meta: ObjectMeta {
2116 key: key.to_string(),
2117 ..Default::default()
2118 },
2119 body,
2120 })
2121 }
2122 async fn get_range(
2123 &self,
2124 key: &str,
2125 _: u64,
2126 _: Option<u64>,
2127 ) -> Result<GetObject, StorageError> {
2128 self.get(key).await
2129 }
2130 async fn put(
2131 &self,
2132 key: &str,
2133 mut body: ByteStream,
2134 _: PutMeta,
2135 ) -> Result<ObjectMeta, StorageError> {
2136 use futures::StreamExt;
2137 let mut buf = Vec::new();
2138 while let Some(chunk) = body.next().await {
2139 buf.extend_from_slice(&chunk?);
2140 }
2141 self.objects.lock().unwrap().insert(key.to_string(), buf);
2142 Ok(ObjectMeta {
2143 key: key.to_string(),
2144 ..Default::default()
2145 })
2146 }
2147 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2148 self.objects
2149 .lock()
2150 .unwrap()
2151 .get(key)
2152 .map(|_| ObjectMeta {
2153 key: key.to_string(),
2154 ..Default::default()
2155 })
2156 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2157 }
2158 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2159 self.objects.lock().unwrap().remove(key);
2160 Ok(())
2161 }
2162 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2163 Ok(Vec::new())
2164 }
2165 }
2166
2167 fn observed_state(
2169 workload: &str,
2170 healthy: bool,
2171 phase: boatramp_core::compute::ReplicaPhase,
2172 ) -> boatramp_core::compute::ObservedInstance {
2173 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2174 boatramp_core::compute::ObservedInstance {
2175 handle: InstanceHandle {
2176 workload: workload.into(),
2177 replica: 0,
2178 backend_ref: "ref-0".into(),
2179 },
2180 node: 1,
2181 backend: "vmm".into(),
2182 endpoint: Endpoint {
2183 scheme: Scheme::Http,
2184 host: "10.0.0.2".into(),
2185 port: 80,
2186 },
2187 region: None,
2188 healthy,
2189 phase,
2190 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2191 workload: workload.into(),
2192 replica: 0,
2193 data_ref: "snap-0".into(),
2194 }),
2195 }
2196 }
2197
2198 #[tokio::test]
2199 async fn has_parked_replica_detects_a_zeroed_replica() {
2200 use boatramp_core::compute::ReplicaPhase;
2201 let storage = Arc::new(MemStorage::default());
2202 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2203 let deploy = DeployStore::new(storage, kv);
2204
2205 assert!(!has_parked_replica(&deploy, "w").await);
2207 deploy
2209 .set_replica_state(
2210 ProjectRef::DEFAULT,
2211 &observed_state("w", true, ReplicaPhase::Running),
2212 )
2213 .await
2214 .unwrap();
2215 assert!(!has_parked_replica(&deploy, "w").await);
2216 deploy
2218 .set_replica_state(
2219 ProjectRef::DEFAULT,
2220 &observed_state("w", false, ReplicaPhase::Zero),
2221 )
2222 .await
2223 .unwrap();
2224 assert!(has_parked_replica(&deploy, "w").await);
2225 }
2226
2227 #[tokio::test]
2228 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2229 use boatramp_core::compute::ReplicaPhase;
2230 let storage = Arc::new(MemStorage::default());
2231 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2232 let deploy = DeployStore::new(storage, kv);
2233
2234 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2236 assert!(empty.is_empty());
2237
2238 deploy
2240 .set_replica_state(
2241 ProjectRef::DEFAULT,
2242 &observed_state("w", true, ReplicaPhase::Running),
2243 )
2244 .await
2245 .unwrap();
2246 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2247 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2248 }
2249
2250 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2254 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2255 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2256 let storage = Arc::new(MemStorage::default());
2257 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2258 let mq = LogMessaging::new(storage, kv.clone());
2259 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2260 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2261 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2262 let topic = "blog/orders/created";
2263
2264 for _ in 0..3 {
2266 mq.publish(topic, b"ok").await.unwrap();
2267 }
2268 loop {
2269 let acked = dispatch_consumer_batch(
2270 &engine,
2271 &mq,
2272 &metrics::Metrics::default(),
2273 "blog",
2274 topic,
2275 "blog/",
2276 "",
2277 boatramp_core::messaging::StartPosition::Latest,
2278 &hash,
2279 EVENT_CONSUMER,
2280 &bindings,
2281 Limits::default(),
2282 Duration::from_secs(30),
2283 5,
2284 10,
2285 )
2286 .await;
2287 if acked == 0 {
2288 break;
2289 }
2290 }
2291 assert_eq!(
2292 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2293 Some(b"3".to_vec())
2294 );
2295
2296 mq.publish(topic, b"fail").await.unwrap();
2299 for _ in 0..5 {
2300 dispatch_consumer_batch(
2301 &engine,
2302 &mq,
2303 &metrics::Metrics::default(),
2304 "blog",
2305 topic,
2306 "blog/",
2307 "",
2308 boatramp_core::messaging::StartPosition::Latest,
2309 &hash,
2310 EVENT_CONSUMER,
2311 &bindings,
2312 Limits::default(),
2313 Duration::ZERO,
2314 2,
2315 10,
2316 )
2317 .await;
2318 }
2319 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2320 assert_eq!(
2322 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2323 Some(b"3".to_vec())
2324 );
2325 }
2326
2327 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2332 async fn consumer_groups_fan_out_through_the_dispatcher() {
2333 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2334 let storage = Arc::new(MemStorage::default());
2335 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2336 let mq = LogMessaging::new(storage, kv.clone());
2337 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2338 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2339 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2340 let topic = "blog/orders/created";
2341 let start = boatramp_core::messaging::StartPosition::Latest;
2342
2343 for g in ["billing", "audit"] {
2346 let n = dispatch_consumer_batch(
2347 &engine,
2348 &mq,
2349 &metrics::Metrics::default(),
2350 "blog",
2351 topic,
2352 "blog/",
2353 g,
2354 start,
2355 &hash,
2356 EVENT_CONSUMER,
2357 &bindings,
2358 Limits::default(),
2359 Duration::from_secs(30),
2360 5,
2361 10,
2362 )
2363 .await;
2364 assert_eq!(n, 0, "no events yet for group {g}");
2365 }
2366 mq.publish(topic, b"ok").await.unwrap();
2367
2368 for g in ["billing", "audit"] {
2370 let n = dispatch_consumer_batch(
2371 &engine,
2372 &mq,
2373 &metrics::Metrics::default(),
2374 "blog",
2375 topic,
2376 "blog/",
2377 g,
2378 start,
2379 &hash,
2380 EVENT_CONSUMER,
2381 &bindings,
2382 Limits::default(),
2383 Duration::from_secs(30),
2384 5,
2385 10,
2386 )
2387 .await;
2388 assert_eq!(n, 1, "group {g} should receive the message");
2389 }
2390 assert_eq!(
2392 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2393 Some(b"2".to_vec())
2394 );
2395 }
2396
2397 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2401 async fn scheduler_runs_current_consumers_not_previews() {
2402 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2403 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2404 use boatramp_handlers::{HandlerEngine, Limits};
2405 use futures::StreamExt;
2406
2407 let storage = Arc::new(MemStorage::default());
2408 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2409 let deploy = DeployStore::new(storage.clone(), kv.clone());
2410 let messaging: Arc<dyn Messaging> =
2411 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2412
2413 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2415 let stream: ByteStream =
2416 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2417 .boxed();
2418 deploy.put_blob(&hash, stream).await.unwrap();
2419 let mut files = std::collections::BTreeMap::new();
2420 files.insert(
2421 "consumer.wasm".to_string(),
2422 FileEntry {
2423 hash: hash.clone(),
2424 size: EVENT_CONSUMER.len() as u64,
2425 content_type: None,
2426 variants: std::collections::BTreeMap::new(),
2427 },
2428 );
2429 let manifest = Manifest {
2430 files,
2431 config: DeployConfig {
2432 consumers: vec![ConsumerConfig {
2433 topic: "orders/created".into(),
2434 component: "consumer.wasm".into(),
2435 imports: vec!["wasi:keyvalue".into()],
2436 group: String::new(),
2437 start: Default::default(),
2438 }],
2439 ..Default::default()
2440 },
2441 ..Default::default()
2442 };
2443 let id = deploy.put_manifest(&manifest).await.unwrap();
2444 deploy
2445 .activate(ProjectRef::DEFAULT, "blog", &id)
2446 .await
2447 .unwrap();
2448 deploy
2449 .set_site_config(
2450 ProjectRef::DEFAULT,
2451 "blog",
2452 &SiteConfig {
2453 handlers: Some(HandlersSiteConfig {
2454 enabled: true,
2455 allow_imports: vec!["wasi:keyvalue".into()],
2456 ..Default::default()
2457 }),
2458 ..Default::default()
2459 },
2460 )
2461 .await
2462 .unwrap();
2463
2464 messaging
2466 .publish("blog/orders/created", b"live")
2467 .await
2468 .unwrap();
2469 messaging
2470 .publish("blog/_preview/abc/orders/created", b"preview")
2471 .await
2472 .unwrap();
2473
2474 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2475 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2476 let inner = rt.inner.clone().unwrap();
2477 let mut cache = std::collections::HashMap::new();
2478 let mut crons = std::collections::HashMap::new();
2479 let mut sweep = std::collections::HashMap::new();
2480 let now = CronNow {
2481 minute: 0,
2482 hour: 0,
2483 dom: 1,
2484 month: 1,
2485 dow: 0,
2486 minute_stamp: 0,
2487 };
2488 for _ in 0..3 {
2489 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
2490 .await
2491 .unwrap();
2492 }
2493
2494 assert_eq!(
2496 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2497 Some(b"1".to_vec())
2498 );
2499 assert_eq!(
2502 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2503 .await
2504 .unwrap(),
2505 None
2506 );
2507 }
2508
2509 const KV_COUNTER: &[u8] =
2514 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2515
2516 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2521 async fn function_invoker_runs_target_buffers_and_meters() {
2522 use boatramp_core::deploy::DeployStore;
2523 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2524 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2525 use futures::StreamExt;
2526
2527 const HTTP_200: &[u8] =
2530 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2531
2532 let storage = Arc::new(MemStorage::default());
2533 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2534 let deploy = DeployStore::new(storage.clone(), kv.clone());
2535
2536 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2537 let stream: ByteStream =
2538 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2539 deploy.put_blob(&hash, stream).await.unwrap();
2540 let function = Function {
2541 name: "target".into(),
2542 owner: Owner::Project("default".into()),
2543 versions: vec![FunctionVersion {
2544 id: "v1".into(),
2545 component: hash.clone(),
2546 created: 0,
2547 lifecycle: Lifecycle::Independent,
2548 }],
2549 active: "v1".into(),
2550 aliases: Default::default(),
2551 config: Default::default(),
2552 };
2553 deploy
2554 .put_function(ProjectRef::DEFAULT, &function)
2555 .await
2556 .unwrap();
2557
2558 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2559 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2560 rt.set_invoker(deploy.clone());
2561 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2562
2563 let request = || InvokeRequest {
2564 method: "GET".into(),
2565 path: "/".into(),
2566 headers: vec![],
2567 body: vec![],
2568 };
2569
2570 let response = invoker.invoke("target", request(), 1).await.unwrap();
2572 assert_eq!(response.status, 200);
2573
2574 let metering = deploy
2576 .get_metering(ProjectRef::DEFAULT, "target")
2577 .await
2578 .unwrap()
2579 .unwrap();
2580 assert_eq!(metering.invocations, 1);
2581
2582 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2584 assert!(matches!(err, InvokeError::NotFound));
2585 }
2586
2587 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2593 async fn federation_runner_enforces_the_safelist_before_planning() {
2594 use boatramp_core::deploy::DeployStore;
2595 use boatramp_core::project::ProjectRef;
2596 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2597
2598 let storage = Arc::new(MemStorage::default());
2599 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2600 let deploy = DeployStore::new(storage.clone(), kv.clone());
2601 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2602 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2603 rt.set_invoker(deploy.clone());
2604 let runner = rt
2605 .inner
2606 .as_ref()
2607 .unwrap()
2608 .federation_runner
2609 .get()
2610 .unwrap()
2611 .scoped(ProjectRef::new("default"));
2612
2613 let req = |query: &str| GraphqlRequest {
2614 query: Some(query.to_string()),
2615 persisted_hash: None,
2616 variables: "{}".to_string(),
2617 operation_name: None,
2618 authorization: None,
2619 };
2620
2621 assert!(matches!(
2623 runner.run(req("{ me { id } }"), 1).await,
2624 Err(SupergraphRunError::NotSafelisted)
2625 ));
2626
2627 let query = "{ me { id } }";
2630 let hash = crate::graphql_apq::sha256_hex(query);
2631 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2632 .await
2633 .unwrap();
2634 assert!(matches!(
2635 runner.run(req(query), 1).await,
2636 Err(SupergraphRunError::PlanFailed(_))
2637 ));
2638
2639 let persisted = GraphqlRequest {
2641 query: None,
2642 persisted_hash: Some("deadbeef".to_string()),
2643 variables: "{}".to_string(),
2644 operation_name: None,
2645 authorization: None,
2646 };
2647 assert!(matches!(
2648 runner.run(persisted, 1).await,
2649 Err(SupergraphRunError::NotSafelisted)
2650 ));
2651 }
2652
2653 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2659 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2660 use crate::scheduler::{run_scheduler_tick, CronNow};
2661 use boatramp_core::deploy::DeployStore;
2662 use boatramp_core::function::{
2663 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2664 };
2665 use boatramp_handlers::{HandlerEngine, Limits};
2666 use futures::StreamExt;
2667
2668 const HTTP_200: &[u8] =
2669 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2670
2671 let storage = Arc::new(MemStorage::default());
2672 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2673 let deploy = DeployStore::new(storage.clone(), kv.clone());
2674
2675 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2676 let stream: ByteStream =
2677 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2678 deploy.put_blob(&hash, stream).await.unwrap();
2679
2680 let acme = ProjectRef::new("acme");
2682 let function = Function {
2683 name: "worker".into(),
2684 owner: Owner::Project("acme".into()),
2685 versions: vec![FunctionVersion {
2686 id: "v1".into(),
2687 component: hash.clone(),
2688 created: 0,
2689 lifecycle: Lifecycle::Independent,
2690 }],
2691 active: "v1".into(),
2692 aliases: Default::default(),
2693 config: Default::default(),
2694 };
2695 deploy.put_function(acme, &function).await.unwrap();
2696 let inv = Invocation {
2697 id: "inv1".into(),
2698 function: "worker".into(),
2699 version: "v1".into(),
2700 mode: InvokeMode::Async,
2701 status: InvocationStatus::Queued,
2702 idempotency_key: None,
2703 attempts: 0,
2704 lease_expires: None,
2705 request_b64: None,
2706 request_content_type: None,
2707 result: None,
2708 created: 0,
2709 updated: 0,
2710 };
2711 deploy.put_invocation(acme, &inv).await.unwrap();
2712
2713 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2714 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2715 let inner = rt.inner.as_ref().unwrap();
2716
2717 let mut wasm_cache = std::collections::HashMap::new();
2720 let mut cron_state = std::collections::HashMap::new();
2721 let mut sweep = std::collections::HashMap::new();
2722 let now = CronNow {
2723 minute: 0,
2724 hour: 0,
2725 dom: 1,
2726 month: 1,
2727 dow: 0,
2728 minute_stamp: 0,
2729 };
2730 run_scheduler_tick(
2731 inner,
2732 &deploy,
2733 &mut wasm_cache,
2734 &mut cron_state,
2735 &mut sweep,
2736 now,
2737 )
2738 .await
2739 .unwrap();
2740
2741 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
2744 assert_eq!(settled.status, InvocationStatus::Succeeded);
2746 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2748 assert_eq!(metering.invocations, 1);
2749 assert!(deploy
2751 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2752 .await
2753 .unwrap()
2754 .is_none());
2755 assert!(deploy
2756 .get_metering(ProjectRef::DEFAULT, "worker")
2757 .await
2758 .unwrap()
2759 .is_none());
2760 }
2761
2762 #[cfg(feature = "handlers")]
2766 async fn poll_invocation_settled(
2767 deploy: &boatramp_core::deploy::DeployStore,
2768 project: ProjectRef<'_>,
2769 function: &str,
2770 id: &str,
2771 ) -> boatramp_core::function::Invocation {
2772 use boatramp_core::function::InvocationStatus;
2773 for _ in 0..200 {
2774 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
2775 if matches!(
2776 inv.status,
2777 InvocationStatus::Succeeded | InvocationStatus::Failed
2778 ) {
2779 return inv;
2780 }
2781 }
2782 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2783 }
2784 panic!("invocation {function}/{id} never settled");
2785 }
2786
2787 #[cfg(feature = "handlers")]
2792 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2793 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
2794 use crate::scheduler::{run_scheduler_tick, CronNow};
2795 use boatramp_core::deploy::DeployStore;
2796 use boatramp_core::function::{
2797 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2798 };
2799 use boatramp_handlers::{HandlerEngine, Limits};
2800 use futures::StreamExt;
2801
2802 const HTTP_200: &[u8] =
2803 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2804
2805 let storage = Arc::new(MemStorage::default());
2806 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2807 let deploy = DeployStore::new(storage.clone(), kv.clone());
2808 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2809 let stream: ByteStream =
2810 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2811 deploy.put_blob(&hash, stream).await.unwrap();
2812
2813 let function = Function {
2814 name: "worker".into(),
2815 owner: Owner::Project("default".into()),
2816 versions: vec![FunctionVersion {
2817 id: "v1".into(),
2818 component: hash.clone(),
2819 created: 0,
2820 lifecycle: Lifecycle::Independent,
2821 }],
2822 active: "v1".into(),
2823 aliases: Default::default(),
2824 config: Default::default(),
2825 };
2826 deploy
2827 .put_function(ProjectRef::DEFAULT, &function)
2828 .await
2829 .unwrap();
2830
2831 let base = Invocation {
2834 id: String::new(),
2835 function: "worker".into(),
2836 version: "v1".into(),
2837 mode: InvokeMode::Async,
2838 status: InvocationStatus::Running,
2839 idempotency_key: None,
2840 attempts: 1,
2841 lease_expires: None,
2842 request_b64: None,
2843 request_content_type: None,
2844 result: None,
2845 created: 0,
2846 updated: 0,
2847 };
2848 let orphan = Invocation {
2849 id: "orphan".into(),
2850 lease_expires: Some(1),
2851 ..base.clone()
2852 };
2853 deploy
2854 .put_invocation(ProjectRef::DEFAULT, &orphan)
2855 .await
2856 .unwrap();
2857 let live = Invocation {
2858 id: "live".into(),
2859 lease_expires: Some(u64::MAX),
2860 ..base.clone()
2861 };
2862 deploy
2863 .put_invocation(ProjectRef::DEFAULT, &live)
2864 .await
2865 .unwrap();
2866
2867 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2868 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2869 let inner = rt.inner.as_ref().unwrap();
2870
2871 let now = CronNow {
2872 minute: 0,
2873 hour: 0,
2874 dom: 1,
2875 month: 1,
2876 dow: 0,
2877 minute_stamp: 0,
2878 };
2879 let mut wasm_cache = std::collections::HashMap::new();
2880 let mut cron_state = std::collections::HashMap::new();
2881 let mut sweep = std::collections::HashMap::new();
2882 run_scheduler_tick(
2883 inner,
2884 &deploy,
2885 &mut wasm_cache,
2886 &mut cron_state,
2887 &mut sweep,
2888 now,
2889 )
2890 .await
2891 .unwrap();
2892
2893 let settled =
2895 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
2896 assert_eq!(settled.status, InvocationStatus::Succeeded);
2897 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
2898 assert_eq!(
2899 settled.lease_expires, None,
2900 "a settled invocation drops its lease"
2901 );
2902 let live_after = deploy
2904 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
2905 .await
2906 .unwrap()
2907 .unwrap();
2908 assert_eq!(live_after.status, InvocationStatus::Running);
2909 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
2910 assert_eq!(live_after.lease_expires, Some(u64::MAX));
2911 }
2912
2913 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2928 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2929 use boatramp_core::deploy::DeployStore;
2930 use boatramp_core::function::{
2931 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2932 };
2933 use boatramp_handlers::{HandlerEngine, Limits};
2934 use futures::StreamExt;
2935
2936 let storage = Arc::new(MemStorage::default());
2937 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2938 let deploy = DeployStore::new(storage.clone(), kv.clone());
2939
2940 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2943 let stream: ByteStream =
2944 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2945 deploy.put_blob(&hash, stream).await.unwrap();
2946
2947 let store = Function {
2952 name: "store".into(),
2953 owner: Owner::Project("default".into()),
2954 versions: vec![FunctionVersion {
2955 id: "v1".into(),
2956 component: hash.clone(),
2957 created: 0,
2958 lifecycle: Lifecycle::Independent,
2959 }],
2960 active: "v1".into(),
2961 aliases: Default::default(),
2962 config: FunctionConfig {
2963 imports: vec!["wasi:keyvalue".into()],
2964 ..Default::default()
2965 },
2966 };
2967 let acme = ProjectRef::new("acme");
2968 let globex = ProjectRef::new("globex");
2969
2970 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2971 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2972 let inner = rt.inner.as_ref().unwrap();
2973
2974 let request = || {
2975 axum::http::Request::builder()
2976 .method("GET")
2977 .uri("/")
2978 .body(axum::body::Body::empty())
2979 .unwrap()
2980 };
2981
2982 let component = store.resolve(&store.active).unwrap().to_owned();
2985 for project in [acme, globex, ProjectRef::DEFAULT] {
2986 let (response, _) = execute_function(
2987 inner,
2988 &deploy,
2989 project,
2990 &store,
2991 &component,
2992 request(),
2993 0,
2994 boatramp_handlers::Lane::Sync,
2995 )
2996 .await;
2997 assert!(response.status().is_success(), "invocation should succeed");
2998 }
2999
3000 assert_eq!(
3003 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3004 Some(b"1".to_vec()),
3005 "acme's write must be tenant-qualified"
3006 );
3007 assert_eq!(
3008 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3009 Some(b"1".to_vec()),
3010 "globex's write must be tenant-qualified"
3011 );
3012 assert_eq!(
3013 kv.get("hkv/fn/store/hits").await.unwrap(),
3014 Some(b"1".to_vec()),
3015 "the default project must keep the byte-identical pre-project key"
3016 );
3017 }
3020
3021 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3025 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3026 use boatramp_core::config::{
3027 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3028 };
3029 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3030 use boatramp_handlers::{HandlerEngine, Limits};
3031 use futures::StreamExt;
3032 use std::sync::atomic::Ordering;
3033
3034 let storage = Arc::new(MemStorage::default());
3035 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3036 let deploy = DeployStore::new(storage.clone(), kv.clone());
3037
3038 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3039 let stream: ByteStream =
3040 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3041 deploy.put_blob(&hash, stream).await.unwrap();
3042 let mut files = std::collections::BTreeMap::new();
3043 files.insert(
3044 "counter.wasm".to_string(),
3045 FileEntry {
3046 hash: hash.clone(),
3047 size: KV_COUNTER.len() as u64,
3048 content_type: None,
3049 variants: std::collections::BTreeMap::new(),
3050 },
3051 );
3052 let manifest = Manifest {
3053 files,
3054 config: DeployConfig {
3055 handlers: vec![HandlerConfig {
3056 route: "/".into(),
3057 methods: Vec::new(),
3058 component: "counter.wasm".into(),
3059 imports: vec!["wasi:keyvalue".into()],
3060 limits: None,
3061 env: std::collections::BTreeMap::new(),
3062 invoke_targets: Vec::new(),
3063 }],
3064 crons: vec![CronConfig {
3065 schedule: "* * * * *".into(),
3066 route: "/".into(),
3067 overlap: Overlap::Skip,
3068 }],
3069 ..Default::default()
3070 },
3071 ..Default::default()
3072 };
3073 let id = deploy.put_manifest(&manifest).await.unwrap();
3074 deploy
3075 .activate(ProjectRef::DEFAULT, "blog", &id)
3076 .await
3077 .unwrap();
3078 deploy
3079 .set_site_config(
3080 ProjectRef::DEFAULT,
3081 "blog",
3082 &SiteConfig {
3083 handlers: Some(HandlersSiteConfig {
3084 enabled: true,
3085 allow_imports: vec!["wasi:keyvalue".into()],
3086 ..Default::default()
3087 }),
3088 ..Default::default()
3089 },
3090 )
3091 .await
3092 .unwrap();
3093
3094 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3095 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3096 let inner = rt.inner.clone().unwrap();
3097 let mut wasm = std::collections::HashMap::new();
3098 let mut crons = std::collections::HashMap::new();
3099 let mut sweep = std::collections::HashMap::new();
3100 let at = |stamp| CronNow {
3101 minute: 0,
3102 hour: 0,
3103 dom: 1,
3104 month: 1,
3105 dow: 0,
3106 minute_stamp: stamp,
3107 };
3108
3109 let (_, handles) =
3111 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3112 .await
3113 .unwrap();
3114 for h in handles {
3115 h.await.unwrap();
3116 }
3117 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3118
3119 let (_, handles) =
3121 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3122 .await
3123 .unwrap();
3124 assert!(handles.is_empty());
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(101))
3130 .await
3131 .unwrap();
3132 for h in handles {
3133 h.await.unwrap();
3134 }
3135 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3136
3137 crons
3141 .get("default|blog|cron|0")
3142 .unwrap()
3143 .running
3144 .store(true, Ordering::Release);
3145 let (_, handles) =
3146 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3147 .await
3148 .unwrap();
3149 assert!(handles.is_empty());
3150 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3151 }
3152
3153 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3157 async fn cron_leader_gate_suppresses_crons_off_leader() {
3158 use boatramp_core::config::{
3159 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3160 };
3161 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3162 use boatramp_handlers::{HandlerEngine, Limits};
3163 use futures::StreamExt;
3164
3165 let storage = Arc::new(MemStorage::default());
3166 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3167 let deploy = DeployStore::new(storage.clone(), kv.clone());
3168
3169 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3170 let stream: ByteStream =
3171 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3172 deploy.put_blob(&hash, stream).await.unwrap();
3173 let mut files = std::collections::BTreeMap::new();
3174 files.insert(
3175 "counter.wasm".to_string(),
3176 FileEntry {
3177 hash: hash.clone(),
3178 size: KV_COUNTER.len() as u64,
3179 content_type: None,
3180 variants: std::collections::BTreeMap::new(),
3181 },
3182 );
3183 let manifest = Manifest {
3184 files,
3185 config: DeployConfig {
3186 handlers: vec![HandlerConfig {
3187 route: "/".into(),
3188 methods: Vec::new(),
3189 component: "counter.wasm".into(),
3190 imports: vec!["wasi:keyvalue".into()],
3191 limits: None,
3192 env: std::collections::BTreeMap::new(),
3193 invoke_targets: Vec::new(),
3194 }],
3195 crons: vec![CronConfig {
3196 schedule: "* * * * *".into(),
3197 route: "/".into(),
3198 overlap: Overlap::Skip,
3199 }],
3200 ..Default::default()
3201 },
3202 ..Default::default()
3203 };
3204 let id = deploy.put_manifest(&manifest).await.unwrap();
3205 deploy
3206 .activate(ProjectRef::DEFAULT, "blog", &id)
3207 .await
3208 .unwrap();
3209 deploy
3210 .set_site_config(
3211 ProjectRef::DEFAULT,
3212 "blog",
3213 &SiteConfig {
3214 handlers: Some(HandlersSiteConfig {
3215 enabled: true,
3216 allow_imports: vec!["wasi:keyvalue".into()],
3217 ..Default::default()
3218 }),
3219 ..Default::default()
3220 },
3221 )
3222 .await
3223 .unwrap();
3224
3225 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3226 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3227 rt.set_cron_leader_gate(Arc::new(|| false));
3229 let inner = rt.inner.clone().unwrap();
3230 let mut wasm = std::collections::HashMap::new();
3231 let mut crons = std::collections::HashMap::new();
3232 let mut sweep = std::collections::HashMap::new();
3233 let now = CronNow {
3234 minute: 0,
3235 hour: 0,
3236 dom: 1,
3237 month: 1,
3238 dow: 0,
3239 minute_stamp: 100,
3240 };
3241
3242 let (_, handles) =
3243 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3244 .await
3245 .unwrap();
3246 assert!(handles.is_empty(), "a non-leader must not fire crons");
3248 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3249 }
3250
3251 #[tokio::test]
3258 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3259 use boatramp_core::config::HandlersSiteConfig;
3260 use boatramp_core::project::ProjectRef;
3261 use boatramp_handlers::{HandlerEngine, Limits};
3262
3263 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3264 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3265 let sql_dir =
3267 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3268 let _ = std::fs::remove_dir_all(&sql_dir);
3269 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3270 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3271
3272 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3273 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3274 let inner = rt.inner.as_ref().unwrap();
3275
3276 let site = HandlersSiteConfig {
3278 enabled: true,
3279 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3280 ..Default::default()
3281 };
3282 let env = std::collections::BTreeMap::new();
3283 let build = |imports: &[&str]| {
3284 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3285 let site = &site;
3286 let env = &env;
3287 async move {
3288 crate::handler_dispatch::build_bindings(
3289 inner,
3290 ProjectRef::new("default"),
3291 "shop",
3292 "shop",
3293 None,
3294 &imports,
3295 site,
3296 env,
3297 &[],
3298 0,
3299 None,
3300 )
3301 .await
3302 .sql_database_names()
3303 }
3304 };
3305
3306 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3309 assert_eq!(
3311 build(&["sql", "sql:*"]).await,
3312 vec!["", "privileged", "product"]
3313 );
3314 assert!(build(&["sql:secret"]).await.is_empty());
3316 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3318
3319 let _ = std::fs::remove_dir_all(&sql_dir);
3320 }
3321}