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(feature = "handlers")]
116pub use function_api::{
117 component_requires, host_capability_features, host_capability_features_detailed, unmet_requires,
118};
119#[cfg(all(test, feature = "handlers"))]
120use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
121pub use function_api::{CapabilityFeature, Lifecycle};
124mod gateway;
125mod host;
126pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
127#[cfg(feature = "http3")]
128mod http3;
129mod limits;
130#[cfg(feature = "handlers")]
131mod logs;
132#[cfg(feature = "handlers")]
133mod metrics;
134#[cfg(feature = "oidc")]
135mod oidc;
136mod operator;
137pub(crate) use operator::prometheus_metrics;
138#[cfg(feature = "handlers")]
139pub(crate) use operator::{
140 operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
141};
142mod proxy;
143pub use proxy::spawn_compute_reconcile;
144pub(crate) use proxy::{
145 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
146 proxy, COMPUTE_WAKE_TIMEOUT,
147};
148mod splice;
149mod http_serve;
152pub use http_serve::{
153 alpn_h1_h2, serve_plaintext, serve_plaintext_listener, serve_router_conn, serve_tls,
154 serve_tls_listener, ReloadableTls,
155};
156#[cfg(feature = "handlers")]
158pub(crate) use proxy::is_upgrade_request;
159#[cfg(all(test, feature = "handlers"))]
160use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
161mod project_api;
162pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
163mod project_scope;
164pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
165mod ratelimit;
166mod routes;
167pub use routes::{router, router_with};
168#[cfg(feature = "mcp")]
169mod mcp_http;
170#[cfg(feature = "handlers")]
171mod scheduler;
172mod serve_pipeline;
173pub use serve_pipeline::http_redirect_router;
174#[cfg(all(test, feature = "handlers"))]
175use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
176pub(crate) use serve_pipeline::{
177 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
178 BootstrapAttestation,
179};
180pub mod signer;
183mod srvmetrics;
184#[cfg(all(feature = "handlers", test))]
185use scheduler::run_scheduler_tick;
186#[cfg(feature = "handlers")]
187pub(crate) use scheduler::{
188 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
189};
190#[cfg(feature = "handlers")]
191use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
192#[cfg(feature = "handlers")]
193mod function_runtime;
194#[cfg(feature = "handlers")]
195pub(crate) use function_runtime::{
196 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
197 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
198 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
199 put_trigger_handler, webhook_ingress,
200};
201#[cfg(feature = "handlers")]
202mod stream;
203#[cfg(feature = "handlers")]
204mod workflow;
205pub use auth::{require_auth, Auth};
206#[cfg(feature = "http3")]
207pub use http3::{
208 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
209 Http3Error,
210};
211pub use limits::{ServerLimits, UploadGuard};
212#[cfg(feature = "oidc")]
213pub use oidc::{OidcConfig, OidcError, OidcVerifier};
214use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
215#[cfg(feature = "handlers")]
216pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
217#[cfg(feature = "handlers")]
218pub(crate) use workflow::{
219 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
220 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
221};
222pub use srvmetrics::{server_metrics, ServerMetrics};
225
226#[derive(Clone, Default)]
231pub struct HandlerRuntime {
232 #[cfg(feature = "handlers")]
233 inner: Option<Arc<HandlerRuntimeInner>>,
234}
235
236#[cfg(feature = "handlers")]
237struct HandlerRuntimeInner {
238 engine: boatramp_handlers::HandlerEngine,
239 async_drain_gate: Arc<tokio::sync::Semaphore>,
245 kv: Arc<dyn boatramp_core::kv::KvStore>,
246 storage: Arc<dyn boatramp_core::Storage>,
247 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
251 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
254 site_semaphores:
257 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
258 stream_semaphores:
262 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
263 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
266 metrics: metrics::Metrics,
269 logs: Arc<logs::LogStore>,
271 #[cfg(feature = "handlers")]
275 graphql_cache: graphql_cache::GraphqlCache,
276 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
282 max_blob_bytes: std::sync::OnceLock<u64>,
286 max_component_bytes: std::sync::OnceLock<u64>,
291 function_meter_locks:
295 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
296 function_semaphores:
299 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
300 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
305 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
309 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
317 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
322}
323
324pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
327
328impl HandlerRuntime {
329 pub fn disabled() -> Self {
331 Self::default()
332 }
333
334 #[cfg(feature = "handlers")]
340 pub fn new(
341 engine: boatramp_handlers::HandlerEngine,
342 kv: Arc<dyn boatramp_core::kv::KvStore>,
343 storage: Arc<dyn boatramp_core::Storage>,
344 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
345 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
346 ) -> Self {
347 let async_drain_slots = engine.async_max_concurrency().max(1);
350 Self {
351 inner: Some(Arc::new(HandlerRuntimeInner {
352 engine,
353 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
354 kv,
355 storage,
356 sql,
357 messaging,
358 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
359 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
360 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
361 metrics: metrics::Metrics::default(),
362 logs: Arc::new(logs::LogStore::default()),
363 #[cfg(feature = "handlers")]
364 graphql_cache: graphql_cache::GraphqlCache::default(),
365 cron_leader_gate: std::sync::OnceLock::new(),
366 max_blob_bytes: std::sync::OnceLock::new(),
367 max_component_bytes: std::sync::OnceLock::new(),
368 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
369 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
370 watch_provider: std::sync::OnceLock::new(),
371 provision_tier: std::sync::OnceLock::new(),
372 invoker: std::sync::OnceLock::new(),
373 federation_runner: std::sync::OnceLock::new(),
374 })),
375 }
376 }
377
378 #[cfg(feature = "handlers")]
381 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
382 self.inner.as_ref().and_then(|inner| inner.sql.clone())
383 }
384
385 #[cfg(feature = "handlers")]
389 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
390 self.inner
391 .as_ref()
392 .and_then(|inner| inner.invoker.get().cloned())
393 }
394
395 #[cfg(feature = "handlers")]
399 pub(crate) async fn introspect_subgraph_sdl(
400 &self,
401 deploy: &DeployStore,
402 project: boatramp_core::project::ProjectRef<'_>,
403 function: &boatramp_core::function::Function,
404 component: &str,
405 ) -> Result<String, function_runtime::SubgraphSdlError> {
406 match self.inner.as_ref() {
407 Some(inner) => {
408 function_runtime::introspect_service_sdl(
409 inner, deploy, project, function, component,
410 )
411 .await
412 }
413 None => Err(function_runtime::SubgraphSdlError::Unavailable),
414 }
415 }
416
417 #[cfg(feature = "handlers")]
423 pub fn set_invoker(&self, deploy: DeployStore) {
424 if let Some(inner) = self.inner.as_ref() {
425 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
426 deploy,
427 Arc::downgrade(inner),
428 ));
429 let _ = inner.invoker.set(invoker);
430 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
433 inner,
434 )));
435 let _ = inner.federation_runner.set(runner);
436 }
437 }
438
439 #[cfg(feature = "handlers")]
443 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
444 self.inner.as_ref().and_then(|inner| inner.sql.clone())
445 }
446
447 #[cfg(feature = "handlers")]
451 pub fn set_watch_provider(
452 &self,
453 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
454 ) {
455 if let Some(inner) = self.inner.as_ref() {
456 let _ = inner.watch_provider.set(provider);
457 }
458 }
459
460 #[cfg(feature = "handlers")]
464 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
465 if let Some(inner) = self.inner.as_ref() {
466 let _ = inner.provision_tier.set(tier);
467 }
468 }
469
470 #[cfg(feature = "handlers")]
474 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
475 if let Some(inner) = self.inner.as_ref() {
476 let _ = inner.max_blob_bytes.set(max_bytes);
477 }
478 }
479
480 #[cfg(feature = "handlers")]
483 pub fn set_max_component_bytes(&self, max_bytes: u64) {
484 if let Some(inner) = self.inner.as_ref() {
485 let _ = inner.max_component_bytes.set(max_bytes);
486 }
487 }
488
489 #[cfg(feature = "handlers")]
494 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
495 if let Some(inner) = self.inner.as_ref() {
496 let _ = inner.cron_leader_gate.set(gate);
497 }
498 }
499
500 #[cfg(feature = "handlers")]
507 async fn precheck_activation(
508 &self,
509 deploy: &DeployStore,
510 manifest: &Manifest,
511 site_config: Option<&SiteConfig>,
512 ) -> Result<(), String> {
513 let Some(inner) = self.inner.as_ref() else {
514 return Ok(());
515 };
516 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
519 return Ok(());
520 }
521 let site_handlers = site_config
523 .and_then(|c| c.handlers.as_ref())
524 .filter(|h| h.enabled)
525 .ok_or_else(|| {
526 "deployment ships handlers/consumers but the site has them disabled".to_string()
527 })?;
528 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
529
530 let sync_ceiling = inner.engine.sync_timeout_ms();
536 let async_ceiling = inner.engine.async_timeout_ms();
537 if let Some(ms) = site_handlers.max_timeout_ms {
538 if u64::from(ms) > sync_ceiling {
539 tracing::warn!(
540 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
541 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
542 applies only to async calls (?mode=async / triggers), capped at \
543 async_max_timeout_ms={async_ceiling}"
544 );
545 }
546 }
547
548 for handler in &manifest.config.handlers {
550 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
551 if u64::from(ms) > sync_ceiling {
552 let route = &handler.route;
553 tracing::warn!(
554 "route {route:?} declares limits.timeout_ms={ms}, above \
555 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
556 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
557 (?mode=async / a queue trigger / a #[consumer]), capped at \
558 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
559 that isn't possible — move the work to the async lane"
560 );
561 }
562 }
563 if !handler.streaming {
568 if let Some(entry) = manifest.files.get(&handler.component) {
569 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
570 if crate::function_api::component_declares_streaming_route(
571 &bytes,
572 &handler.route,
573 ) {
574 let route = &handler.route;
575 tracing::warn!(
576 "route {route:?} is a streaming handler (#[handler(stream)]) but \
577 its config lacks streaming = true: it will run on the sync request \
578 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
579 streaming = true so it serves on the dedicated streaming lane (its \
580 own concurrency budget + a much larger wall-clock)."
581 );
582 }
583 }
584 }
585 }
586 if let Some(entry) = manifest.files.get(&handler.component) {
590 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
591 let unmet = crate::function_api::unmet_requires(&bytes);
592 if !unmet.is_empty() {
593 return Err(format!(
594 "route {:?} [{}] requires capabilities this host does not implement: \
595 {}. Upgrade boatramp or enable those features — see `boatramp \
596 capabilities`.",
597 handler.route,
598 handler.methods.join(","),
599 unmet.join(", ")
600 ));
601 }
602 }
603 }
604 precheck_component(
605 deploy,
606 manifest,
607 site_handlers,
608 inner,
609 max_component,
610 &handler.imports,
611 &handler.component,
612 &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
615 false,
616 )
617 .await?;
618 }
619 for consumer in &manifest.config.consumers {
620 precheck_component(
621 deploy,
622 manifest,
623 site_handlers,
624 inner,
625 max_component,
626 &consumer.imports,
627 &consumer.component,
628 &format!("consumer {:?}", consumer.topic),
629 true,
630 )
631 .await?;
632 }
633 Ok(())
634 }
635
636 #[cfg(not(feature = "handlers"))]
637 async fn precheck_activation(
638 &self,
639 _deploy: &DeployStore,
640 _manifest: &Manifest,
641 _site_config: Option<&SiteConfig>,
642 ) -> Result<(), String> {
643 Ok(())
644 }
645}
646
647#[derive(Default, Clone)]
653pub struct ServerOptions {
654 pub limits: ServerLimits,
656 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
659 pub default_site: Option<String>,
662 pub implicit_routing: bool,
668 pub protect_previews: bool,
673 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
677 pub issuer: Option<Arc<dyn Signer>>,
681 pub bootstrap_secret: Option<String>,
686 pub bootstrap_attestation: Option<String>,
692 pub mesh_control: Option<Arc<dyn MeshControl>>,
696 pub cors_allowed_origins: Vec<String>,
706 #[cfg(feature = "oidc")]
709 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
710 pub posture: boatramp_core::security::SecurityPosture,
714 pub served_over_tls: bool,
719 pub pop_origin: Option<String>,
726 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
730 #[cfg(feature = "console")]
734 pub console: Option<console::ConsoleMount>,
735}
736
737#[derive(Clone, Copy)]
741struct ServedOverTls(bool);
742
743#[derive(Clone, Copy, Default)]
748struct ImplicitRouting(bool);
749
750const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
762
763pub struct DaemonRuntime {
764 baseline: boatramp_core::daemon_config::ConfigBaseline,
765 state: std::sync::RwLock<DaemonState>,
766 reload: tokio::sync::Notify,
769}
770
771struct DaemonState {
772 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
773 generation: Option<String>,
774}
775
776pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
781 #[cfg(feature = "console")]
785 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
786 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
787 None => (false, None, None),
788 };
789 #[cfg(not(feature = "console"))]
790 let (console_enabled, console_host, console_path) = (false, None, None);
791 boatramp_core::daemon_config::ConfigBaseline {
792 default_site: options.default_site.clone(),
793 protect_previews: options.protect_previews,
794 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
795 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
796 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
797 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
798 compute_vcpus: 0,
799 compute_mem_mib: 0,
800 console_enabled,
801 console_host,
802 console_path,
803 max_upload_ceiling: options.posture.max_upload_bytes,
804 max_concurrent_uploads_ceiling: None,
805 posture: options.posture,
806 }
807}
808
809impl DaemonRuntime {
810 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
814 let effective =
815 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
816 Self {
817 baseline,
818 state: std::sync::RwLock::new(DaemonState {
819 effective,
820 generation: None,
821 }),
822 reload: tokio::sync::Notify::new(),
823 }
824 }
825
826 pub fn notify_reload(&self) {
830 self.reload.notify_one();
831 }
832
833 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
835 self.state
836 .read()
837 .expect("daemon config lock")
838 .effective
839 .clone()
840 }
841
842 pub fn generation(&self) -> Option<String> {
845 self.state
846 .read()
847 .expect("daemon config lock")
848 .generation
849 .clone()
850 }
851
852 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
854 &self.baseline
855 }
856
857 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
860 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
861 let generation = deploy.daemon_config_generation().await?;
862 let effective = Arc::new(cfg.resolve(&self.baseline));
863 *self.state.write().expect("daemon config lock") = DaemonState {
864 effective,
865 generation,
866 };
867 Ok(())
868 }
869}
870
871#[derive(Clone, Copy, Default)]
874struct PreviewPolicy {
875 protect: bool,
876}
877
878#[derive(Clone, Default)]
883struct Issuer(Option<Arc<dyn Signer>>);
884
885#[derive(Clone, Default)]
890struct BootstrapGate(Option<Arc<BootstrapInner>>);
891
892struct BootstrapInner {
893 secret_hash: String,
896 lock: tokio::sync::Mutex<()>,
899}
900
901impl BootstrapGate {
902 fn new(secret: Option<&str>) -> Self {
903 Self(secret.filter(|s| !s.is_empty()).map(|s| {
904 Arc::new(BootstrapInner {
905 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
906 lock: tokio::sync::Mutex::new(()),
907 })
908 }))
909 }
910}
911
912#[async_trait::async_trait]
916pub trait MeshControl: Send + Sync {
917 async fn admit(
925 &self,
926 mesh_pubkey_hex: &str,
927 jti: &str,
928 possession_proof: &[u8],
929 proof_iat: u64,
930 now: u64,
931 advertise_addr: Option<&str>,
932 ) -> Result<JoinOutcome, String>;
933
934 async fn rotate_key(&self) -> Result<String, String>;
938
939 async fn revoke(&self, node: u64) -> Result<(), String>;
943
944 async fn members(&self) -> Result<Vec<MeshMember>, String>;
948
949 async fn promote(&self, node: u64) -> Result<(), String>;
952}
953
954pub enum JoinOutcome {
956 Admitted {
959 members: Vec<String>,
961 addrs: std::collections::BTreeMap<u64, String>,
963 },
964 TokenSpent,
966 ProofInvalid,
968 Revoked,
971}
972
973#[derive(Debug, Clone, Serialize)]
975pub struct MeshMember {
976 pub node: u64,
978 pub voter: bool,
980 pub caught_up: bool,
982 pub leader: bool,
984 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub addr: Option<String>,
989}
990
991#[derive(Clone, Default)]
994struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
995
996#[cfg(feature = "oidc")]
998#[derive(Clone, Default)]
999struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1000
1001#[cfg(feature = "oidc")]
1004const EXCHANGE_TTL_SECS: u64 = 3600;
1005
1006use boatramp_core::time::now_unix;
1007
1008#[derive(Clone)]
1010struct CorsState(Arc<Vec<String>>);
1011
1012const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1014const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1017const CORS_MAX_AGE: &str = "600";
1019
1020fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1024 allowed.iter().any(|a| a == "*" || a == origin)
1025}
1026
1027async fn cors(
1034 State(allowed): State<CorsState>,
1035 request: Request,
1036 next: axum::middleware::Next,
1037) -> Response {
1038 let origin = request
1039 .headers()
1040 .get(header::ORIGIN)
1041 .and_then(|v| v.to_str().ok())
1042 .filter(|o| cors_origin_allowed(&allowed.0, o))
1043 .map(str::to_string);
1044 let is_preflight = request.method() == Method::OPTIONS
1046 && request
1047 .headers()
1048 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1049 if is_preflight {
1050 let allow_headers = request
1052 .headers()
1053 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1054 .and_then(|v| v.to_str().ok())
1055 .map(str::to_string)
1056 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1057 let mut response = Response::new(Body::empty());
1058 *response.status_mut() = StatusCode::NO_CONTENT;
1059 if let Some(origin) = origin {
1060 let headers = response.headers_mut();
1061 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1062 set_header(headers, header::VARY, "Origin");
1063 set_header(
1064 headers,
1065 header::ACCESS_CONTROL_ALLOW_METHODS,
1066 CORS_ALLOW_METHODS,
1067 );
1068 set_header(
1069 headers,
1070 header::ACCESS_CONTROL_ALLOW_HEADERS,
1071 &allow_headers,
1072 );
1073 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1074 }
1075 return response;
1076 }
1077 let mut response = next.run(request).await;
1078 if let Some(origin) = origin {
1079 let headers = response.headers_mut();
1080 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1081 if let Ok(value) = HeaderValue::from_str("Origin") {
1084 headers.append(header::VARY, value);
1085 }
1086 }
1087 response
1088}
1089
1090const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1095
1096#[derive(Debug, thiserror::Error)]
1098pub enum ServeError {
1099 #[error("server I/O: {0}")]
1101 Io(#[from] std::io::Error),
1102}
1103
1104pub async fn serve(
1107 addr: SocketAddr,
1108 deploy: DeployStore,
1109 auth: Auth,
1110 handlers: HandlerRuntime,
1111) -> Result<(), ServeError> {
1112 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1113}
1114
1115pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1125 if let Err(err) = stream.set_nodelay(true) {
1126 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1127 }
1128}
1129
1130pub async fn serve_with(
1132 addr: SocketAddr,
1133 deploy: DeployStore,
1134 auth: Auth,
1135 handlers: HandlerRuntime,
1136 options: ServerOptions,
1137) -> Result<(), ServeError> {
1138 let tcp = tokio::net::TcpListener::bind(addr).await?;
1139 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1140 let splice_ctx = splice::SpliceCtx {
1146 deploy: deploy.clone(),
1147 posture: options.posture,
1148 daemon: options.daemon_runtime.clone(),
1149 };
1150 #[cfg(feature = "handlers")]
1153 let scheduler = handlers.spawn_scheduler(deploy.clone());
1154 let gateway_prober = gateway::spawn_active_health_prober();
1158 let router = router_with(deploy, auth, handlers, options);
1161
1162 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1166 let server = splice::serve(tcp, splice_ctx, router, async move {
1170 shutdown_signal().await;
1171 let _ = signalled_tx.send(true);
1172 });
1173 let signalled = {
1174 let mut rx = signalled_rx;
1175 async move {
1176 let _ = rx.wait_for(|fired| *fired).await;
1177 }
1178 };
1179 let result = serve_with_drain_deadline(
1180 async move { server.await.map_err(ServeError::from) },
1181 signalled,
1182 DRAIN_DEADLINE,
1183 )
1184 .await;
1185 #[cfg(feature = "handlers")]
1187 if let Some(handle) = scheduler {
1188 handle.abort();
1189 }
1190 gateway_prober.abort();
1191 result
1192}
1193
1194async fn serve_with_drain_deadline<Srv, Sig>(
1199 server: Srv,
1200 signalled: Sig,
1201 deadline: Duration,
1202) -> Result<(), ServeError>
1203where
1204 Srv: Future<Output = Result<(), ServeError>>,
1205 Sig: Future<Output = ()>,
1206{
1207 tokio::pin!(server);
1208 let drain_cap = async move {
1209 signalled.await;
1210 tokio::time::sleep(deadline).await;
1211 };
1212 tokio::select! {
1213 result = &mut server => result,
1214 _ = drain_cap => {
1215 tracing::warn!(
1216 deadline_s = deadline.as_secs(),
1217 "drain deadline exceeded; forcing shutdown with requests still in flight"
1218 );
1219 Ok(())
1220 }
1221 }
1222}
1223
1224pub async fn shutdown_signal() {
1227 let ctrl_c = async {
1228 let _ = tokio::signal::ctrl_c().await;
1229 };
1230 #[cfg(unix)]
1231 let terminate = async {
1232 if let Ok(mut sig) =
1233 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1234 {
1235 sig.recv().await;
1236 }
1237 };
1238 #[cfg(not(unix))]
1239 let terminate = std::future::pending::<()>();
1240
1241 tokio::select! {
1242 _ = ctrl_c => {}
1243 _ = terminate => {}
1244 }
1245 tracing::info!("shutdown signal received; draining");
1246}
1247
1248async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1252 match daemon.generation() {
1253 Some(gen) => format!("ok gen={gen}"),
1254 None => "ok".to_string(),
1255 }
1256}
1257
1258async fn readyz(State(deploy): State<DeployStore>) -> Response {
1260 match deploy.ready().await {
1261 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1262 Err(err) => {
1263 tracing::warn!(error = %err, "readiness probe failed");
1264 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1265 }
1266 }
1267}
1268
1269#[derive(Clone)]
1274pub struct RequestId(pub String);
1275
1276fn request_id_for(headers: &HeaderMap) -> String {
1279 if let Some(id) = headers
1280 .get("x-request-id")
1281 .and_then(|v| v.to_str().ok())
1282 .map(str::trim)
1283 .filter(|s| !s.is_empty())
1284 {
1285 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1286 }
1287 use std::sync::atomic::{AtomicU64, Ordering};
1288 static SEQ: AtomicU64 = AtomicU64::new(0);
1289 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1290 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1291}
1292
1293struct AccessLog {
1297 request_id: String,
1298 method: Method,
1299 path: String,
1300 host: String,
1301 client: String,
1302 status: u16,
1303 encoding: String,
1305 start: std::time::Instant,
1306 bytes: std::sync::atomic::AtomicU64,
1307}
1308
1309impl Drop for AccessLog {
1310 fn drop(&mut self) {
1311 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1312 srvmetrics::server_metrics().record_request(self.status, bytes);
1315 tracing::info!(
1316 target: "boatramp::access",
1317 request_id = %self.request_id,
1318 method = %self.method,
1319 path = %self.path,
1320 host = %self.host,
1321 client = %self.client,
1322 status = self.status,
1323 bytes = bytes,
1324 encoding = %self.encoding,
1325 cache_result = srvmetrics::cache_result(self.status),
1326 elapsed_ms = self.start.elapsed().as_millis() as u64,
1327 "request"
1328 );
1329 }
1330}
1331
1332async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1336 let request_id = request_id_for(request.headers());
1339 request
1340 .extensions_mut()
1341 .insert(RequestId(request_id.clone()));
1342 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1347 return next.run(request).await;
1348 }
1349 let method = request.method().clone();
1350 let path = request.uri().path().to_string();
1351 let host = request
1352 .headers()
1353 .get(header::HOST)
1354 .and_then(|value| value.to_str().ok())
1355 .or_else(|| request.uri().host()) .unwrap_or("-")
1357 .to_string();
1358 let client = request
1359 .extensions()
1360 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1361 .map(|info| info.0.ip().to_string())
1362 .unwrap_or_else(|| "-".to_string());
1363
1364 let start = std::time::Instant::now();
1365 let response = next.run(request).await;
1366 let encoding = response
1367 .headers()
1368 .get(header::CONTENT_ENCODING)
1369 .and_then(|v| v.to_str().ok())
1370 .unwrap_or("identity")
1371 .to_string();
1372 let log = AccessLog {
1373 request_id,
1374 method,
1375 path,
1376 host,
1377 client,
1378 status: response.status().as_u16(),
1379 encoding,
1380 start,
1381 bytes: std::sync::atomic::AtomicU64::new(0),
1382 };
1383
1384 let (parts, body) = response.into_parts();
1388 let counted = body.into_data_stream().map(move |chunk| {
1389 if let Ok(bytes) = &chunk {
1390 log.bytes
1391 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1392 }
1393 chunk
1394 });
1395 Response::from_parts(parts, Body::from_stream(counted))
1396}
1397
1398fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1400 req_headers
1401 .get(header::IF_NONE_MATCH)
1402 .and_then(|value| value.to_str().ok())
1403 .is_some_and(|value| {
1404 value
1405 .split(',')
1406 .map(str::trim)
1407 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1408 })
1409}
1410
1411fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1412 if let Ok(value) = HeaderValue::from_str(value) {
1413 headers.insert(name, value);
1414 }
1415}
1416
1417fn not_found() -> Response {
1418 (StatusCode::NOT_FOUND, "not found\n").into_response()
1419}
1420
1421fn redirect(status: u16, location: &str) -> Response {
1422 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1423 match HeaderValue::from_str(location) {
1424 Ok(location) => {
1425 let mut headers = HeaderMap::new();
1426 headers.insert(header::LOCATION, location);
1427 (status, headers).into_response()
1428 }
1429 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1430 }
1431}
1432
1433fn deploy_error_response(err: DeployError) -> Response {
1435 let status = match &err {
1436 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1437 StatusCode::NOT_FOUND
1438 }
1439 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1440 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1441 DeployError::Conflict(_) => StatusCode::CONFLICT,
1443 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1445 _ => StatusCode::INTERNAL_SERVER_ERROR,
1446 };
1447 tracing::warn!(error = %err, "request failed");
1448 (status, format!("{err}\n")).into_response()
1449}
1450
1451fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1456 boatramp_core::project::validate_resource_name(kind, value)
1457 .err()
1458 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1459}
1460
1461#[cfg(test)]
1462mod drain_tests {
1463 use super::*;
1464
1465 #[tokio::test]
1466 async fn deadline_forces_shutdown_after_signal() {
1467 let server = std::future::pending::<Result<(), ServeError>>();
1470 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1472 assert!(result.is_ok());
1473 }
1474
1475 #[tokio::test]
1476 async fn server_finishing_first_wins() {
1477 let server = async { Ok(()) };
1480 let signalled = std::future::pending::<()>();
1481 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1482 assert!(result.is_ok());
1483 }
1484
1485 #[tokio::test]
1486 async fn deadline_does_not_trip_before_signal() {
1487 let server = async {
1491 tokio::time::sleep(Duration::from_millis(40)).await;
1492 Err(ServeError::Io(std::io::Error::other("server error")))
1493 };
1494 let signalled = std::future::pending::<()>();
1495 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1496 assert!(result.is_err());
1497 }
1498}
1499
1500#[cfg(all(test, feature = "handlers"))]
1501mod tests {
1502 use super::*;
1503 use boatramp_core::cose::{LocalSigner, TokenAlg};
1504 use boatramp_core::project::ProjectRef;
1505
1506 #[test]
1507 fn query_string_parses_and_url_decodes() {
1508 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1509 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1510 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")); }
1514
1515 #[test]
1516 fn cookie_header_parses_pairs() {
1517 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1518 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1519 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1520 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1521 }
1522
1523 #[test]
1524 fn apply_vary_merges_without_duplicates() {
1525 let base = (StatusCode::OK, "x").into_response();
1526 let r = apply_vary(base, &["accept-language".into()]);
1527 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1528 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1530 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1531 assert!(v.contains("accept-language") && v.contains("cookie"));
1532 assert_eq!(v.matches("accept-language").count(), 1);
1533 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1535 assert!(plain.headers().get(header::VARY).is_none());
1536 }
1537
1538 #[tokio::test]
1542 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1543 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1544 let public = keys.public_key();
1545
1546 let resp = create_join_token(
1548 Extension(Issuer(Some(keys.clone()))),
1549 Json(CreateJoinTokenRequest {
1550 ttl_secs: Some(600),
1551 }),
1552 )
1553 .await;
1554 assert_eq!(resp.status(), StatusCode::CREATED);
1555 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1556 .await
1557 .unwrap();
1558 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1559 let token = parsed["token"].as_str().unwrap();
1560 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1561 assert!(!jti.is_empty());
1562
1563 let no_issuer = create_join_token(
1565 Extension(Issuer(None)),
1566 Json(CreateJoinTokenRequest { ttl_secs: None }),
1567 )
1568 .await;
1569 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1570 }
1571
1572 #[tokio::test]
1577 async fn function_write_path_deploy_rollback_alias_remove() {
1578 use boatramp_core::function::Lifecycle;
1579 use boatramp_core::kv::MemoryKv;
1580 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1581
1582 struct FakeStorage {
1585 present: bool,
1586 }
1587 #[async_trait::async_trait]
1588 impl Storage for FakeStorage {
1589 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1590 Err(StorageError::NotFound(String::new()))
1591 }
1592 async fn get_range(
1593 &self,
1594 _: &str,
1595 _: u64,
1596 _: Option<u64>,
1597 ) -> Result<GetObject, StorageError> {
1598 Err(StorageError::NotFound(String::new()))
1599 }
1600 async fn put(
1601 &self,
1602 _: &str,
1603 _: ByteStream,
1604 _: PutMeta,
1605 ) -> Result<ObjectMeta, StorageError> {
1606 Err(StorageError::unsupported("fake"))
1607 }
1608 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1609 if self.present {
1610 Ok(ObjectMeta {
1611 key: key.to_string(),
1612 ..Default::default()
1613 })
1614 } else {
1615 Err(StorageError::NotFound(key.to_string()))
1616 }
1617 }
1618 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1619 Ok(())
1620 }
1621 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1622 Ok(Vec::new())
1623 }
1624 }
1625
1626 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1627 let status = resp.status();
1628 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1629 .await
1630 .unwrap();
1631 let value = if bytes.is_empty() {
1632 serde_json::Value::Null
1633 } else {
1634 serde_json::from_slice(&bytes).unwrap()
1635 };
1636 (status, value)
1637 }
1638
1639 let deploy = DeployStore::new(
1640 Arc::new(FakeStorage { present: true }),
1641 Arc::new(MemoryKv::new()),
1642 );
1643 let v1 = "a".repeat(64);
1644 let v2 = "b".repeat(64);
1645
1646 let (st, body) = body_json(
1648 deploy_function(
1649 State(deploy.clone()),
1650 axum::extract::Extension(crate::ProjectContext::default()),
1651 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1652 axum::extract::Query(DeployFunctionQuery::default()),
1653 Path("greeter".to_string()),
1654 Json(FunctionUpsert {
1655 component: v1.clone(),
1656 config: Default::default(),
1657 lifecycle: Lifecycle::Independent,
1658 }),
1659 )
1660 .await,
1661 )
1662 .await;
1663 assert_eq!(st, StatusCode::OK);
1664 assert_eq!(body["active"], v1);
1665
1666 let (_, body) = body_json(
1668 deploy_function(
1669 State(deploy.clone()),
1670 axum::extract::Extension(crate::ProjectContext::default()),
1671 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1672 axum::extract::Query(DeployFunctionQuery::default()),
1673 Path("greeter".to_string()),
1674 Json(FunctionUpsert {
1675 component: v2.clone(),
1676 config: Default::default(),
1677 lifecycle: Lifecycle::Independent,
1678 }),
1679 )
1680 .await,
1681 )
1682 .await;
1683 assert_eq!(body["active"], v2);
1684 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1685
1686 let (st, body) = body_json(
1688 rollback_function(
1689 State(deploy.clone()),
1690 axum::extract::Extension(crate::ProjectContext::default()),
1691 Path("greeter".to_string()),
1692 Json(RollbackBody { to: v1.clone() }),
1693 )
1694 .await,
1695 )
1696 .await;
1697 assert_eq!(st, StatusCode::OK);
1698 assert_eq!(body["active"], v1);
1699
1700 let resp = rollback_function(
1702 State(deploy.clone()),
1703 axum::extract::Extension(crate::ProjectContext::default()),
1704 Path("greeter".to_string()),
1705 Json(RollbackBody { to: "c".repeat(64) }),
1706 )
1707 .await;
1708 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1709
1710 let (st, body) = body_json(
1712 alias_function(
1713 State(deploy.clone()),
1714 axum::extract::Extension(crate::ProjectContext::default()),
1715 Path(("greeter".to_string(), "prod".to_string())),
1716 Json(AliasBody {
1717 version: v2.clone(),
1718 }),
1719 )
1720 .await,
1721 )
1722 .await;
1723 assert_eq!(st, StatusCode::OK);
1724 assert_eq!(body["aliases"]["prod"], v2);
1725
1726 let (st, _) = body_json(
1728 remove_function(
1729 State(deploy.clone()),
1730 axum::extract::Extension(crate::ProjectContext::default()),
1731 Path("greeter".to_string()),
1732 )
1733 .await,
1734 )
1735 .await;
1736 assert_eq!(st, StatusCode::NO_CONTENT);
1737 assert!(deploy
1738 .get_function(ProjectRef::DEFAULT, "greeter")
1739 .await
1740 .unwrap()
1741 .is_none());
1742
1743 let empty = DeployStore::new(
1745 Arc::new(FakeStorage { present: false }),
1746 Arc::new(MemoryKv::new()),
1747 );
1748 let resp = deploy_function(
1749 State(empty),
1750 axum::extract::Extension(crate::ProjectContext::default()),
1751 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1752 axum::extract::Query(DeployFunctionQuery::default()),
1753 Path("orphan".to_string()),
1754 Json(FunctionUpsert {
1755 component: v1.clone(),
1756 config: Default::default(),
1757 lifecycle: Lifecycle::default(),
1758 }),
1759 )
1760 .await;
1761 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1762 }
1763
1764 struct StubControl {
1768 admits: std::sync::Mutex<Vec<(String, String)>>,
1769 respond: StubJoin,
1770 }
1771 #[derive(Clone, Copy)]
1772 enum StubJoin {
1773 Admit,
1774 Spent,
1775 Invalid,
1776 Revoked,
1777 }
1778
1779 #[async_trait::async_trait]
1780 impl MeshControl for StubControl {
1781 async fn admit(
1782 &self,
1783 mesh_pubkey_hex: &str,
1784 jti: &str,
1785 _proof: &[u8],
1786 _proof_iat: u64,
1787 _now: u64,
1788 _advertise_addr: Option<&str>,
1789 ) -> Result<JoinOutcome, String> {
1790 self.admits
1791 .lock()
1792 .unwrap()
1793 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1794 Ok(match self.respond {
1795 StubJoin::Admit => JoinOutcome::Admitted {
1796 members: vec!["signed-member".to_string()],
1797 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1798 },
1799 StubJoin::Spent => JoinOutcome::TokenSpent,
1800 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1801 StubJoin::Revoked => JoinOutcome::Revoked,
1802 })
1803 }
1804 async fn rotate_key(&self) -> Result<String, String> {
1805 Ok("cafe".to_string())
1806 }
1807 async fn revoke(&self, _node: u64) -> Result<(), String> {
1808 Ok(())
1809 }
1810 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1811 Ok(Vec::new())
1812 }
1813 async fn promote(&self, _node: u64) -> Result<(), String> {
1814 Ok(())
1815 }
1816 }
1817
1818 #[tokio::test]
1822 async fn cluster_join_dispatches_and_maps_outcomes() {
1823 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1824 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1825 let auth = Auth::with_key(keys.public_key(), kv);
1826 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1827 let req = |proof: &str| JoinRequest {
1828 token: token.clone(),
1829 mesh_pubkey: "302a300506032b6570032100feed".into(),
1830 possession_proof: proof.to_string(),
1831 proof_iat: now_unix(),
1832 advertise_addr: Some("https://joiner:7000".into()),
1833 };
1834
1835 let admitter = Arc::new(StubControl {
1837 admits: std::sync::Mutex::new(Vec::new()),
1838 respond: StubJoin::Admit,
1839 });
1840 let resp = cluster_join(
1841 Extension(auth.clone()),
1842 Extension(MeshControlHandle(Some(admitter.clone()))),
1843 Json(req("aa01")),
1844 )
1845 .await;
1846 assert_eq!(resp.status(), StatusCode::OK);
1847 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1848
1849 let spent = Arc::new(StubControl {
1851 admits: std::sync::Mutex::new(Vec::new()),
1852 respond: StubJoin::Spent,
1853 });
1854 assert_eq!(
1855 cluster_join(
1856 Extension(auth.clone()),
1857 Extension(MeshControlHandle(Some(spent))),
1858 Json(req("aa01")),
1859 )
1860 .await
1861 .status(),
1862 StatusCode::CONFLICT
1863 );
1864 let invalid = Arc::new(StubControl {
1865 admits: std::sync::Mutex::new(Vec::new()),
1866 respond: StubJoin::Invalid,
1867 });
1868 assert_eq!(
1869 cluster_join(
1870 Extension(auth.clone()),
1871 Extension(MeshControlHandle(Some(invalid))),
1872 Json(req("aa01")),
1873 )
1874 .await
1875 .status(),
1876 StatusCode::FORBIDDEN
1877 );
1878 let revoked = Arc::new(StubControl {
1880 admits: std::sync::Mutex::new(Vec::new()),
1881 respond: StubJoin::Revoked,
1882 });
1883 assert_eq!(
1884 cluster_join(
1885 Extension(auth.clone()),
1886 Extension(MeshControlHandle(Some(revoked))),
1887 Json(req("aa01")),
1888 )
1889 .await
1890 .status(),
1891 StatusCode::FORBIDDEN
1892 );
1893
1894 let ok = Arc::new(StubControl {
1896 admits: std::sync::Mutex::new(Vec::new()),
1897 respond: StubJoin::Admit,
1898 });
1899 assert_eq!(
1900 cluster_join(
1901 Extension(auth.clone()),
1902 Extension(MeshControlHandle(Some(ok))),
1903 Json(req("not-hex")),
1904 )
1905 .await
1906 .status(),
1907 StatusCode::BAD_REQUEST
1908 );
1909
1910 let none = cluster_join(
1912 Extension(auth),
1913 Extension(MeshControlHandle(None)),
1914 Json(req("aa01")),
1915 )
1916 .await;
1917 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1918 }
1919
1920 #[tokio::test]
1924 async fn bootstrap_mints_the_first_token_once() {
1925 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1926 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1927 let public = keys.public_key();
1928 let deploy = DeployStore::new(
1929 Arc::new(MemStorage::default()),
1930 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1931 );
1932 let secret = "s3cr3t-bootstrap-value";
1933 let gate = BootstrapGate::new(Some(secret));
1934 let issuer = Issuer(Some(keys.clone()));
1935 let bearer = |s: &str| {
1936 let mut h = HeaderMap::new();
1937 h.insert(
1938 AUTHORIZATION,
1939 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1940 );
1941 h
1942 };
1943 let req = || BootstrapRequest {
1944 roles: vec!["admin".to_string()],
1945 ttl_secs: None,
1946 };
1947
1948 let bad = bootstrap_token(
1950 State(deploy.clone()),
1951 Extension(issuer.clone()),
1952 Extension(gate.clone()),
1953 bearer("wrong"),
1954 Json(req()),
1955 )
1956 .await;
1957 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1958
1959 let ok = bootstrap_token(
1961 State(deploy.clone()),
1962 Extension(issuer.clone()),
1963 Extension(gate.clone()),
1964 bearer(secret),
1965 Json(req()),
1966 )
1967 .await;
1968 assert_eq!(ok.status(), StatusCode::CREATED);
1969 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1970 .await
1971 .unwrap();
1972 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1973 let token = json["token"].as_str().unwrap();
1974 let id = json["id"].as_str().unwrap();
1975 let verified = cose::verify(token, &public, now_unix()).unwrap();
1976 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1977 assert!(deploy
1978 .list_token_meta()
1979 .await
1980 .unwrap()
1981 .iter()
1982 .any(|m| m.revocation_id == id));
1983
1984 let reuse = bootstrap_token(
1986 State(deploy.clone()),
1987 Extension(issuer.clone()),
1988 Extension(gate),
1989 bearer(secret),
1990 Json(req()),
1991 )
1992 .await;
1993 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1994
1995 let disabled = bootstrap_token(
1997 State(deploy),
1998 Extension(issuer),
1999 Extension(BootstrapGate(None)),
2000 bearer(secret),
2001 Json(req()),
2002 )
2003 .await;
2004 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2005 }
2006
2007 #[tokio::test]
2010 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2011 let control = Arc::new(StubControl {
2012 admits: std::sync::Mutex::new(Vec::new()),
2013 respond: StubJoin::Admit,
2014 });
2015 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2016 assert_eq!(resp.status(), StatusCode::OK);
2017 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2018 .await
2019 .unwrap();
2020 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2021 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2022
2023 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2024 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2025 }
2026
2027 #[test]
2028 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2029 use boatramp_core::security::SecurityProfile;
2030 let strict = SecurityProfile::MultiTenant.preset();
2031 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
2035 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2036 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2037
2038 assert!(gateway_addr_allowed(public, &strict));
2040 assert!(!gateway_addr_allowed(private, &strict));
2041 assert!(!gateway_addr_allowed(loopback, &strict));
2042 assert!(!gateway_addr_allowed(metadata, &strict));
2043
2044 assert!(gateway_addr_allowed(public, &loose));
2047 assert!(gateway_addr_allowed(private, &loose));
2048 assert!(gateway_addr_allowed(loopback, &loose));
2049 assert!(!gateway_addr_allowed(metadata, &loose));
2050 }
2051
2052 #[test]
2053 fn resolve_env_merges_static_and_host_secrets() {
2054 use boatramp_core::config::HandlersSiteConfig;
2055
2056 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2058
2059 let deploy_env = std::collections::BTreeMap::from([
2060 ("GREETING".to_string(), "hi".to_string()),
2061 ("OVERRIDE_ME".to_string(), "static".to_string()),
2062 ]);
2063 let site_handlers = HandlersSiteConfig {
2064 enabled: true,
2065 secrets: std::collections::BTreeMap::from([
2066 (
2068 "SECRET_TOKEN".to_string(),
2069 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2070 ),
2071 (
2072 "OVERRIDE_ME".to_string(),
2073 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2074 ),
2075 (
2076 "MISSING".to_string(),
2077 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2078 ),
2079 ]),
2080 ..Default::default()
2081 };
2082 let env = resolve_env("blog", &deploy_env, &site_handlers);
2083
2084 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2088 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2089 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2090 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2091
2092 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2093 }
2094
2095 fn req() -> Request {
2096 Request::builder()
2097 .uri("/")
2098 .header(header::HOST, "example.com")
2099 .body(Body::empty())
2100 .unwrap()
2101 }
2102
2103 #[test]
2104 fn forwarded_headers_set_standard_triple() {
2105 let mut request = req();
2106 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2107 let h = request.headers();
2108 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2109 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2110 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2111 }
2112
2113 #[test]
2114 fn forwarded_for_overwrites_spoofed_value() {
2115 let mut request = Request::builder()
2118 .uri("/")
2119 .header(header::HOST, "example.com")
2120 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2121 .body(Body::empty())
2122 .unwrap();
2123 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2124 let values: Vec<_> = request
2125 .headers()
2126 .get_all("x-forwarded-for")
2127 .iter()
2128 .collect();
2129 assert_eq!(values.len(), 1);
2130 assert_eq!(values[0], "203.0.113.7");
2131 }
2132
2133 #[test]
2134 fn forwarded_proto_preserves_upstream_tls() {
2135 let mut request = Request::builder()
2137 .uri("/")
2138 .header(header::HOST, "example.com")
2139 .header("x-forwarded-proto", "https")
2140 .body(Body::empty())
2141 .unwrap();
2142 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2143 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2144 }
2145
2146 #[test]
2147 fn forwarded_host_absent_when_no_host_header() {
2148 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2149 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2150 assert!(request.headers().get("x-forwarded-host").is_none());
2151 assert_eq!(
2152 request.headers().get("x-forwarded-for").unwrap(),
2153 "203.0.113.7"
2154 );
2155 }
2156
2157 use boatramp_core::kv::{KvStore, MemoryKv};
2160 use boatramp_core::messaging::{LogMessaging, Messaging};
2161 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2162
2163 const EVENT_CONSUMER: &[u8] =
2164 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2165
2166 #[derive(Default)]
2167 struct MemStorage {
2168 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2169 }
2170
2171 #[async_trait::async_trait]
2172 impl boatramp_core::Storage for MemStorage {
2173 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2174 let bytes = self
2175 .objects
2176 .lock()
2177 .unwrap()
2178 .get(key)
2179 .cloned()
2180 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2181 let body: ByteStream =
2182 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2183 Ok(GetObject {
2184 meta: ObjectMeta {
2185 key: key.to_string(),
2186 ..Default::default()
2187 },
2188 body,
2189 })
2190 }
2191 async fn get_range(
2192 &self,
2193 key: &str,
2194 _: u64,
2195 _: Option<u64>,
2196 ) -> Result<GetObject, StorageError> {
2197 self.get(key).await
2198 }
2199 async fn put(
2200 &self,
2201 key: &str,
2202 mut body: ByteStream,
2203 _: PutMeta,
2204 ) -> Result<ObjectMeta, StorageError> {
2205 use futures::StreamExt;
2206 let mut buf = Vec::new();
2207 while let Some(chunk) = body.next().await {
2208 buf.extend_from_slice(&chunk?);
2209 }
2210 self.objects.lock().unwrap().insert(key.to_string(), buf);
2211 Ok(ObjectMeta {
2212 key: key.to_string(),
2213 ..Default::default()
2214 })
2215 }
2216 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2217 self.objects
2218 .lock()
2219 .unwrap()
2220 .get(key)
2221 .map(|_| ObjectMeta {
2222 key: key.to_string(),
2223 ..Default::default()
2224 })
2225 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2226 }
2227 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2228 self.objects.lock().unwrap().remove(key);
2229 Ok(())
2230 }
2231 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2232 Ok(Vec::new())
2233 }
2234 }
2235
2236 fn observed_state(
2238 workload: &str,
2239 healthy: bool,
2240 phase: boatramp_core::compute::ReplicaPhase,
2241 ) -> boatramp_core::compute::ObservedInstance {
2242 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2243 boatramp_core::compute::ObservedInstance {
2244 handle: InstanceHandle {
2245 workload: workload.into(),
2246 replica: 0,
2247 backend_ref: "ref-0".into(),
2248 },
2249 node: 1,
2250 backend: "vmm".into(),
2251 endpoint: Endpoint {
2252 scheme: Scheme::Http,
2253 host: "10.0.0.2".into(),
2254 port: 80,
2255 },
2256 region: None,
2257 healthy,
2258 phase,
2259 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2260 workload: workload.into(),
2261 replica: 0,
2262 data_ref: "snap-0".into(),
2263 }),
2264 }
2265 }
2266
2267 #[tokio::test]
2268 async fn has_parked_replica_detects_a_zeroed_replica() {
2269 use boatramp_core::compute::ReplicaPhase;
2270 let storage = Arc::new(MemStorage::default());
2271 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2272 let deploy = DeployStore::new(storage, kv);
2273
2274 assert!(!has_parked_replica(&deploy, "w").await);
2276 deploy
2278 .set_replica_state(
2279 ProjectRef::DEFAULT,
2280 &observed_state("w", true, ReplicaPhase::Running),
2281 )
2282 .await
2283 .unwrap();
2284 assert!(!has_parked_replica(&deploy, "w").await);
2285 deploy
2287 .set_replica_state(
2288 ProjectRef::DEFAULT,
2289 &observed_state("w", false, ReplicaPhase::Zero),
2290 )
2291 .await
2292 .unwrap();
2293 assert!(has_parked_replica(&deploy, "w").await);
2294 }
2295
2296 #[tokio::test]
2297 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2298 use boatramp_core::compute::ReplicaPhase;
2299 let storage = Arc::new(MemStorage::default());
2300 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2301 let deploy = DeployStore::new(storage, kv);
2302
2303 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2305 assert!(empty.is_empty());
2306
2307 deploy
2309 .set_replica_state(
2310 ProjectRef::DEFAULT,
2311 &observed_state("w", true, ReplicaPhase::Running),
2312 )
2313 .await
2314 .unwrap();
2315 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2316 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2317 }
2318
2319 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2323 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2324 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2325 let storage = Arc::new(MemStorage::default());
2326 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2327 let mq = LogMessaging::new(storage, kv.clone());
2328 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2329 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2330 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2331 let topic = "blog/orders/created";
2332
2333 for _ in 0..3 {
2335 mq.publish(topic, b"ok").await.unwrap();
2336 }
2337 loop {
2338 let acked = dispatch_consumer_batch(
2339 &engine,
2340 &mq,
2341 &metrics::Metrics::default(),
2342 "blog",
2343 topic,
2344 "blog/",
2345 "",
2346 boatramp_core::messaging::StartPosition::Latest,
2347 &hash,
2348 EVENT_CONSUMER,
2349 &bindings,
2350 Limits::default(),
2351 Duration::from_secs(30),
2352 5,
2353 10,
2354 )
2355 .await;
2356 if acked == 0 {
2357 break;
2358 }
2359 }
2360 assert_eq!(
2361 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2362 Some(b"3".to_vec())
2363 );
2364
2365 mq.publish(topic, b"fail").await.unwrap();
2368 for _ in 0..5 {
2369 dispatch_consumer_batch(
2370 &engine,
2371 &mq,
2372 &metrics::Metrics::default(),
2373 "blog",
2374 topic,
2375 "blog/",
2376 "",
2377 boatramp_core::messaging::StartPosition::Latest,
2378 &hash,
2379 EVENT_CONSUMER,
2380 &bindings,
2381 Limits::default(),
2382 Duration::ZERO,
2383 2,
2384 10,
2385 )
2386 .await;
2387 }
2388 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2389 assert_eq!(
2391 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2392 Some(b"3".to_vec())
2393 );
2394 }
2395
2396 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2401 async fn consumer_groups_fan_out_through_the_dispatcher() {
2402 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2403 let storage = Arc::new(MemStorage::default());
2404 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2405 let mq = LogMessaging::new(storage, kv.clone());
2406 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2407 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2408 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2409 let topic = "blog/orders/created";
2410 let start = boatramp_core::messaging::StartPosition::Latest;
2411
2412 for g in ["billing", "audit"] {
2415 let n = dispatch_consumer_batch(
2416 &engine,
2417 &mq,
2418 &metrics::Metrics::default(),
2419 "blog",
2420 topic,
2421 "blog/",
2422 g,
2423 start,
2424 &hash,
2425 EVENT_CONSUMER,
2426 &bindings,
2427 Limits::default(),
2428 Duration::from_secs(30),
2429 5,
2430 10,
2431 )
2432 .await;
2433 assert_eq!(n, 0, "no events yet for group {g}");
2434 }
2435 mq.publish(topic, b"ok").await.unwrap();
2436
2437 for g in ["billing", "audit"] {
2439 let n = dispatch_consumer_batch(
2440 &engine,
2441 &mq,
2442 &metrics::Metrics::default(),
2443 "blog",
2444 topic,
2445 "blog/",
2446 g,
2447 start,
2448 &hash,
2449 EVENT_CONSUMER,
2450 &bindings,
2451 Limits::default(),
2452 Duration::from_secs(30),
2453 5,
2454 10,
2455 )
2456 .await;
2457 assert_eq!(n, 1, "group {g} should receive the message");
2458 }
2459 assert_eq!(
2461 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2462 Some(b"2".to_vec())
2463 );
2464 }
2465
2466 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2470 async fn scheduler_runs_current_consumers_not_previews() {
2471 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2472 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2473 use boatramp_handlers::{HandlerEngine, Limits};
2474 use futures::StreamExt;
2475
2476 let storage = Arc::new(MemStorage::default());
2477 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2478 let deploy = DeployStore::new(storage.clone(), kv.clone());
2479 let messaging: Arc<dyn Messaging> =
2480 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2481
2482 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2484 let stream: ByteStream =
2485 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2486 .boxed();
2487 deploy.put_blob(&hash, stream).await.unwrap();
2488 let mut files = std::collections::BTreeMap::new();
2489 files.insert(
2490 "consumer.wasm".to_string(),
2491 FileEntry {
2492 hash: hash.clone(),
2493 size: EVENT_CONSUMER.len() as u64,
2494 content_type: None,
2495 variants: std::collections::BTreeMap::new(),
2496 },
2497 );
2498 let manifest = Manifest {
2499 files,
2500 config: DeployConfig {
2501 consumers: vec![ConsumerConfig {
2502 topic: "orders/created".into(),
2503 component: "consumer.wasm".into(),
2504 imports: vec!["wasi:keyvalue".into()],
2505 group: String::new(),
2506 start: Default::default(),
2507 }],
2508 ..Default::default()
2509 },
2510 ..Default::default()
2511 };
2512 let id = deploy.put_manifest(&manifest).await.unwrap();
2513 deploy
2514 .activate(ProjectRef::DEFAULT, "blog", &id)
2515 .await
2516 .unwrap();
2517 deploy
2518 .set_site_config(
2519 ProjectRef::DEFAULT,
2520 "blog",
2521 &SiteConfig {
2522 handlers: Some(HandlersSiteConfig {
2523 enabled: true,
2524 allow_imports: vec!["wasi:keyvalue".into()],
2525 ..Default::default()
2526 }),
2527 ..Default::default()
2528 },
2529 )
2530 .await
2531 .unwrap();
2532
2533 messaging
2535 .publish("blog/orders/created", b"live")
2536 .await
2537 .unwrap();
2538 messaging
2539 .publish("blog/_preview/abc/orders/created", b"preview")
2540 .await
2541 .unwrap();
2542
2543 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2544 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2545 let inner = rt.inner.clone().unwrap();
2546 let mut cache = std::collections::HashMap::new();
2547 let mut crons = std::collections::HashMap::new();
2548 let mut sweep = std::collections::HashMap::new();
2549 let now = CronNow {
2550 minute: 0,
2551 hour: 0,
2552 dom: 1,
2553 month: 1,
2554 dow: 0,
2555 minute_stamp: 0,
2556 };
2557 for _ in 0..3 {
2558 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
2559 .await
2560 .unwrap();
2561 }
2562
2563 assert_eq!(
2565 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2566 Some(b"1".to_vec())
2567 );
2568 assert_eq!(
2571 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2572 .await
2573 .unwrap(),
2574 None
2575 );
2576 }
2577
2578 const KV_COUNTER: &[u8] =
2583 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2584
2585 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2590 async fn function_invoker_runs_target_buffers_and_meters() {
2591 use boatramp_core::deploy::DeployStore;
2592 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2593 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2594 use futures::StreamExt;
2595
2596 const HTTP_200: &[u8] =
2599 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2600
2601 let storage = Arc::new(MemStorage::default());
2602 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2603 let deploy = DeployStore::new(storage.clone(), kv.clone());
2604
2605 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2606 let stream: ByteStream =
2607 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2608 deploy.put_blob(&hash, stream).await.unwrap();
2609 let function = Function {
2610 name: "target".into(),
2611 owner: Owner::Project("default".into()),
2612 versions: vec![FunctionVersion {
2613 id: "v1".into(),
2614 component: hash.clone(),
2615 created: 0,
2616 lifecycle: Lifecycle::Independent,
2617 }],
2618 active: "v1".into(),
2619 aliases: Default::default(),
2620 config: Default::default(),
2621 };
2622 deploy
2623 .put_function(ProjectRef::DEFAULT, &function)
2624 .await
2625 .unwrap();
2626
2627 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2628 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2629 rt.set_invoker(deploy.clone());
2630 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2631
2632 let request = || InvokeRequest {
2633 method: "GET".into(),
2634 path: "/".into(),
2635 headers: vec![],
2636 body: vec![],
2637 };
2638
2639 let response = invoker.invoke("target", request(), 1).await.unwrap();
2641 assert_eq!(response.status, 200);
2642
2643 let metering = deploy
2645 .get_metering(ProjectRef::DEFAULT, "target")
2646 .await
2647 .unwrap()
2648 .unwrap();
2649 assert_eq!(metering.invocations, 1);
2650
2651 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2653 assert!(matches!(err, InvokeError::NotFound));
2654 }
2655
2656 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2662 async fn federation_runner_enforces_the_safelist_before_planning() {
2663 use boatramp_core::deploy::DeployStore;
2664 use boatramp_core::project::ProjectRef;
2665 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2666
2667 let storage = Arc::new(MemStorage::default());
2668 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2669 let deploy = DeployStore::new(storage.clone(), kv.clone());
2670 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2671 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2672 rt.set_invoker(deploy.clone());
2673 let runner = rt
2674 .inner
2675 .as_ref()
2676 .unwrap()
2677 .federation_runner
2678 .get()
2679 .unwrap()
2680 .scoped(ProjectRef::new("default"));
2681
2682 let req = |query: &str| GraphqlRequest {
2683 query: Some(query.to_string()),
2684 persisted_hash: None,
2685 variables: "{}".to_string(),
2686 operation_name: None,
2687 authorization: None,
2688 };
2689
2690 assert!(matches!(
2692 runner.run(req("{ me { id } }"), 1).await,
2693 Err(SupergraphRunError::NotSafelisted)
2694 ));
2695
2696 let query = "{ me { id } }";
2699 let hash = crate::graphql_apq::sha256_hex(query);
2700 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2701 .await
2702 .unwrap();
2703 assert!(matches!(
2704 runner.run(req(query), 1).await,
2705 Err(SupergraphRunError::PlanFailed(_))
2706 ));
2707
2708 let persisted = GraphqlRequest {
2710 query: None,
2711 persisted_hash: Some("deadbeef".to_string()),
2712 variables: "{}".to_string(),
2713 operation_name: None,
2714 authorization: None,
2715 };
2716 assert!(matches!(
2717 runner.run(persisted, 1).await,
2718 Err(SupergraphRunError::NotSafelisted)
2719 ));
2720 }
2721
2722 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2728 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2729 use crate::scheduler::{run_scheduler_tick, CronNow};
2730 use boatramp_core::deploy::DeployStore;
2731 use boatramp_core::function::{
2732 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2733 };
2734 use boatramp_handlers::{HandlerEngine, Limits};
2735 use futures::StreamExt;
2736
2737 const HTTP_200: &[u8] =
2738 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2739
2740 let storage = Arc::new(MemStorage::default());
2741 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2742 let deploy = DeployStore::new(storage.clone(), kv.clone());
2743
2744 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2745 let stream: ByteStream =
2746 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2747 deploy.put_blob(&hash, stream).await.unwrap();
2748
2749 let acme = ProjectRef::new("acme");
2751 let function = Function {
2752 name: "worker".into(),
2753 owner: Owner::Project("acme".into()),
2754 versions: vec![FunctionVersion {
2755 id: "v1".into(),
2756 component: hash.clone(),
2757 created: 0,
2758 lifecycle: Lifecycle::Independent,
2759 }],
2760 active: "v1".into(),
2761 aliases: Default::default(),
2762 config: Default::default(),
2763 };
2764 deploy.put_function(acme, &function).await.unwrap();
2765 let inv = Invocation {
2766 id: "inv1".into(),
2767 function: "worker".into(),
2768 version: "v1".into(),
2769 mode: InvokeMode::Async,
2770 status: InvocationStatus::Queued,
2771 idempotency_key: None,
2772 attempts: 0,
2773 lease_expires: None,
2774 request_b64: None,
2775 request_content_type: None,
2776 result: None,
2777 created: 0,
2778 updated: 0,
2779 };
2780 deploy.put_invocation(acme, &inv).await.unwrap();
2781
2782 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2783 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2784 let inner = rt.inner.as_ref().unwrap();
2785
2786 let mut wasm_cache = std::collections::HashMap::new();
2789 let mut cron_state = std::collections::HashMap::new();
2790 let mut sweep = std::collections::HashMap::new();
2791 let now = CronNow {
2792 minute: 0,
2793 hour: 0,
2794 dom: 1,
2795 month: 1,
2796 dow: 0,
2797 minute_stamp: 0,
2798 };
2799 run_scheduler_tick(
2800 inner,
2801 &deploy,
2802 &mut wasm_cache,
2803 &mut cron_state,
2804 &mut sweep,
2805 now,
2806 )
2807 .await
2808 .unwrap();
2809
2810 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
2813 assert_eq!(settled.status, InvocationStatus::Succeeded);
2815 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2817 assert_eq!(metering.invocations, 1);
2818 assert!(deploy
2820 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2821 .await
2822 .unwrap()
2823 .is_none());
2824 assert!(deploy
2825 .get_metering(ProjectRef::DEFAULT, "worker")
2826 .await
2827 .unwrap()
2828 .is_none());
2829 }
2830
2831 #[cfg(feature = "handlers")]
2835 async fn poll_invocation_settled(
2836 deploy: &boatramp_core::deploy::DeployStore,
2837 project: ProjectRef<'_>,
2838 function: &str,
2839 id: &str,
2840 ) -> boatramp_core::function::Invocation {
2841 use boatramp_core::function::InvocationStatus;
2842 for _ in 0..200 {
2843 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
2844 if matches!(
2845 inv.status,
2846 InvocationStatus::Succeeded | InvocationStatus::Failed
2847 ) {
2848 return inv;
2849 }
2850 }
2851 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2852 }
2853 panic!("invocation {function}/{id} never settled");
2854 }
2855
2856 #[cfg(feature = "handlers")]
2861 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2862 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
2863 use crate::scheduler::{run_scheduler_tick, CronNow};
2864 use boatramp_core::deploy::DeployStore;
2865 use boatramp_core::function::{
2866 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2867 };
2868 use boatramp_handlers::{HandlerEngine, Limits};
2869 use futures::StreamExt;
2870
2871 const HTTP_200: &[u8] =
2872 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2873
2874 let storage = Arc::new(MemStorage::default());
2875 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2876 let deploy = DeployStore::new(storage.clone(), kv.clone());
2877 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2878 let stream: ByteStream =
2879 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2880 deploy.put_blob(&hash, stream).await.unwrap();
2881
2882 let function = Function {
2883 name: "worker".into(),
2884 owner: Owner::Project("default".into()),
2885 versions: vec![FunctionVersion {
2886 id: "v1".into(),
2887 component: hash.clone(),
2888 created: 0,
2889 lifecycle: Lifecycle::Independent,
2890 }],
2891 active: "v1".into(),
2892 aliases: Default::default(),
2893 config: Default::default(),
2894 };
2895 deploy
2896 .put_function(ProjectRef::DEFAULT, &function)
2897 .await
2898 .unwrap();
2899
2900 let base = Invocation {
2903 id: String::new(),
2904 function: "worker".into(),
2905 version: "v1".into(),
2906 mode: InvokeMode::Async,
2907 status: InvocationStatus::Running,
2908 idempotency_key: None,
2909 attempts: 1,
2910 lease_expires: None,
2911 request_b64: None,
2912 request_content_type: None,
2913 result: None,
2914 created: 0,
2915 updated: 0,
2916 };
2917 let orphan = Invocation {
2918 id: "orphan".into(),
2919 lease_expires: Some(1),
2920 ..base.clone()
2921 };
2922 deploy
2923 .put_invocation(ProjectRef::DEFAULT, &orphan)
2924 .await
2925 .unwrap();
2926 let live = Invocation {
2927 id: "live".into(),
2928 lease_expires: Some(u64::MAX),
2929 ..base.clone()
2930 };
2931 deploy
2932 .put_invocation(ProjectRef::DEFAULT, &live)
2933 .await
2934 .unwrap();
2935
2936 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2937 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2938 let inner = rt.inner.as_ref().unwrap();
2939
2940 let now = CronNow {
2941 minute: 0,
2942 hour: 0,
2943 dom: 1,
2944 month: 1,
2945 dow: 0,
2946 minute_stamp: 0,
2947 };
2948 let mut wasm_cache = std::collections::HashMap::new();
2949 let mut cron_state = std::collections::HashMap::new();
2950 let mut sweep = std::collections::HashMap::new();
2951 run_scheduler_tick(
2952 inner,
2953 &deploy,
2954 &mut wasm_cache,
2955 &mut cron_state,
2956 &mut sweep,
2957 now,
2958 )
2959 .await
2960 .unwrap();
2961
2962 let settled =
2964 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
2965 assert_eq!(settled.status, InvocationStatus::Succeeded);
2966 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
2967 assert_eq!(
2968 settled.lease_expires, None,
2969 "a settled invocation drops its lease"
2970 );
2971 let live_after = deploy
2973 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
2974 .await
2975 .unwrap()
2976 .unwrap();
2977 assert_eq!(live_after.status, InvocationStatus::Running);
2978 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
2979 assert_eq!(live_after.lease_expires, Some(u64::MAX));
2980 }
2981
2982 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2997 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2998 use boatramp_core::deploy::DeployStore;
2999 use boatramp_core::function::{
3000 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3001 };
3002 use boatramp_handlers::{HandlerEngine, Limits};
3003 use futures::StreamExt;
3004
3005 let storage = Arc::new(MemStorage::default());
3006 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3007 let deploy = DeployStore::new(storage.clone(), kv.clone());
3008
3009 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3012 let stream: ByteStream =
3013 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3014 deploy.put_blob(&hash, stream).await.unwrap();
3015
3016 let store = Function {
3021 name: "store".into(),
3022 owner: Owner::Project("default".into()),
3023 versions: vec![FunctionVersion {
3024 id: "v1".into(),
3025 component: hash.clone(),
3026 created: 0,
3027 lifecycle: Lifecycle::Independent,
3028 }],
3029 active: "v1".into(),
3030 aliases: Default::default(),
3031 config: FunctionConfig {
3032 imports: vec!["wasi:keyvalue".into()],
3033 ..Default::default()
3034 },
3035 };
3036 let acme = ProjectRef::new("acme");
3037 let globex = ProjectRef::new("globex");
3038
3039 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3040 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3041 let inner = rt.inner.as_ref().unwrap();
3042
3043 let request = || {
3044 axum::http::Request::builder()
3045 .method("GET")
3046 .uri("/")
3047 .body(axum::body::Body::empty())
3048 .unwrap()
3049 };
3050
3051 let component = store.resolve(&store.active).unwrap().to_owned();
3054 for project in [acme, globex, ProjectRef::DEFAULT] {
3055 let (response, _) = execute_function(
3056 inner,
3057 &deploy,
3058 project,
3059 &store,
3060 &component,
3061 request(),
3062 0,
3063 boatramp_handlers::Lane::Sync,
3064 )
3065 .await;
3066 assert!(response.status().is_success(), "invocation should succeed");
3067 }
3068
3069 assert_eq!(
3072 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3073 Some(b"1".to_vec()),
3074 "acme's write must be tenant-qualified"
3075 );
3076 assert_eq!(
3077 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3078 Some(b"1".to_vec()),
3079 "globex's write must be tenant-qualified"
3080 );
3081 assert_eq!(
3082 kv.get("hkv/fn/store/hits").await.unwrap(),
3083 Some(b"1".to_vec()),
3084 "the default project must keep the byte-identical pre-project key"
3085 );
3086 }
3089
3090 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3094 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3095 use boatramp_core::config::{
3096 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3097 };
3098 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3099 use boatramp_handlers::{HandlerEngine, Limits};
3100 use futures::StreamExt;
3101 use std::sync::atomic::Ordering;
3102
3103 let storage = Arc::new(MemStorage::default());
3104 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3105 let deploy = DeployStore::new(storage.clone(), kv.clone());
3106
3107 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3108 let stream: ByteStream =
3109 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3110 deploy.put_blob(&hash, stream).await.unwrap();
3111 let mut files = std::collections::BTreeMap::new();
3112 files.insert(
3113 "counter.wasm".to_string(),
3114 FileEntry {
3115 hash: hash.clone(),
3116 size: KV_COUNTER.len() as u64,
3117 content_type: None,
3118 variants: std::collections::BTreeMap::new(),
3119 },
3120 );
3121 let manifest = Manifest {
3122 files,
3123 config: DeployConfig {
3124 handlers: vec![HandlerConfig {
3125 route: "/".into(),
3126 methods: Vec::new(),
3127 component: "counter.wasm".into(),
3128 imports: vec!["wasi:keyvalue".into()],
3129 streaming: false,
3130 limits: None,
3131 env: std::collections::BTreeMap::new(),
3132 invoke_targets: Vec::new(),
3133 }],
3134 crons: vec![CronConfig {
3135 schedule: "* * * * *".into(),
3136 route: "/".into(),
3137 overlap: Overlap::Skip,
3138 }],
3139 ..Default::default()
3140 },
3141 ..Default::default()
3142 };
3143 let id = deploy.put_manifest(&manifest).await.unwrap();
3144 deploy
3145 .activate(ProjectRef::DEFAULT, "blog", &id)
3146 .await
3147 .unwrap();
3148 deploy
3149 .set_site_config(
3150 ProjectRef::DEFAULT,
3151 "blog",
3152 &SiteConfig {
3153 handlers: Some(HandlersSiteConfig {
3154 enabled: true,
3155 allow_imports: vec!["wasi:keyvalue".into()],
3156 ..Default::default()
3157 }),
3158 ..Default::default()
3159 },
3160 )
3161 .await
3162 .unwrap();
3163
3164 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3165 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3166 let inner = rt.inner.clone().unwrap();
3167 let mut wasm = std::collections::HashMap::new();
3168 let mut crons = std::collections::HashMap::new();
3169 let mut sweep = std::collections::HashMap::new();
3170 let at = |stamp| CronNow {
3171 minute: 0,
3172 hour: 0,
3173 dom: 1,
3174 month: 1,
3175 dow: 0,
3176 minute_stamp: stamp,
3177 };
3178
3179 let (_, handles) =
3181 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3182 .await
3183 .unwrap();
3184 for h in handles {
3185 h.await.unwrap();
3186 }
3187 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3188
3189 let (_, handles) =
3191 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3192 .await
3193 .unwrap();
3194 assert!(handles.is_empty());
3195 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3196
3197 let (_, handles) =
3199 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3200 .await
3201 .unwrap();
3202 for h in handles {
3203 h.await.unwrap();
3204 }
3205 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3206
3207 crons
3211 .get("default|blog|cron|0")
3212 .unwrap()
3213 .running
3214 .store(true, Ordering::Release);
3215 let (_, handles) =
3216 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3217 .await
3218 .unwrap();
3219 assert!(handles.is_empty());
3220 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3221 }
3222
3223 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3227 async fn cron_leader_gate_suppresses_crons_off_leader() {
3228 use boatramp_core::config::{
3229 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3230 };
3231 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3232 use boatramp_handlers::{HandlerEngine, Limits};
3233 use futures::StreamExt;
3234
3235 let storage = Arc::new(MemStorage::default());
3236 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3237 let deploy = DeployStore::new(storage.clone(), kv.clone());
3238
3239 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3240 let stream: ByteStream =
3241 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3242 deploy.put_blob(&hash, stream).await.unwrap();
3243 let mut files = std::collections::BTreeMap::new();
3244 files.insert(
3245 "counter.wasm".to_string(),
3246 FileEntry {
3247 hash: hash.clone(),
3248 size: KV_COUNTER.len() as u64,
3249 content_type: None,
3250 variants: std::collections::BTreeMap::new(),
3251 },
3252 );
3253 let manifest = Manifest {
3254 files,
3255 config: DeployConfig {
3256 handlers: vec![HandlerConfig {
3257 route: "/".into(),
3258 methods: Vec::new(),
3259 component: "counter.wasm".into(),
3260 imports: vec!["wasi:keyvalue".into()],
3261 streaming: false,
3262 limits: None,
3263 env: std::collections::BTreeMap::new(),
3264 invoke_targets: Vec::new(),
3265 }],
3266 crons: vec![CronConfig {
3267 schedule: "* * * * *".into(),
3268 route: "/".into(),
3269 overlap: Overlap::Skip,
3270 }],
3271 ..Default::default()
3272 },
3273 ..Default::default()
3274 };
3275 let id = deploy.put_manifest(&manifest).await.unwrap();
3276 deploy
3277 .activate(ProjectRef::DEFAULT, "blog", &id)
3278 .await
3279 .unwrap();
3280 deploy
3281 .set_site_config(
3282 ProjectRef::DEFAULT,
3283 "blog",
3284 &SiteConfig {
3285 handlers: Some(HandlersSiteConfig {
3286 enabled: true,
3287 allow_imports: vec!["wasi:keyvalue".into()],
3288 ..Default::default()
3289 }),
3290 ..Default::default()
3291 },
3292 )
3293 .await
3294 .unwrap();
3295
3296 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3297 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3298 rt.set_cron_leader_gate(Arc::new(|| false));
3300 let inner = rt.inner.clone().unwrap();
3301 let mut wasm = std::collections::HashMap::new();
3302 let mut crons = std::collections::HashMap::new();
3303 let mut sweep = std::collections::HashMap::new();
3304 let now = CronNow {
3305 minute: 0,
3306 hour: 0,
3307 dom: 1,
3308 month: 1,
3309 dow: 0,
3310 minute_stamp: 100,
3311 };
3312
3313 let (_, handles) =
3314 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3315 .await
3316 .unwrap();
3317 assert!(handles.is_empty(), "a non-leader must not fire crons");
3319 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3320 }
3321
3322 #[tokio::test]
3329 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3330 use boatramp_core::config::HandlersSiteConfig;
3331 use boatramp_core::project::ProjectRef;
3332 use boatramp_handlers::{HandlerEngine, Limits};
3333
3334 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3335 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3336 let sql_dir =
3338 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3339 let _ = std::fs::remove_dir_all(&sql_dir);
3340 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3341 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3342
3343 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3344 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3345 let inner = rt.inner.as_ref().unwrap();
3346
3347 let site = HandlersSiteConfig {
3349 enabled: true,
3350 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3351 ..Default::default()
3352 };
3353 let env = std::collections::BTreeMap::new();
3354 let build = |imports: &[&str]| {
3355 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3356 let site = &site;
3357 let env = &env;
3358 async move {
3359 crate::handler_dispatch::build_bindings(
3360 inner,
3361 ProjectRef::new("default"),
3362 "shop",
3363 "shop",
3364 None,
3365 &imports,
3366 site,
3367 env,
3368 &[],
3369 0,
3370 None,
3371 )
3372 .await
3373 .sql_database_names()
3374 }
3375 };
3376
3377 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3380 assert_eq!(
3382 build(&["sql", "sql:*"]).await,
3383 vec!["", "privileged", "product"]
3384 );
3385 assert!(build(&["sql:secret"]).await.is_empty());
3387 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3389
3390 let _ = std::fs::remove_dir_all(&sql_dir);
3391 }
3392}